refactor: migrate from Navigation Compose 2 to Navigation Compose 3
This commit is contained in:
parent
2756efa812
commit
40ee348b12
62 changed files with 1747 additions and 1127 deletions
|
|
@ -4,18 +4,19 @@ Native mobile client for LibreChat (Android & iOS). Connects to existing LibreCh
|
|||
|
||||
## Tech Stack
|
||||
|
||||
- **UI**: Jetpack Compose + Compose Navigation
|
||||
- **UI**: Jetpack Compose + Navigation Compose 3 (Nav 3)
|
||||
- **DI**: Koin (KMP-ready)
|
||||
- **Network**: Ktor Client (OkHttp engine)
|
||||
- **Serialization**: Kotlinx Serialization
|
||||
- **Local Storage**: Room (cache), DataStore (prefs), EncryptedSharedPreferences (tokens)
|
||||
- **Build**: Gradle 8.11.1, AGP 8.7.3, Kotlin 2.1.0, compileSdk 35, minSdk 26
|
||||
- **Build**: Gradle 9.4.1, AGP 9.1.0, Kotlin 2.3.20, compileSdk 36, minSdk 26
|
||||
|
||||
## Module Layout
|
||||
|
||||
```
|
||||
app/ → Single Activity, adaptive navigation (phone/tablet)
|
||||
build-logic/ → 7 convention plugins for consistent Gradle config
|
||||
shared/ → KMP shared framework (iOS entry point, shared navigation)
|
||||
build-logic/ → 13 convention plugins for consistent Gradle config
|
||||
core/common/ → Result type, dispatcher DI, coroutine scopes, extensions
|
||||
core/model/ → @Serializable data classes (pure Kotlin, no Android deps)
|
||||
core/network/ → Ktor client, 16 API services, SSE client, auth interceptor
|
||||
|
|
@ -34,7 +35,7 @@ Each module has its own `CLAUDE.md` with specific guidance.
|
|||
## Architecture Rules
|
||||
|
||||
- Feature modules depend on `:core:*` only, never on each other
|
||||
- Single Activity with Compose Navigation
|
||||
- Single Activity with Nav 3 (`NavDisplay` + `NavBackStack<NavKey>` + `entryProvider`)
|
||||
- Unidirectional data flow: UI → ViewModel → Repository → API/Room
|
||||
- Room is a read-through cache; server is source of truth
|
||||
- Custom SSE parser over raw ByteReadChannel (not Ktor SSE plugin)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
# Known Issues
|
||||
|
||||
## Tablet back navigation doesn't dismiss sidebar first
|
||||
|
||||
**Status**: Known issue, to be fixed later
|
||||
|
||||
**Symptoms**: In tablet mode with the sidebar open, pressing back navigates away from the current chat instead of dismissing the sidebar first. The sidebar remains covering the screen.
|
||||
|
||||
**Root cause**: The `BackHandler` in `TabletLayout.kt` is present but the system navigation's back handler takes priority. Needs investigation into back handler dispatch ordering relative to the NavHost.
|
||||
|
||||
**Workaround**: Tap the hamburger menu button to toggle the sidebar closed.
|
||||
|
||||
## Chat search: sub-message scroll precision
|
||||
|
||||
|
|
|
|||
841
NAV3_RESEARCH.md
Normal file
841
NAV3_RESEARCH.md
Normal file
|
|
@ -0,0 +1,841 @@
|
|||
# Navigation Compose 3: Research Report
|
||||
|
||||
> Compiled 2026-04-03. Sources: Android Developer docs, JetBrains Kotlin docs, nav3-recipes repo, Android Developers Blog, community blog posts, and release notes.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#1-overview)
|
||||
2. [Core Concepts & API](#2-core-concepts--api)
|
||||
3. [Best Practices](#3-best-practices)
|
||||
4. [Migration Guide (Nav 2 → Nav 3)](#4-migration-guide-nav-2--nav-3)
|
||||
5. [Gotchas, Footguns & Complications](#5-gotchas-footguns--complications)
|
||||
6. [KMP / Compose Multiplatform Considerations](#6-kmp--compose-multiplatform-considerations)
|
||||
7. [Appendix: Version & Dependency Reference](#7-appendix-version--dependency-reference)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
### What is Navigation 3?
|
||||
|
||||
Navigation 3 (Nav3) is a **ground-up rewrite** of Android's Jetpack Navigation, designed specifically for Jetpack Compose. Released as stable **1.0.0 on November 19, 2025**, it replaces the controller-based paradigm of Nav 2 with a **state-based, declarative approach** where the developer owns the back stack directly.
|
||||
|
||||
- **Stable release**: 1.0.1 (Feb 11, 2026)
|
||||
- **Latest**: 1.1.0-rc01 (Mar 25, 2026)
|
||||
- **Min SDK**: 23
|
||||
- **Compile SDK**: 36+
|
||||
|
||||
### How It Differs from Nav 2
|
||||
|
||||
| Aspect | Nav 2 | Nav 3 |
|
||||
|--------|-------|-------|
|
||||
| **State ownership** | Library-owned (`NavController`) | Developer-owned (`SnapshotStateList`) |
|
||||
| **Back stack model** | Hidden internal structure | Transparent mutable list |
|
||||
| **Navigation API** | `navController.navigate()` | `backStack.add(key)` |
|
||||
| **Host component** | `NavHost` | `NavDisplay` |
|
||||
| **Route definition** | `composable<T> { }` in `NavHost` | `entry<T> { }` in `entryProvider` |
|
||||
| **Layout flexibility** | Single pane only | Multi-pane via Scenes API |
|
||||
| **Nested graphs** | `navigation<T> { }` | Not needed — flat entry list |
|
||||
| **Design philosophy** | Opinionated framework | Flexible building blocks |
|
||||
| **Compose alignment** | Bolted-on Compose support | Compose-first from scratch |
|
||||
|
||||
### Founding Principles
|
||||
|
||||
1. **You Own the Back Stack** — Navigation state is a `SnapshotStateList<T>` you control directly. Push = `add()`, pop = `removeLastOrNull()`.
|
||||
2. **Get Out of Your Way** — The library provides open, extensible building blocks rather than a black box.
|
||||
3. **Pick Your Building Blocks** — Composable components you combine for your specific needs, with recipes for complex scenarios.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
User Action → modify backStack (add/remove) → NavDisplay observes change → entryProvider resolves key → NavEntry.content rendered
|
||||
```
|
||||
|
||||
This follows **Unidirectional Data Flow (UDF)**, fully aligned with Compose's declarative model.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Concepts & API
|
||||
|
||||
### 2.1 Keys (Routes)
|
||||
|
||||
Routes in Nav 3 are simple Kotlin data types that implement `NavKey`:
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
data object Home : NavKey
|
||||
|
||||
@Serializable
|
||||
data class ProductDetail(val id: String) : NavKey
|
||||
|
||||
@Serializable
|
||||
data object Settings : NavKey
|
||||
```
|
||||
|
||||
Keys must be:
|
||||
- `@Serializable` (for state persistence)
|
||||
- Implement `NavKey` (marker interface for `rememberNavBackStack`)
|
||||
|
||||
### 2.2 Back Stack
|
||||
|
||||
The back stack is a `SnapshotStateList<T>` — you own it completely:
|
||||
|
||||
```kotlin
|
||||
// Simple (no persistence across process death)
|
||||
val backStack = remember { mutableStateListOf<Any>(Home) }
|
||||
|
||||
// Persistent (survives config changes + process death)
|
||||
val backStack = rememberNavBackStack(Home)
|
||||
```
|
||||
|
||||
Navigation is just list manipulation:
|
||||
```kotlin
|
||||
// Navigate forward
|
||||
backStack.add(ProductDetail(id = "123"))
|
||||
|
||||
// Navigate back
|
||||
backStack.removeLastOrNull()
|
||||
```
|
||||
|
||||
### 2.3 NavEntry
|
||||
|
||||
`NavEntry` wraps a composable function with its key and optional metadata:
|
||||
|
||||
```kotlin
|
||||
NavEntry(
|
||||
key = ProductDetail("123"),
|
||||
metadata = mapOf("type" to "detail")
|
||||
) {
|
||||
ProductDetailScreen()
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Entry Provider
|
||||
|
||||
Resolves keys to `NavEntry` objects. Two approaches:
|
||||
|
||||
**Lambda function:**
|
||||
```kotlin
|
||||
entryProvider = { key ->
|
||||
when (key) {
|
||||
is Home -> NavEntry(key) { HomeScreen() }
|
||||
is ProductDetail -> NavEntry(key) { ProductDetailScreen(key.id) }
|
||||
else -> NavEntry(Unit) { Text("Unknown") }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**DSL (recommended):**
|
||||
```kotlin
|
||||
entryProvider = entryProvider {
|
||||
entry<Home> { HomeScreen() }
|
||||
entry<ProductDetail> { key -> ProductDetailScreen(key.id) }
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 NavDisplay
|
||||
|
||||
The primary UI component that observes the back stack and renders content:
|
||||
|
||||
```kotlin
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
sceneStrategies = listOf(DialogSceneStrategy()),
|
||||
entryProvider = entryProvider {
|
||||
entry<Home> { HomeScreen() }
|
||||
entry<ProductDetail> { key -> ProductDetailScreen(key.id) }
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 2.6 NavEntry Decorators
|
||||
|
||||
Decorators wrap each `NavEntry` with additional logic (state holders, ViewModel stores, logging):
|
||||
|
||||
| Decorator | Purpose |
|
||||
|-----------|---------|
|
||||
| `rememberSaveableStateHolderNavEntryDecorator()` | Persists `rememberSaveable` state across config changes and process death. **Should be first in the list.** |
|
||||
| `rememberViewModelStoreNavEntryDecorator()` | Scopes `ViewModel`s to individual `NavEntry` lifecycle. Requires `androidx.lifecycle:lifecycle-viewmodel-navigation3` dependency. |
|
||||
| `rememberSharedViewModelStoreNavEntryDecorator()` | Enables ViewModel sharing between parent/child entries. |
|
||||
|
||||
Custom decorator example:
|
||||
```kotlin
|
||||
class LoggingDecorator<T : Any> : NavEntryDecorator<T>(
|
||||
decorate = { entry ->
|
||||
Log.d("Nav", "Entered ${entry.contentKey}")
|
||||
entry.Content()
|
||||
},
|
||||
onPop = { key -> Log.d("Nav", "Popped $key") }
|
||||
)
|
||||
```
|
||||
|
||||
### 2.7 Scenes & SceneStrategy
|
||||
|
||||
**Scenes** render one or more `NavEntry` instances. **SceneStrategies** determine which Scene to use based on the current entries.
|
||||
|
||||
**Built-in strategies:**
|
||||
- `SinglePaneSceneStrategy` — Default, shows topmost entry
|
||||
- `DialogSceneStrategy` — Renders entries with dialog metadata as overlays
|
||||
- `ListDetailSceneStrategy` (from `material3-adaptive-navigation3`) — Adaptive list-detail layout
|
||||
|
||||
**Custom strategy example (list-detail):**
|
||||
```kotlin
|
||||
class ListDetailSceneStrategy<T : Any>(
|
||||
val windowSizeClass: WindowSizeClass
|
||||
) : SceneStrategy<T> {
|
||||
override fun SceneStrategyScope<T>.calculateScene(
|
||||
entries: List<NavEntry<T>>
|
||||
): Scene<T>? {
|
||||
if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND))
|
||||
return null
|
||||
val detailEntry = entries.lastOrNull()?.takeIf { it.metadata.contains(DetailKey) } ?: return null
|
||||
val listEntry = entries.findLast { it.metadata.contains(ListKey) } ?: return null
|
||||
return ListDetailScene(
|
||||
key = listEntry.contentKey,
|
||||
previousEntries = entries.dropLast(1),
|
||||
listEntry = listEntry,
|
||||
detailEntry = detailEntry
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.8 Metadata
|
||||
|
||||
Arbitrary `Map<String, Any>` attached to `NavEntry` or `Scene` for communication between components:
|
||||
|
||||
```kotlin
|
||||
// Type-safe metadata DSL (1.1.0-beta01+)
|
||||
entry<Home>(
|
||||
metadata = metadata {
|
||||
put(NavDisplay.TransitionKey) { fadeIn() togetherWith fadeOut() }
|
||||
put(ListDetailSceneStrategy.ListKey, true)
|
||||
}
|
||||
) { HomeScreen() }
|
||||
```
|
||||
|
||||
### 2.9 Animations
|
||||
|
||||
NavDisplay supports built-in transition animations:
|
||||
|
||||
```kotlin
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
// Global defaults
|
||||
transitionSpec = {
|
||||
slideInHorizontally(initialOffsetX = { it }) togetherWith
|
||||
slideOutHorizontally(targetOffsetX = { -it })
|
||||
},
|
||||
popTransitionSpec = {
|
||||
slideInHorizontally(initialOffsetX = { -it }) togetherWith
|
||||
slideOutHorizontally(targetOffsetX = { it })
|
||||
},
|
||||
predictivePopTransitionSpec = {
|
||||
slideInHorizontally(initialOffsetX = { -it }) togetherWith
|
||||
slideOutHorizontally(targetOffsetX = { it })
|
||||
},
|
||||
entryProvider = entryProvider {
|
||||
// Per-entry override via metadata
|
||||
entry<ScreenC>(
|
||||
metadata = metadata {
|
||||
put(NavDisplay.TransitionKey) {
|
||||
slideInVertically(initialOffsetY = { it }) togetherWith
|
||||
ExitTransition.KeepUntilTransitionsFinished
|
||||
}
|
||||
}
|
||||
) { ScreenC() }
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Shared element transitions:
|
||||
```kotlin
|
||||
SharedTransitionLayout {
|
||||
NavDisplay(
|
||||
sharedTransitionScope = this,
|
||||
// ...
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.10 Deep Linking
|
||||
|
||||
Nav 3 does **not** have built-in deep link support like Nav 2. Instead, you parse intents manually:
|
||||
|
||||
1. Define `DeepLinkPattern` objects mapping URIs to `NavKey` classes
|
||||
2. Parse incoming `Intent.data` into a `DeepLinkRequest`
|
||||
3. Match against patterns to produce a `NavKey`
|
||||
4. Build a synthetic back stack for proper Up navigation
|
||||
|
||||
```kotlin
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val uri: Uri? = intent.data
|
||||
val key: NavKey = uri?.let { parseDeepLink(it) } ?: HomeKey
|
||||
setContent {
|
||||
val backStack = rememberNavBackStack(key)
|
||||
NavDisplay(backStack = backStack, ...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Recipes available: [Basic deep links](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplinks), [Advanced deep links](https://github.com/android/nav3-recipes/tree/main/advanceddeeplinkapp).
|
||||
|
||||
---
|
||||
|
||||
## 3. Best Practices
|
||||
|
||||
### 3.1 Architecture Pattern
|
||||
|
||||
**Recommended: Navigator + NavigationState pattern** (from official migration guide):
|
||||
|
||||
```kotlin
|
||||
// NavigationState — holds all navigation state
|
||||
class NavigationState(
|
||||
val startRoute: NavKey,
|
||||
topLevelRoute: MutableState<NavKey>,
|
||||
val backStacks: Map<NavKey, NavBackStack<NavKey>>
|
||||
) {
|
||||
var topLevelRoute: NavKey by topLevelRoute
|
||||
}
|
||||
|
||||
// Navigator — handles navigation events
|
||||
class Navigator(val state: NavigationState) {
|
||||
fun navigate(route: NavKey) {
|
||||
if (route in state.backStacks.keys) {
|
||||
state.topLevelRoute = route
|
||||
} else {
|
||||
state.backStacks[state.topLevelRoute]?.add(route)
|
||||
}
|
||||
}
|
||||
fun goBack() { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Multi-Module Navigation
|
||||
|
||||
Nav 3 is designed for modular navigation. Each feature module exposes:
|
||||
- **`api` module**: Navigation keys (routes)
|
||||
- **`impl` module**: Entry builders (content)
|
||||
|
||||
```kotlin
|
||||
// feature/chat/impl
|
||||
fun EntryProviderScope<NavKey>.chatEntryBuilder() {
|
||||
entry<ChatRoute> { key -> ChatScreen(key.conversationId) }
|
||||
entry<ChatSettings> { ChatSettingsScreen() }
|
||||
}
|
||||
|
||||
// app module
|
||||
NavDisplay(
|
||||
entryProvider = entryProvider {
|
||||
chatEntryBuilder()
|
||||
authEntryBuilder()
|
||||
settingsEntryBuilder()
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**With Koin DI** (from nav3-recipes):
|
||||
```kotlin
|
||||
// Each feature module provides its entry builder
|
||||
val chatNavigationModule = module {
|
||||
factory<EntryProviderScope<NavKey>.() -> Unit>(named("chat")) {
|
||||
{ chatEntryBuilder(get()) }
|
||||
}
|
||||
}
|
||||
|
||||
// App module collects all builders
|
||||
val entryBuilders: List<EntryProviderScope<NavKey>.() -> Unit> = getAll()
|
||||
NavDisplay(
|
||||
entryProvider = entryProvider {
|
||||
entryBuilders.forEach { builder -> this.builder() }
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 3.3 ViewModel Scoping
|
||||
|
||||
ViewModels are scoped to `NavEntry` lifecycle via decorators:
|
||||
|
||||
```kotlin
|
||||
NavDisplay(
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(), // Always first
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
// ...
|
||||
)
|
||||
```
|
||||
|
||||
With this setup:
|
||||
- `viewModel()` calls inside NavEntry content are scoped to that entry
|
||||
- ViewModels survive config changes
|
||||
- ViewModels are cleared when the entry is popped
|
||||
|
||||
**With Koin:**
|
||||
```kotlin
|
||||
entry<ChatRoute> { key ->
|
||||
val viewModel = koinViewModel<ChatViewModel>()
|
||||
ChatScreen(viewModel)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Multiple Back Stacks (Bottom Navigation)
|
||||
|
||||
For apps with bottom navigation / top-level routes:
|
||||
|
||||
```kotlin
|
||||
val topLevelRoutes = setOf(Home, Search, Profile)
|
||||
|
||||
// Each top-level route gets its own persistent back stack
|
||||
val backStacks = topLevelRoutes.associateWith { rememberNavBackStack(it) }
|
||||
|
||||
// Switch between stacks (state is retained)
|
||||
var currentTopLevel by remember { mutableStateOf(Home) }
|
||||
```
|
||||
|
||||
Recipe: [Multiple back stacks](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/multiplestacks)
|
||||
|
||||
### 3.5 Adaptive / Multi-Pane Layouts
|
||||
|
||||
Use Material 3 Adaptive for responsive list-detail:
|
||||
|
||||
```kotlin
|
||||
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
|
||||
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
sceneStrategies = listOf(listDetailStrategy),
|
||||
entryProvider = entryProvider {
|
||||
entry<ConversationList>(
|
||||
metadata = ListDetailSceneStrategy.listPane(
|
||||
detailPlaceholder = { Text("Select a conversation") }
|
||||
)
|
||||
) { ConversationListScreen() }
|
||||
|
||||
entry<ConversationDetail>(
|
||||
metadata = ListDetailSceneStrategy.detailPane()
|
||||
) { key -> ConversationDetailScreen(key.id) }
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 3.6 Testing Navigation
|
||||
|
||||
**Nav 3 has no dedicated testing library like Nav 2's `navigation-testing`.** However, since navigation state is just a list, testing is simpler:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun testNavigation() {
|
||||
val backStack = mutableStateListOf<NavKey>(Home)
|
||||
|
||||
// Simulate navigation
|
||||
backStack.add(ProductDetail("123"))
|
||||
assertEquals(ProductDetail("123"), backStack.last())
|
||||
|
||||
// Simulate back
|
||||
backStack.removeLastOrNull()
|
||||
assertEquals(Home, backStack.last())
|
||||
}
|
||||
```
|
||||
|
||||
For UI testing, you can use standard Compose testing:
|
||||
```kotlin
|
||||
@Test
|
||||
fun testNavigationUI() {
|
||||
composeTestRule.setContent {
|
||||
val backStack = remember { mutableStateListOf<NavKey>(Home) }
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
entryProvider = entryProvider { /* ... */ }
|
||||
)
|
||||
}
|
||||
// Assert on rendered content
|
||||
}
|
||||
```
|
||||
|
||||
The `SceneStrategyScope` has a no-arg public constructor specifically for testing (added in 1.0.0-beta01).
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration Guide (Nav 2 → Nav 3)
|
||||
|
||||
### 4.1 Prerequisites
|
||||
|
||||
- `compileSdk` 36+
|
||||
- `minSdk` 23+
|
||||
- All destinations must be composable functions
|
||||
- Routes must implement `NavKey`
|
||||
|
||||
### 4.2 Can Nav 2 and Nav 3 Coexist?
|
||||
|
||||
**Yes.** The official migration guide mentions **atomic migration** as the recommended approach, but community sources confirm **incremental migration** is possible:
|
||||
|
||||
- Nav 2 and Nav 3 can operate simultaneously during migration
|
||||
- Features can be migrated one at a time
|
||||
- Business logic and ViewModels don't need rewriting
|
||||
|
||||
However, the official migration guide assumes atomic migration. For incremental, you'd maintain a Nav 2 `NavHost` for unmigrated features and a Nav 3 `NavDisplay` for migrated ones, coordinating between them.
|
||||
|
||||
### 4.3 Step-by-Step Migration
|
||||
|
||||
#### Step 1: Add Dependencies
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
nav3Core = "1.0.1"
|
||||
lifecycleViewmodelNav3 = "2.11.0-alpha03"
|
||||
|
||||
[libraries]
|
||||
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
|
||||
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
|
||||
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
|
||||
```
|
||||
|
||||
#### Step 2: Update Routes to Implement NavKey
|
||||
|
||||
```kotlin
|
||||
// Before
|
||||
@Serializable data object Home
|
||||
@Serializable data class Chat(val conversationId: String)
|
||||
|
||||
// After
|
||||
@Serializable data object Home : NavKey
|
||||
@Serializable data class Chat(val conversationId: String) : NavKey
|
||||
```
|
||||
|
||||
#### Step 3: Create NavigationState & Navigator
|
||||
|
||||
See [Section 3.1](#31-architecture-pattern) for the full pattern.
|
||||
|
||||
#### Step 4: Replace NavController Calls
|
||||
|
||||
| Nav 2 | Nav 3 |
|
||||
|-------|-------|
|
||||
| `navController.navigate(route)` | `navigator.navigate(route)` or `backStack.add(route)` |
|
||||
| `navController.popBackStack()` | `navigator.goBack()` or `backStack.removeLastOrNull()` |
|
||||
| `navController.currentDestination` | `backStack.last()` |
|
||||
| `currentBackStackEntryAsState()` | Direct state observation of `backStack` |
|
||||
| `destination.isRouteInHierarchy()` | `key == navigationState.topLevelRoute` |
|
||||
|
||||
#### Step 5: Convert Destinations
|
||||
|
||||
| Nav 2 | Nav 3 |
|
||||
|-------|-------|
|
||||
| `composable<T> { }` | `entry<T> { }` |
|
||||
| `dialog<T> { }` | `entry<T>(metadata = DialogSceneStrategy.dialog()) { }` |
|
||||
| `navigation<T>(startDestination = ...) { }` | **Delete** — replace references with first child route |
|
||||
| `NavGraphBuilder` extension | `EntryProviderScope<NavKey>` extension |
|
||||
| `entry.toRoute<T>()` | `key` parameter: `entry<T> { key -> ... }` |
|
||||
|
||||
**Before:**
|
||||
```kotlin
|
||||
NavHost(navController = navController, startDestination = Home) {
|
||||
composable<Home> { HomeScreen() }
|
||||
composable<Chat> { entry ->
|
||||
val route = entry.toRoute<Chat>()
|
||||
ChatScreen(route.conversationId)
|
||||
}
|
||||
dialog<ConfirmDialog> { ConfirmDialogScreen() }
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```kotlin
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
sceneStrategies = listOf(DialogSceneStrategy()),
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Home> { HomeScreen() }
|
||||
entry<Chat> { key -> ChatScreen(key.conversationId) }
|
||||
entry<ConfirmDialog>(
|
||||
metadata = DialogSceneStrategy.dialog()
|
||||
) { ConfirmDialogScreen() }
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### Step 6: Remove Nav 2 Dependencies
|
||||
|
||||
Remove all Nav 2 imports, `NavController`, `NavHost`, `NavGraphBuilder`, and gradle dependencies.
|
||||
|
||||
### 4.4 Key Differences to Watch
|
||||
|
||||
1. **No NavController** — State is a mutable list, not a controller object
|
||||
2. **No nested graphs** — Delete `navigation<T>` blocks entirely
|
||||
3. **No built-in deep links** — Must implement manually (see recipes)
|
||||
4. **Route args via key param** — `entry<T> { key -> ... }` instead of `toRoute<T>()`
|
||||
5. **Dialogs via metadata** — `DialogSceneStrategy.dialog()` metadata instead of `dialog<T>`
|
||||
6. **Use `rememberSerializable`, not `rememberSaveable`** — For top-level route persistence
|
||||
7. **Decorators are explicit** — Must add state/ViewModel decorators manually
|
||||
|
||||
---
|
||||
|
||||
## 5. Gotchas, Footguns & Complications
|
||||
|
||||
### 5.1 Known Issues (from release notes)
|
||||
|
||||
| Issue | Status | Versions |
|
||||
|-------|--------|----------|
|
||||
| z-order bug during animation interruptions ([b/459419800](https://issuetracker.google.com/issues/459419800)) | Fixed in 1.1.0-rc01 | |
|
||||
| Lifecycle not capped at STARTED for entries under overlays ([b/483966071](https://issuetracker.google.com/issues/483966071)) | Fixed in 1.1.0-rc01 | |
|
||||
| Crash with SharedTransitionLayout + OverlayScene ([b/478664101](https://issuetracker.google.com/issues/478664101)) | Fixed in 1.1.0-alpha04 | |
|
||||
| IllegalStateException in Android Studio Previews ([b/477149762](https://issuetracker.google.com/issues/477149762)) | Fixed in 1.0.1 / NavigationEvent 1.0.2 | |
|
||||
| Infinite scene recalculation ([b/418153031](https://issuetracker.google.com/issues/418153031)) | Fixed in 1.0.0-alpha07 | |
|
||||
| Screen flicker when swapping backStack with animations ([b/450967248](https://issuetracker.google.com/issues/450967248)) | Fixed in 1.0.0-beta01 | |
|
||||
|
||||
### 5.2 Footguns
|
||||
|
||||
1. **Decorator order matters** — `rememberSaveableStateHolderNavEntryDecorator()` must be **first** in the decorators list. Otherwise, `rememberSaveable` won't work inside NavEntry content.
|
||||
|
||||
2. **Animations in adaptive layouts** — When using `ListDetailSceneStrategy` in two-pane mode (>=600dp), transition animations between top-level destinations may not work. The screen "jumps" without animation.
|
||||
|
||||
3. **No built-in deep link handling** — Unlike Nav 2's `deepLinks { }` DSL, you must parse intents and build synthetic back stacks yourself. See the recipes repo for patterns.
|
||||
|
||||
4. **`rememberNavBackStack` requires NavKey** — If you want persistence across process death, all keys must implement `NavKey` and be `@Serializable`. Simple `data object` without `NavKey` will work but won't survive process death.
|
||||
|
||||
5. **compileSdk 36 required** — This is a hard requirement. If your project is on an older compileSdk, you must upgrade.
|
||||
|
||||
6. **No nested navigation (>1 level)** — The migration guide explicitly states: only one level of nesting is supported. Shared destinations (screens moving between different back stacks) are also unsupported.
|
||||
|
||||
7. **`calculateScene` is no longer `@Composable` (since 1.0.0-alpha11)** — Custom SceneStrategies cannot call composable functions in `calculateScene`. Use `remember` in a factory function instead.
|
||||
|
||||
8. **NavBackStack serialization is Android-only** — The built-in `NavBackStackSerializer` is restricted to Android ([b/420443609](https://issuetracker.google.com/issues/420443609)). For KMP, you need polymorphic serialization (see Section 6).
|
||||
|
||||
9. **API churn in 1.1.x** — The 1.1 branch is still in RC. APIs like `SceneStrategy` receiving `List<SceneStrategy>` instead of chained strategies changed in alpha05. Pin to 1.0.1 for stability.
|
||||
|
||||
### 5.3 Things Harder in Nav 3
|
||||
|
||||
- **Deep linking**: Significantly more manual work required
|
||||
- **Bottom sheet destinations**: No built-in support; recipe available but requires custom SceneStrategy
|
||||
- **Fragment/View interop**: Must use Views-in-Compose wrappers; Nav 3 is Compose-only
|
||||
- **Shared destinations between back stacks**: Not supported
|
||||
- **Testing**: No dedicated testing artifact; testing is easier conceptually (just a list) but lacks the convenience APIs of `TestNavHostController`
|
||||
|
||||
### 5.4 Things Easier in Nav 3
|
||||
|
||||
- **Back stack inspection**: It's just a list — fully observable and debuggable
|
||||
- **Multi-pane/adaptive layouts**: First-class support via Scenes
|
||||
- **Custom navigation logic**: No fighting the NavController
|
||||
- **State management**: Aligns perfectly with Compose state
|
||||
- **ViewModel scoping**: Explicit, clear, and configurable via decorators
|
||||
|
||||
---
|
||||
|
||||
## 6. KMP / Compose Multiplatform Considerations
|
||||
|
||||
### 6.1 Is Nav 3 Available for Compose Multiplatform?
|
||||
|
||||
**Yes.** JetBrains added Nav 3 support in **Compose Multiplatform 1.10.0** (January 2026) for all platforms: Android, iOS, desktop, and web.
|
||||
|
||||
### 6.2 Dependencies (KMP)
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
multiplatform-nav3-ui = "1.0.0-alpha05"
|
||||
|
||||
[libraries]
|
||||
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" }
|
||||
```
|
||||
|
||||
Nav 3 ships as two artifacts:
|
||||
- `navigation3-common` — Core (transitive dependency)
|
||||
- `navigation3-ui` — NavDisplay and UI components (separate CMP implementation)
|
||||
|
||||
Additional KMP libraries:
|
||||
```toml
|
||||
# Material 3 Adaptive
|
||||
jetbrains-material3-adaptiveNavigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "compose-multiplatform-adaptive" }
|
||||
|
||||
# ViewModel support
|
||||
jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" }
|
||||
```
|
||||
|
||||
### 6.3 Critical KMP Requirement: Polymorphic Serialization
|
||||
|
||||
**On Android**, Nav 3 uses reflection-based serialization for `NavKey` types. **This does NOT work on non-JVM platforms (iOS, web).** You must use polymorphic serialization.
|
||||
|
||||
`rememberNavBackStack()` has two overloads:
|
||||
1. **Android-only**: `rememberNavBackStack(startKey)` — uses reflection
|
||||
2. **Multiplatform**: `rememberNavBackStack(config, startKey)` — requires `SavedStateConfiguration`
|
||||
|
||||
**For KMP, you MUST use the second overload:**
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
data object Home : NavKey
|
||||
|
||||
@Serializable
|
||||
data class Chat(val id: String) : NavKey
|
||||
|
||||
// Required for non-JVM platforms
|
||||
private val config = SavedStateConfiguration {
|
||||
serializersModule = SerializersModule {
|
||||
polymorphic(NavKey::class) {
|
||||
subclass(Home::class, Home.serializer())
|
||||
subclass(Chat::class, Chat.serializer())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
val backStack = rememberNavBackStack(config, Home)
|
||||
NavDisplay(backStack = backStack, ...)
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 Multi-Module KMP Pattern
|
||||
|
||||
For our multi-module setup, use sealed interfaces per feature module:
|
||||
|
||||
```kotlin
|
||||
// feature/auth/api
|
||||
@Serializable
|
||||
sealed interface AuthRoute : NavKey
|
||||
|
||||
@Serializable data object Login : AuthRoute
|
||||
@Serializable data object Register : AuthRoute
|
||||
|
||||
// feature/chat/api
|
||||
@Serializable
|
||||
sealed interface ChatRoute : NavKey
|
||||
|
||||
@Serializable data class Conversation(val id: String) : ChatRoute
|
||||
|
||||
// app module — aggregate all feature routes
|
||||
private val config = SavedStateConfiguration {
|
||||
serializersModule = SerializersModule {
|
||||
polymorphic(NavKey::class) {
|
||||
subclassesOfSealed<AuthRoute>()
|
||||
subclassesOfSealed<ChatRoute>()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, with Koin DI, each module can expose a `SerializersModule` and the app module combines them:
|
||||
|
||||
```kotlin
|
||||
// Each feature module
|
||||
val chatSerializerModule = SerializersModule {
|
||||
polymorphic(NavKey::class) {
|
||||
subclass(Conversation::class, Conversation.serializer())
|
||||
}
|
||||
}
|
||||
|
||||
// App module
|
||||
val combinedConfig = SavedStateConfiguration {
|
||||
serializersModule = chatSerializerModule + authSerializerModule + settingsSerializerModule
|
||||
}
|
||||
```
|
||||
|
||||
### 6.5 Impact on Our Current Setup
|
||||
|
||||
We currently use `org.jetbrains.androidx.navigation:navigation-compose:2.9.2`. For Nav 3:
|
||||
|
||||
| Current (Nav 2 KMP) | Nav 3 KMP |
|
||||
|---------------------|-----------|
|
||||
| `org.jetbrains.androidx.navigation:navigation-compose:2.9.2` | `org.jetbrains.androidx.navigation3:navigation3-ui:1.0.0-alpha05` |
|
||||
| `NavHost` | `NavDisplay` |
|
||||
| `NavController` | `SnapshotStateList` + custom Navigator |
|
||||
| Reflection-based routing | **Must use** `SavedStateConfiguration` with polymorphic serialization |
|
||||
| Direct route access | Key-based entry access |
|
||||
|
||||
**Key considerations:**
|
||||
- The JetBrains Nav 3 artifact is at **1.0.0-alpha05** (not yet stable for KMP)
|
||||
- Android-side Nav 3 is stable at 1.0.1
|
||||
- We must implement polymorphic serialization for all routes
|
||||
- Our Koin-based DI can provide serializer modules per feature
|
||||
- Browser history navigation (for web target) is expected in Nav 3 KMP 1.1.0
|
||||
|
||||
### 6.6 CMP Nav 3 Recipes
|
||||
|
||||
JetBrains maintains a Compose Multiplatform fork of the nav3-recipes:
|
||||
- [github.com/terrakok/nav3-recipes](https://github.com/terrakok/nav3-recipes)
|
||||
|
||||
---
|
||||
|
||||
## 7. Appendix: Version & Dependency Reference
|
||||
|
||||
### AndroidX Dependencies (Android-only)
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
nav3Core = "1.0.1" # Stable
|
||||
# nav3Core = "1.1.0-rc01" # Latest RC
|
||||
lifecycleViewmodelNav3 = "2.11.0-alpha03"
|
||||
material3AdaptiveNav3 = "1.3.0-alpha09"
|
||||
kotlinxSerializationCore = "1.9.0"
|
||||
|
||||
[libraries]
|
||||
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
|
||||
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
|
||||
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
|
||||
androidx-material3-adaptive-navigation3 = { module = "androidx.compose.material3.adaptive:adaptive-navigation3", version.ref = "material3AdaptiveNav3" }
|
||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" }
|
||||
```
|
||||
|
||||
### Compose Multiplatform Dependencies (KMP)
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
multiplatform-nav3-ui = "1.0.0-alpha05"
|
||||
compose-multiplatform-adaptive = "1.3.0-alpha02"
|
||||
compose-multiplatform-lifecycle = "2.10.0-alpha05"
|
||||
|
||||
[libraries]
|
||||
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" }
|
||||
jetbrains-material3-adaptiveNavigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "compose-multiplatform-adaptive" }
|
||||
jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" }
|
||||
```
|
||||
|
||||
### Nav 3 Recipes Repository
|
||||
|
||||
- Android: [github.com/android/nav3-recipes](https://github.com/android/nav3-recipes) (1.2k stars)
|
||||
- CMP: [github.com/terrakok/nav3-recipes](https://github.com/terrakok/nav3-recipes)
|
||||
|
||||
Available recipes:
|
||||
- Basic, Saveable back stack, Entry provider DSL
|
||||
- Deep links (basic + advanced)
|
||||
- Dialog, BottomSheet, List-Detail, Two-pane, Supporting Pane
|
||||
- Animations
|
||||
- Navigation UI, Multiple back stacks
|
||||
- Conditional navigation
|
||||
- Modularized navigation (Hilt + Koin)
|
||||
- ViewModel patterns (basic, Hilt, Koin, shared)
|
||||
- Retain values for back stack composables
|
||||
- Returning results (events + state)
|
||||
- Fragment/View interop
|
||||
|
||||
### Sources
|
||||
|
||||
- [Navigation 3 Overview](https://developer.android.com/guide/navigation/navigation-3)
|
||||
- [Navigation 3 Basics](https://developer.android.com/guide/navigation/navigation-3/basics)
|
||||
- [Navigation 3 Migration Guide](https://developer.android.com/guide/navigation/navigation-3/migration-guide)
|
||||
- [Navigation 3 Save State](https://developer.android.com/guide/navigation/navigation-3/save-state)
|
||||
- [Navigation 3 Metadata](https://developer.android.com/guide/navigation/navigation-3/metadata)
|
||||
- [Navigation 3 Modularize](https://developer.android.com/guide/navigation/navigation-3/modularize)
|
||||
- [Navigation 3 Scenes](https://developer.android.com/guide/navigation/navigation-3/scenes)
|
||||
- [Navigation 3 Animations](https://developer.android.com/guide/navigation/navigation-3/animate-destinations)
|
||||
- [Navigation 3 Entry Decorators](https://developer.android.com/guide/navigation/navigation-3/naventrydecorators)
|
||||
- [Navigation 3 Get Started](https://developer.android.com/guide/navigation/navigation-3/get-started)
|
||||
- [Navigation 3 Release Notes](https://developer.android.com/jetpack/androidx/releases/navigation3)
|
||||
- [Navigation 3 in Compose Multiplatform](https://kotlinlang.org/docs/multiplatform/compose-navigation-3.html)
|
||||
- [Compose Multiplatform 1.10.0 Blog Post](https://blog.jetbrains.com/kotlin/2026/01/compose-multiplatform-1-10-0/)
|
||||
- [Jetpack Navigation 3 is Stable (Blog)](https://android-developers.googleblog.com/2025/11/jetpack-navigation-3-is-stable.html)
|
||||
- [Announcing Jetpack Navigation 3 (Blog)](https://android-developers.googleblog.com/2025/05/announcing-jetpack-navigation-3-for-compose.html)
|
||||
- [nav3-recipes GitHub](https://github.com/android/nav3-recipes)
|
||||
- [Deep Link Guide](https://github.com/android/nav3-recipes/blob/main/docs/deeplink-guide.md)
|
||||
- [Shared ViewModel Recipe](https://developer.android.com/guide/navigation/navigation-3/recipes/sharedviewmodel)
|
||||
|
|
@ -4,11 +4,13 @@ Single Activity architecture. `MainActivity` is the sole entry point.
|
|||
|
||||
## Navigation
|
||||
|
||||
`LibreChatNavHost` is the root composable. It uses adaptive layout based on `WindowSizeClass`:
|
||||
`LibreChatNavHost` is the root composable using Nav 3's `NavDisplay` with `NavBackStack<NavKey>` and `entryProvider`. It uses adaptive layout based on `WindowSizeClass`:
|
||||
|
||||
- **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.
|
||||
|
||||
Feature modules provide entries via `EntryProviderScope<NavKey>` 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
|
||||
|
||||
`DrawerContent` is the rich sidebar matching the web's left panel:
|
||||
|
|
@ -22,8 +24,8 @@ Single Activity architecture. `MainActivity` is the sole entry point.
|
|||
|
||||
## Top-Level Destinations
|
||||
|
||||
Defined in `TopLevelDestination` enum: Chat, Conversations, Agents, Files, Settings.
|
||||
Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes remain registered in the NavHost.
|
||||
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
|
||||
|
||||
|
|
@ -32,8 +34,8 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a
|
|||
## Auth & Session
|
||||
|
||||
- `NavHostViewModel` observes auth state via `isLoggedIn` flow
|
||||
- Start destination is `CHAT_GRAPH_ROUTE` if logged in, `AUTH_GRAPH_ROUTE` otherwise
|
||||
- `sessionExpired` flow triggers nav to auth graph with full backstack clear
|
||||
- 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
|
||||
|
||||
## Deep Linking
|
||||
|
|
@ -66,6 +68,6 @@ It applies convention plugins: `librechat.mobile.application`, `librechat.mobile
|
|||
- **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
|
||||
- `PRESET_MANAGER_ROUTE` registered in `SettingsNavigation.kt`
|
||||
- `PresetManager` route registered in `SettingsNavigation.kt`
|
||||
- `PresetManagerScreen` accessible from Settings → Chat section
|
||||
- Navigation wired in both `LibreChatNavHost` and `TabletLayout`
|
||||
- Navigation wired through `MainNavDisplay` entry provider, accessible from both phone and tablet layouts
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ dependencies {
|
|||
implementation(project(":feature:files"))
|
||||
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.navigation.compose)
|
||||
implementation(libs.navigation3.ui.kmp)
|
||||
implementation(libs.lifecycle.viewmodel.navigation3.kmp)
|
||||
implementation(libs.koin.compose)
|
||||
implementation(libs.koin.compose.viewmodel)
|
||||
implementation(libs.koin.compose.viewmodel.navigation)
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
/**
|
||||
* Incremented each time a share intent arrives.
|
||||
* The NavHost observes this and either:
|
||||
* - Navigates to NEW_CHAT_ROUTE if the user is not on a chat screen, or
|
||||
* The NavDisplay observes this and either:
|
||||
* - Navigates to NewChat if the user is not on a chat screen, or
|
||||
* - Lets the active ChatViewModel consume the shared content in-place if already on a chat screen.
|
||||
*/
|
||||
private var shareNavigationTrigger by mutableStateOf(0)
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ 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 NavHost/main content.
|
||||
* PhoneLayout/TabletLayout, which avoids recomposing the NavDisplay/main content.
|
||||
*/
|
||||
@Composable
|
||||
fun DrawerContent(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.garfiec.librechat.navigation
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
|
|
@ -9,7 +8,9 @@ import androidx.compose.animation.fadeIn
|
|||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideOut
|
||||
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
|
||||
|
|
@ -29,55 +30,42 @@ 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.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.toRoute
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import kotlin.reflect.KClass
|
||||
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.AgentDetail
|
||||
import com.garfiec.librechat.feature.agents.navigation.AgentEditorCreate
|
||||
import com.garfiec.librechat.feature.agents.navigation.AgentEditorEdit
|
||||
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
|
||||
import com.garfiec.librechat.feature.agents.navigation.agentsGraph
|
||||
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.authGraph
|
||||
import com.garfiec.librechat.feature.auth.navigation.authEntries
|
||||
import com.garfiec.librechat.feature.chat.navigation.Chat
|
||||
import com.garfiec.librechat.feature.chat.navigation.ChatRoute
|
||||
import com.garfiec.librechat.feature.chat.navigation.NewChat
|
||||
import com.garfiec.librechat.feature.chat.navigation.chatGraph
|
||||
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
|
||||
import com.garfiec.librechat.feature.chat.navigation.chatEntries
|
||||
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
|
||||
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
|
||||
import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries
|
||||
import com.garfiec.librechat.feature.files.navigation.Files
|
||||
import com.garfiec.librechat.feature.files.navigation.filesGraph
|
||||
import com.garfiec.librechat.feature.settings.navigation.ApiKeys
|
||||
import com.garfiec.librechat.feature.settings.navigation.PresetManager
|
||||
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.SharedLinks
|
||||
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
|
||||
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
|
||||
|
||||
/** Checks if this destination's route matches the given typed route class. */
|
||||
private fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
|
||||
val qualifiedName = routeClass.qualifiedName ?: return false
|
||||
return this?.route?.startsWith(qualifiedName) == true
|
||||
}
|
||||
|
||||
/** Maps a [SettingsCategory] to its corresponding typed navigation route. */
|
||||
fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
|
||||
SettingsCategory.GENERAL -> SettingsGeneral
|
||||
|
|
@ -95,46 +83,40 @@ fun LibreChatNavHost(
|
|||
shareNavigationTrigger: Int = 0,
|
||||
navHostViewModel: NavHostViewModel = koinViewModel(),
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
|
||||
|
||||
val isInAuthFlow = currentDestination.isRoute(AuthRoute::class) ||
|
||||
currentDestination?.parent.isRoute(AuthRoute::class)
|
||||
// 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(navBackStackEntry) {
|
||||
val conversationId = if (navBackStackEntry?.destination.isRoute(Chat::class)) {
|
||||
navBackStackEntry?.toRoute<Chat>()?.conversationId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
LaunchedEffect(navigator.currentRoute) {
|
||||
val conversationId = (navigator.currentRoute as? Chat)?.conversationId
|
||||
navHostViewModel.setActiveConversation(conversationId)
|
||||
}
|
||||
|
||||
// Handle session expiry
|
||||
LaunchedEffect(Unit) {
|
||||
navHostViewModel.sessionExpired.collect {
|
||||
navController.navigate(ServerUrl) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
navigator.navigateToAuth()
|
||||
}
|
||||
}
|
||||
|
||||
val startDestination: Any = if (isLoggedIn) ChatRoute::class else AuthRoute::class
|
||||
|
||||
val isTablet = windowSizeClass?.widthSizeClass?.let {
|
||||
it >= WindowWidthSizeClass.Medium
|
||||
} ?: false
|
||||
|
||||
if (isTablet) {
|
||||
TabletLayout(
|
||||
navController = navController,
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
startDestination = startDestination,
|
||||
isInAuthFlow = isInAuthFlow,
|
||||
deepLinkUri = deepLinkUri,
|
||||
onDeepLinkConsumed = onDeepLinkConsumed,
|
||||
shareNavigationTrigger = shareNavigationTrigger,
|
||||
|
|
@ -142,10 +124,8 @@ fun LibreChatNavHost(
|
|||
)
|
||||
} else {
|
||||
PhoneLayout(
|
||||
navController = navController,
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
startDestination = startDestination,
|
||||
isInAuthFlow = isInAuthFlow,
|
||||
deepLinkUri = deepLinkUri,
|
||||
onDeepLinkConsumed = onDeepLinkConsumed,
|
||||
shareNavigationTrigger = shareNavigationTrigger,
|
||||
|
|
@ -206,10 +186,8 @@ private fun VersionMismatchDialog(
|
|||
|
||||
@Composable
|
||||
private fun PhoneLayout(
|
||||
navController: androidx.navigation.NavHostController,
|
||||
navigator: Navigator,
|
||||
navHostViewModel: NavHostViewModel,
|
||||
startDestination: Any,
|
||||
isInAuthFlow: Boolean,
|
||||
deepLinkUri: Uri?,
|
||||
onDeepLinkConsumed: () -> Unit,
|
||||
shareNavigationTrigger: Int,
|
||||
|
|
@ -217,7 +195,6 @@ private fun PhoneLayout(
|
|||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
// Banner state only -- drawer state is collected inside DrawerContent itself
|
||||
val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
|
||||
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
|
||||
|
||||
|
|
@ -227,7 +204,7 @@ private fun PhoneLayout(
|
|||
if (uri.scheme == "librechat" && uri.host == "conversation") {
|
||||
uri.lastPathSegment?.let { conversationId ->
|
||||
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
||||
navController.navigateToChat(conversationId)
|
||||
navigator.navigateToChat(conversationId)
|
||||
} else {
|
||||
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
|
||||
}
|
||||
|
|
@ -237,22 +214,14 @@ private fun PhoneLayout(
|
|||
}
|
||||
}
|
||||
|
||||
// Handle share intent: if already on a chat screen, let the active ChatViewModel
|
||||
// consume the shared content reactively. Only navigate to new chat when on a
|
||||
// non-chat screen (e.g. settings, agents, files).
|
||||
// Handle share intent
|
||||
LaunchedEffect(shareNavigationTrigger) {
|
||||
if (shareNavigationTrigger > 0) {
|
||||
val currentDest = navController.currentBackStackEntry?.destination
|
||||
val isOnChatScreen = currentDest.isRoute(Chat::class) ||
|
||||
currentDest.isRoute(NewChat::class)
|
||||
val currentRoute = navigator.currentRoute
|
||||
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
|
||||
if (!isOnChatScreen) {
|
||||
navController.navigate(NewChat) {
|
||||
popUpTo<ChatRoute> { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
}
|
||||
// If already on a chat screen, SharedIntentConsumer.shareAvailable
|
||||
// will notify the active ChatViewModel to consume the content.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -265,57 +234,42 @@ private fun PhoneLayout(
|
|||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = !isInAuthFlow,
|
||||
gesturesEnabled = !navigator.isInAuthFlow,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
SidebarScaffold(
|
||||
viewModel = navHostViewModel,
|
||||
onNewChat = {
|
||||
scope.launch { drawerState.close() }
|
||||
// Skip navigation if already on the new chat screen
|
||||
val currentDest = navController.currentBackStackEntry?.destination
|
||||
if (!currentDest.isRoute(NewChat::class)) {
|
||||
navController.navigate(NewChat) {
|
||||
popUpTo<ChatRoute> { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
if (navigator.currentRoute !is NewChat) {
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
}
|
||||
},
|
||||
onConversationClick = { conversationId ->
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigateToChat(conversationId)
|
||||
navigator.navigateToChat(conversationId)
|
||||
},
|
||||
onSettingsClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigate(SettingsTabbed) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(SettingsTabbed)
|
||||
},
|
||||
onSettingsCategorySelected = { category ->
|
||||
// Navigate to the settings sub-page but keep the drawer open
|
||||
navController.navigate(category.toRoute()) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(category.toRoute())
|
||||
},
|
||||
onAgentsClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigate(AgentMarketplace) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(AgentMarketplace)
|
||||
},
|
||||
onFilesClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigate(Files) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(Files)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
// No bottom bar -- drawer is the primary navigation
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
if (!isInAuthFlow && banners.isNotEmpty()) {
|
||||
if (!navigator.isInAuthFlow && banners.isNotEmpty()) {
|
||||
BannerDisplay(
|
||||
banners = banners,
|
||||
dismissedIds = dismissedBannerIds,
|
||||
|
|
@ -323,122 +277,100 @@ private fun PhoneLayout(
|
|||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
MainNavDisplay(
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
||||
animationSpec = tween(300),
|
||||
)
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(150))
|
||||
},
|
||||
popEnterTransition = {
|
||||
fadeIn(
|
||||
initialAlpha = 0.5f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
) + scaleIn(
|
||||
initialScale = 0.92f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOut(
|
||||
targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) },
|
||||
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
||||
) + scaleOut(
|
||||
targetScale = 0.92f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
)
|
||||
},
|
||||
) {
|
||||
authGraph(
|
||||
navController = navController,
|
||||
onAuthComplete = {
|
||||
navHostViewModel.onAuthComplete()
|
||||
navController.navigate(NewChat) {
|
||||
popUpTo<AuthRoute> { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
chatGraph(
|
||||
navController = navController,
|
||||
onOpenDrawer = {
|
||||
scope.launch { drawerState.open() }
|
||||
},
|
||||
)
|
||||
conversationsGraph(
|
||||
onConversationClick = { conversationId ->
|
||||
navController.navigateToChat(conversationId)
|
||||
},
|
||||
onNavigateToArchived = {
|
||||
navController.navigate(ArchivedConversations)
|
||||
},
|
||||
onNavigateBackFromArchived = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
agentsGraph(
|
||||
onAgentClick = { agentId ->
|
||||
navController.navigate(AgentDetail(agentId = agentId))
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
onStartChat = { agentId ->
|
||||
navController.navigate(NewChat) {
|
||||
popUpTo<ChatRoute> { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onCreateAgent = {
|
||||
navController.navigate(AgentEditorCreate)
|
||||
},
|
||||
onEditAgent = { agentId ->
|
||||
navController.navigate(AgentEditorEdit(agentId = agentId))
|
||||
},
|
||||
)
|
||||
filesGraph()
|
||||
settingsGraph(
|
||||
onLogout = {
|
||||
navHostViewModel.logout()
|
||||
navController.navigate(ServerUrl) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToArchived = {
|
||||
navController.navigate(ArchivedConversations) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateToSharedLinks = {
|
||||
navController.navigate(SharedLinks) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateBackFromSharedLinks = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToPresets = {
|
||||
navController.navigate(PresetManager) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateBackFromPresets = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToApiKeys = {
|
||||
navController.navigate(ApiKeys) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateBackFromApiKeys = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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
|
||||
|
|
@ -12,7 +13,7 @@ import kotlinx.serialization.modules.plus
|
|||
/**
|
||||
* Combined [SerializersModule] for all navigation route types.
|
||||
* Registers polymorphic serializers for each feature module's sealed route hierarchy.
|
||||
* This will be used by Nav 3's SavedStateConfiguration for type-safe state saving.
|
||||
* Used by Nav 3's SavedStateConfiguration for type-safe state saving.
|
||||
*/
|
||||
val navigationSerializersModule: SerializersModule =
|
||||
authSerializersModule +
|
||||
|
|
@ -21,3 +22,11 @@ val navigationSerializersModule: SerializersModule =
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
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<NavKey>) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,18 +2,9 @@ package com.garfiec.librechat.navigation
|
|||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
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.slideOut
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -34,46 +25,19 @@ import androidx.compose.ui.input.pointer.pointerInput
|
|||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import kotlin.reflect.KClass
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.MainActivity
|
||||
import com.garfiec.librechat.core.ui.components.BannerDisplay
|
||||
import com.garfiec.librechat.feature.agents.navigation.AgentDetail
|
||||
import com.garfiec.librechat.feature.agents.navigation.AgentEditorCreate
|
||||
import com.garfiec.librechat.feature.agents.navigation.AgentEditorEdit
|
||||
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
|
||||
import com.garfiec.librechat.feature.agents.navigation.agentsGraph
|
||||
import com.garfiec.librechat.feature.auth.navigation.AuthRoute
|
||||
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
|
||||
import com.garfiec.librechat.feature.auth.navigation.authGraph
|
||||
import com.garfiec.librechat.feature.chat.navigation.Chat
|
||||
import com.garfiec.librechat.feature.chat.navigation.ChatRoute
|
||||
import com.garfiec.librechat.feature.chat.navigation.NewChat
|
||||
import com.garfiec.librechat.feature.chat.navigation.chatGraph
|
||||
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
|
||||
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
|
||||
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
|
||||
import com.garfiec.librechat.feature.files.navigation.Files
|
||||
import com.garfiec.librechat.feature.files.navigation.filesGraph
|
||||
import com.garfiec.librechat.feature.settings.navigation.ApiKeys
|
||||
import com.garfiec.librechat.feature.settings.navigation.PresetManager
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
|
||||
import com.garfiec.librechat.feature.settings.navigation.SharedLinks
|
||||
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
|
||||
import kotlinx.coroutines.launch
|
||||
import co.touchlab.kermit.Logger
|
||||
|
||||
/** Checks if this destination's route matches the given typed route class. */
|
||||
private fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
|
||||
val qualifiedName = routeClass.qualifiedName ?: return false
|
||||
return this?.route?.startsWith(qualifiedName) == true
|
||||
}
|
||||
|
||||
private val SidebarWidth = 320.dp
|
||||
|
||||
/** Velocity (px/s) above which a fling commits the gesture regardless of position. */
|
||||
|
|
@ -81,10 +45,8 @@ private const val FLING_VELOCITY_THRESHOLD = 800f
|
|||
|
||||
@Composable
|
||||
fun TabletLayout(
|
||||
navController: NavHostController,
|
||||
navigator: Navigator,
|
||||
navHostViewModel: NavHostViewModel,
|
||||
startDestination: Any,
|
||||
isInAuthFlow: Boolean,
|
||||
deepLinkUri: Uri?,
|
||||
onDeepLinkConsumed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -95,8 +57,6 @@ fun TabletLayout(
|
|||
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
|
||||
|
||||
// Persisted sidebar state from DataStore -- single source of truth in the ViewModel.
|
||||
// The ViewModel's StateFlow initial value is read synchronously from DataStore,
|
||||
// so the first composition already has the correct persisted state (no flicker).
|
||||
val isSidebarOpen by navHostViewModel.tabletSidebarOpen.collectAsStateWithLifecycle()
|
||||
|
||||
// Whether swipe gesture is enabled (from settings)
|
||||
|
|
@ -106,7 +66,6 @@ fun TabletLayout(
|
|||
val sidebarWidthPx = with(density) { SidebarWidth.toPx() }
|
||||
|
||||
// Animatable tracks sidebar reveal in pixels: 0 = closed, sidebarWidthPx = open.
|
||||
// During a drag it follows the finger; on release it animates to 0 or sidebarWidthPx.
|
||||
val sidebarOffset = remember { Animatable(if (isSidebarOpen) sidebarWidthPx else 0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
|
@ -121,7 +80,7 @@ fun TabletLayout(
|
|||
if (uri.scheme == "librechat" && uri.host == "conversation") {
|
||||
uri.lastPathSegment?.let { conversationId ->
|
||||
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
||||
navController.navigateToChat(conversationId)
|
||||
navigator.navigateToChat(conversationId)
|
||||
} else {
|
||||
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
|
||||
}
|
||||
|
|
@ -131,19 +90,13 @@ fun TabletLayout(
|
|||
}
|
||||
}
|
||||
|
||||
// Handle share intent: if already on a chat screen, let the active ChatViewModel
|
||||
// consume the shared content reactively. Only navigate to new chat when on a
|
||||
// non-chat screen (e.g. settings, agents, files).
|
||||
// Handle share intent
|
||||
LaunchedEffect(shareNavigationTrigger) {
|
||||
if (shareNavigationTrigger > 0) {
|
||||
val currentDest = navController.currentBackStackEntry?.destination
|
||||
val isOnChatScreen = currentDest.isRoute(Chat::class) ||
|
||||
currentDest.isRoute(NewChat::class)
|
||||
val currentRoute = navigator.currentRoute
|
||||
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
|
||||
if (!isOnChatScreen) {
|
||||
navController.navigate(NewChat) {
|
||||
popUpTo<ChatRoute> { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -163,7 +116,7 @@ fun TabletLayout(
|
|||
}
|
||||
}
|
||||
|
||||
if (!isInAuthFlow) {
|
||||
if (!navigator.isInAuthFlow) {
|
||||
val swipeModifier = if (gestureEnabled) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
val velocityTracker = VelocityTracker()
|
||||
|
|
@ -174,7 +127,6 @@ fun TabletLayout(
|
|||
onDragEnd = {
|
||||
val velocity = velocityTracker.calculateVelocity().x
|
||||
val currentOffset = sidebarOffset.value
|
||||
// Decide target: fling velocity wins, otherwise use 50% threshold
|
||||
val shouldOpen = when {
|
||||
velocity > FLING_VELOCITY_THRESHOLD -> true
|
||||
velocity < -FLING_VELOCITY_THRESHOLD -> false
|
||||
|
|
@ -192,7 +144,6 @@ fun TabletLayout(
|
|||
}
|
||||
},
|
||||
onDragCancel = {
|
||||
// Snap back to the current committed state
|
||||
val target = if (isSidebarOpen) sidebarWidthPx else 0f
|
||||
scope.launch {
|
||||
sidebarOffset.animateTo(
|
||||
|
|
@ -229,37 +180,24 @@ fun TabletLayout(
|
|||
SidebarScaffold(
|
||||
viewModel = navHostViewModel,
|
||||
onNewChat = {
|
||||
// Skip navigation if already on the new chat screen
|
||||
val currentDest = navController.currentBackStackEntry?.destination
|
||||
if (!currentDest.isRoute(NewChat::class)) {
|
||||
navController.navigate(NewChat) {
|
||||
popUpTo<ChatRoute> { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
if (navigator.currentRoute !is NewChat) {
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
}
|
||||
},
|
||||
onConversationClick = { conversationId ->
|
||||
navController.navigateToChat(conversationId)
|
||||
navigator.navigateToChat(conversationId)
|
||||
},
|
||||
onSettingsClick = {
|
||||
navController.navigate(SettingsTabbed) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(SettingsTabbed)
|
||||
},
|
||||
onSettingsCategorySelected = { category ->
|
||||
navController.navigate(category.toRoute()) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(category.toRoute())
|
||||
},
|
||||
onAgentsClick = {
|
||||
navController.navigate(AgentMarketplace) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(AgentMarketplace)
|
||||
},
|
||||
onFilesClick = {
|
||||
navController.navigate(Files) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(Files)
|
||||
},
|
||||
modifier = Modifier
|
||||
.width(SidebarWidth)
|
||||
|
|
@ -271,9 +209,8 @@ fun TabletLayout(
|
|||
}
|
||||
// Slot 1: Main content -- resizes to fill remaining space
|
||||
MainContent(
|
||||
navController = navController,
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
startDestination = startDestination,
|
||||
isInAuthFlow = false,
|
||||
banners = banners,
|
||||
dismissedBannerIds = dismissedBannerIds,
|
||||
|
|
@ -287,29 +224,24 @@ fun TabletLayout(
|
|||
val sidebarPx = SidebarWidth.roundToPx()
|
||||
val animatedPx = sidebarOffset.value.toInt()
|
||||
|
||||
// Sidebar: always measured at full 320dp width
|
||||
val sidebarPlaceable = measurables[0].measure(
|
||||
constraints.copy(minWidth = sidebarPx, maxWidth = sidebarPx),
|
||||
)
|
||||
// Main content: resizes to fill screen minus the visible sidebar portion
|
||||
val mainWidth = (constraints.maxWidth - animatedPx).coerceAtLeast(0)
|
||||
val mainPlaceable = measurables[1].measure(
|
||||
constraints.copy(minWidth = mainWidth, maxWidth = mainWidth),
|
||||
)
|
||||
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
// Sidebar slides: -320px (off-screen) -> 0px (fully visible)
|
||||
sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0)
|
||||
// Main content starts right after the visible sidebar portion
|
||||
mainPlaceable.placeRelative(animatedPx, 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Auth flow -- no sidebar, just main content
|
||||
MainContent(
|
||||
navController = navController,
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
startDestination = startDestination,
|
||||
isInAuthFlow = true,
|
||||
banners = banners,
|
||||
dismissedBannerIds = dismissedBannerIds,
|
||||
|
|
@ -321,9 +253,8 @@ fun TabletLayout(
|
|||
|
||||
@Composable
|
||||
private fun MainContent(
|
||||
navController: NavHostController,
|
||||
navigator: Navigator,
|
||||
navHostViewModel: NavHostViewModel,
|
||||
startDestination: Any,
|
||||
isInAuthFlow: Boolean,
|
||||
banners: List<com.garfiec.librechat.core.model.Banner>,
|
||||
dismissedBannerIds: Set<String>,
|
||||
|
|
@ -339,119 +270,11 @@ private fun MainContent(
|
|||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
MainNavDisplay(
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
onOpenDrawer = onToggleDrawer,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
||||
animationSpec = tween(300),
|
||||
)
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(150))
|
||||
},
|
||||
popEnterTransition = {
|
||||
fadeIn(
|
||||
initialAlpha = 0.5f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
) + scaleIn(
|
||||
initialScale = 0.92f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOut(
|
||||
targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) },
|
||||
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
||||
) + scaleOut(
|
||||
targetScale = 0.92f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
)
|
||||
},
|
||||
) {
|
||||
authGraph(
|
||||
navController = navController,
|
||||
onAuthComplete = {
|
||||
navHostViewModel.onAuthComplete()
|
||||
navController.navigate(NewChat) {
|
||||
popUpTo<AuthRoute> { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
chatGraph(
|
||||
navController = navController,
|
||||
onOpenDrawer = onToggleDrawer,
|
||||
)
|
||||
conversationsGraph(
|
||||
onConversationClick = { conversationId ->
|
||||
navController.navigateToChat(conversationId)
|
||||
},
|
||||
onNavigateToArchived = {
|
||||
navController.navigate(ArchivedConversations)
|
||||
},
|
||||
onNavigateBackFromArchived = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
agentsGraph(
|
||||
onAgentClick = { agentId ->
|
||||
navController.navigate(AgentDetail(agentId = agentId))
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
onStartChat = { agentId ->
|
||||
navController.navigate(NewChat) {
|
||||
popUpTo<ChatRoute> { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onCreateAgent = {
|
||||
navController.navigate(AgentEditorCreate)
|
||||
},
|
||||
onEditAgent = { agentId ->
|
||||
navController.navigate(AgentEditorEdit(agentId = agentId))
|
||||
},
|
||||
)
|
||||
filesGraph()
|
||||
settingsGraph(
|
||||
onLogout = {
|
||||
navHostViewModel.logout()
|
||||
navController.navigate(ServerUrl) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToArchived = {
|
||||
navController.navigate(ArchivedConversations) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateToSharedLinks = {
|
||||
navController.navigate(SharedLinks) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateBackFromSharedLinks = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToPresets = {
|
||||
navController.navigate(PresetManager) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateBackFromPresets = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToApiKeys = {
|
||||
navController.navigate(ApiKeys) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateBackFromApiKeys = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
package com.garfiec.librechat.navigation
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Forum
|
||||
import androidx.compose.material.icons.filled.SmartToy
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/**
|
||||
* Destinations accessible from the navigation drawer footer.
|
||||
* The primary navigation is now sidebar-first (drawer with conversation list),
|
||||
* matching the web frontend pattern. Bottom nav has been removed entirely.
|
||||
*
|
||||
* Conversations are integrated into the drawer body (not a separate destination).
|
||||
* Agents and Files are accessible via drawer footer links.
|
||||
* Settings are accessed through the sidebar settings nav menu (see [SettingsCategory]).
|
||||
*/
|
||||
enum class TopLevelDestination(
|
||||
val route: String,
|
||||
val icon: ImageVector,
|
||||
val label: String,
|
||||
) {
|
||||
CHAT("chat_graph", Icons.Default.Chat, "Chat"),
|
||||
CONVERSATIONS("conversations", Icons.Default.Forum, "Conversations"),
|
||||
AGENTS("agents", Icons.Default.SmartToy, "Agents"),
|
||||
FILES("files", Icons.Default.Folder, "Files"),
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
package com.garfiec.librechat.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
|
||||
import com.garfiec.librechat.feature.auth.navigation.Login
|
||||
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
|
||||
import com.garfiec.librechat.feature.chat.navigation.Chat
|
||||
import com.garfiec.librechat.feature.chat.navigation.NewChat
|
||||
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NavigatorTest {
|
||||
|
||||
private fun createNavigator(vararg keys: NavKey): Navigator {
|
||||
return Navigator(NavBackStack(*keys))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `currentRoute returns last entry`() {
|
||||
val navigator = createNavigator(NewChat)
|
||||
assertEquals(NewChat, navigator.currentRoute)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `currentRoute returns null for empty stack`() {
|
||||
val navigator = createNavigator()
|
||||
assertNull(navigator.currentRoute)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isInAuthFlow returns true for auth routes`() {
|
||||
val navigator = createNavigator(ServerUrl)
|
||||
assertTrue(navigator.isInAuthFlow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isInAuthFlow returns true for nested auth routes`() {
|
||||
val navigator = createNavigator(ServerUrl, Login)
|
||||
assertTrue(navigator.isInAuthFlow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isInAuthFlow returns false for non-auth routes`() {
|
||||
val navigator = createNavigator(NewChat)
|
||||
assertFalse(navigator.isInAuthFlow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigate adds route to back stack`() {
|
||||
val navigator = createNavigator(NewChat)
|
||||
navigator.navigate(SettingsTabbed)
|
||||
assertEquals(listOf(NewChat, SettingsTabbed), navigator.backStack.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `goBack removes top entry`() {
|
||||
val navigator = createNavigator(NewChat, SettingsTabbed)
|
||||
navigator.goBack()
|
||||
assertEquals(listOf(NewChat), navigator.backStack.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `goBack on empty stack does not crash`() {
|
||||
val navigator = createNavigator()
|
||||
navigator.goBack()
|
||||
assertTrue(navigator.backStack.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigateToChat with conversationId adds Chat route`() {
|
||||
val navigator = createNavigator(NewChat)
|
||||
navigator.navigateToChat("conv-123")
|
||||
assertEquals(
|
||||
listOf(NewChat, Chat(conversationId = "conv-123")),
|
||||
navigator.backStack.toList(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigateToChat replaces current chat`() {
|
||||
val navigator = createNavigator(NewChat, Chat("conv-1"))
|
||||
navigator.navigateToChat("conv-2")
|
||||
assertEquals(
|
||||
listOf(NewChat, Chat(conversationId = "conv-2")),
|
||||
navigator.backStack.toList(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigateToChat does not replace non-chat route`() {
|
||||
val navigator = createNavigator(NewChat, SettingsTabbed)
|
||||
navigator.navigateToChat("conv-1")
|
||||
assertEquals(
|
||||
listOf(NewChat, SettingsTabbed, Chat(conversationId = "conv-1")),
|
||||
navigator.backStack.toList(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigateToTopLevel pops to root and replaces`() {
|
||||
val navigator = createNavigator(NewChat, Chat("conv-1"), SettingsTabbed)
|
||||
navigator.navigateToTopLevel(AgentMarketplace)
|
||||
assertEquals(listOf(AgentMarketplace), navigator.backStack.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigateToTopLevel with same route does not duplicate`() {
|
||||
val navigator = createNavigator(NewChat)
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
assertEquals(listOf(NewChat), navigator.backStack.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigateToTopLevel replaces different root`() {
|
||||
val navigator = createNavigator(NewChat)
|
||||
navigator.navigateToTopLevel(AgentMarketplace)
|
||||
assertEquals(listOf(AgentMarketplace), navigator.backStack.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigateToAuth clears stack and adds ServerUrl`() {
|
||||
val navigator = createNavigator(NewChat, Chat("conv-1"), SettingsTabbed)
|
||||
navigator.navigateToAuth()
|
||||
assertEquals(listOf(ServerUrl), navigator.backStack.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigateToChat no-arg clears stack and adds NewChat`() {
|
||||
val navigator = createNavigator(ServerUrl, Login)
|
||||
navigator.navigateToChat()
|
||||
assertEquals(listOf(NewChat), navigator.backStack.toList())
|
||||
}
|
||||
}
|
||||
|
|
@ -2,16 +2,31 @@
|
|||
|
||||
Convention plugins that apply consistent Gradle configuration across all modules.
|
||||
|
||||
## Convention Plugins (7 total)
|
||||
## Convention Plugins (13 total)
|
||||
|
||||
### Android
|
||||
| Plugin ID | Class | What it does |
|
||||
|-----------|-------|-------------|
|
||||
| `librechat.mobile.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 35, minSdk 26, JDK 17 |
|
||||
| `librechat.mobile.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 36, minSdk 26, JDK 17 |
|
||||
| `librechat.mobile.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config |
|
||||
| `librechat.mobile.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 |
|
||||
| `librechat.mobile.koin` | `AndroidKoinConventionPlugin` | Koin core + Android dependencies |
|
||||
| `librechat.mobile.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + koin + serialization |
|
||||
| `librechat.mobile.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + koin + serialization + Nav 3 |
|
||||
| `librechat.mobile.room` | `AndroidRoomConventionPlugin` | Room + KSP, schema export config |
|
||||
| `librechat.mobile.detekt` | `AndroidDetektConventionPlugin` | Detekt static analysis |
|
||||
|
||||
### KMP
|
||||
| Plugin ID | Class | What it does |
|
||||
|-----------|-------|-------------|
|
||||
| `librechat.kmp.library` | `KmpLibraryConventionPlugin` | KMP library setup (Android + iOS targets) |
|
||||
| `librechat.kmp.compose` | `KmpComposeConventionPlugin` | Compose Multiplatform + Material 3 |
|
||||
| `librechat.kmp.koin` | `KmpKoinConventionPlugin` | Koin multiplatform dependencies |
|
||||
| `librechat.kmp.feature` | `KmpFeatureConventionPlugin` | Auto-applies: kmp library + compose + koin + serialization + Nav 3 |
|
||||
| `librechat.kmp.room` | `KmpRoomConventionPlugin` | Room multiplatform + KSP |
|
||||
|
||||
### Shared
|
||||
| Plugin ID | Class | What it does |
|
||||
|-----------|-------|-------------|
|
||||
| `librechat.kotlin.serialization` | `KotlinSerializationConventionPlugin` | Kotlinx Serialization plugin + JSON dependency |
|
||||
|
||||
## Critical: apply() Signature
|
||||
|
|
@ -32,6 +47,6 @@ build.gradle.kts files. Use `libs.` accessors (e.g., `libs.koin.android`, `libs.
|
|||
|
||||
## Feature Module Convention
|
||||
|
||||
Feature modules use `id("librechat.mobile.feature")` which auto-applies library, compose,
|
||||
koin, and serialization. They only need to add feature-specific dependencies.
|
||||
Feature modules use `id("librechat.kmp.feature")` (KMP) or `id("librechat.mobile.feature")` (Android-only) which auto-applies library, compose,
|
||||
koin, serialization, and Nav 3. They only need to add feature-specific dependencies.
|
||||
Feature modules depend on `:core:*` only, never on other feature modules.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
|
|||
add("implementation", libs.findLibrary("koin-compose").get())
|
||||
add("implementation", libs.findLibrary("koin-compose-viewmodel").get())
|
||||
add("implementation", libs.findLibrary("koin-compose-viewmodel-navigation").get())
|
||||
add("implementation", libs.findLibrary("navigation-compose").get())
|
||||
add("implementation", libs.findLibrary("navigation3-ui-kmp").get())
|
||||
add("implementation", libs.findLibrary("lifecycle-viewmodel-navigation3-kmp").get())
|
||||
add("implementation", libs.findBundle("lifecycle").get())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@ class KmpFeatureConventionPlugin : Plugin<Project> {
|
|||
implementation(libs.findLibrary("koin-compose").get())
|
||||
implementation(libs.findLibrary("koin-compose-viewmodel").get())
|
||||
implementation(libs.findLibrary("koin-compose-viewmodel-navigation").get())
|
||||
implementation(libs.findLibrary("navigation-compose-kmp").get())
|
||||
implementation(libs.findLibrary("navigation3-ui-kmp").get())
|
||||
implementation(libs.findLibrary("lifecycle-runtime-compose-kmp").get())
|
||||
implementation(libs.findLibrary("lifecycle-viewmodel-compose-kmp").get())
|
||||
implementation(libs.findLibrary("lifecycle-viewmodel-navigation3-kmp").get())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ These rules apply to ALL composables across feature modules, not just core:ui.
|
|||
|
||||
3. **Mark UI state classes `@Immutable`.** This lets the Compose compiler skip recomposition when the reference hasn't changed. All fields must be `val` with stable types.
|
||||
|
||||
4. **Collect state at the narrowest scope.** If only `DrawerContent` uses drawer state, collect inside `DrawerContent` — not in the parent `PhoneLayout` which also owns the NavHost. State changes should only recompose the composable that reads them.
|
||||
4. **Collect state at the narrowest scope.** If only `DrawerContent` uses drawer state, collect inside `DrawerContent` — not in the parent `PhoneLayout` which also owns the NavDisplay. State changes should only recompose the composable that reads them.
|
||||
|
||||
5. **Use `SharingStarted.Eagerly`** for UI state that should be ready before the composable subscribes (avoids empty→populated two-phase render jank on first frame).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
package com.garfiec.librechat.core.ui.components
|
||||
|
||||
import android.os.Build
|
||||
import android.view.RoundedCorner
|
||||
import androidx.compose.animation.EnterExitState
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.Transition
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private const val FALLBACK_CORNER_RADIUS_DP = 24f
|
||||
|
||||
/**
|
||||
* Wraps screen content to apply rounded corners during navigation transitions.
|
||||
* Pass the [transition] from AnimatedVisibilityScope inside a composable() block.
|
||||
*/
|
||||
@Composable
|
||||
actual fun ScreenTransitionWrapper(
|
||||
transition: Transition<EnterExitState>,
|
||||
modifier: Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val screenCornerRadiusDp = rememberScreenCornerRadiusDp()
|
||||
|
||||
val cornerRadius by transition.animateFloat(
|
||||
transitionSpec = { tween(300, easing = LinearEasing) },
|
||||
label = "screenCorner",
|
||||
) { state ->
|
||||
when (state) {
|
||||
EnterExitState.PreEnter -> screenCornerRadiusDp
|
||||
EnterExitState.Visible -> 0f
|
||||
EnterExitState.PostExit -> screenCornerRadiusDp
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(cornerRadius.dp)),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberScreenCornerRadiusDp(): Float {
|
||||
val view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
return remember(view, density) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val insets = view.rootWindowInsets
|
||||
val topLeft = insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT)
|
||||
if (topLeft != null && topLeft.radius > 0) {
|
||||
with(density) { topLeft.radius.toDp().value }
|
||||
} else {
|
||||
FALLBACK_CORNER_RADIUS_DP
|
||||
}
|
||||
} else {
|
||||
FALLBACK_CORNER_RADIUS_DP
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
package com.garfiec.librechat.core.ui.components
|
||||
|
||||
import androidx.compose.animation.EnterExitState
|
||||
import androidx.compose.animation.core.Transition
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* Wraps screen content to apply rounded corners during navigation transitions.
|
||||
* On Android, reads the device screen corner radius for a polished effect.
|
||||
* On iOS, renders content directly (no transition animation needed).
|
||||
*/
|
||||
@Composable
|
||||
expect fun ScreenTransitionWrapper(
|
||||
transition: Transition<EnterExitState>,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
)
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
package com.garfiec.librechat.core.ui.components
|
||||
|
||||
import androidx.compose.animation.EnterExitState
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.Transition
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
|
||||
@Composable
|
||||
actual fun ScreenTransitionWrapper(
|
||||
transition: Transition<EnterExitState>,
|
||||
modifier: Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val alpha by transition.animateFloat(
|
||||
transitionSpec = { tween(300, easing = LinearEasing) },
|
||||
label = "screenAlpha",
|
||||
) { state ->
|
||||
when (state) {
|
||||
EnterExitState.PreEnter -> 0f
|
||||
EnterExitState.Visible -> 1f
|
||||
EnterExitState.PostExit -> 0f
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer { this.alpha = alpha },
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,9 @@
|
|||
- **AgentDetailScreen** -- full agent info with "Start Chat" action
|
||||
|
||||
## Navigation
|
||||
- `AGENTS_ROUTE` ("agents") -> marketplace grid
|
||||
- `AGENT_DETAIL_ROUTE` ("agents/{agentId}") -> detail view
|
||||
- Sealed interface: `AgentsRoute : NavKey` with typed route classes
|
||||
- Routes: `AgentMarketplace` (grid), `AgentDetail(agentId: String)` (detail), `AgentEditorCreate`, `AgentEditorEdit(agentId: String)` (all `@Serializable`)
|
||||
- Feature entries registered via `EntryProviderScope<NavKey>.agentsEntries()`
|
||||
- `onStartChat(agentId)` callback navigates to chat with the selected agent
|
||||
|
||||
## Marketplace ViewModel
|
||||
|
|
|
|||
|
|
@ -14,14 +14,22 @@ val agentsModule = module {
|
|||
includes(agentsPlatformModule)
|
||||
|
||||
viewModelOf(::AgentMarketplaceViewModel)
|
||||
viewModelOf(::AgentDetailViewModel)
|
||||
viewModel {
|
||||
viewModel { params ->
|
||||
AgentDetailViewModel(
|
||||
savedStateHandle = get(),
|
||||
agentRepository = get(),
|
||||
serverDataStore = get(),
|
||||
initialAgentId = params.getOrNull(),
|
||||
)
|
||||
}
|
||||
viewModel { params ->
|
||||
AgentEditorViewModel(
|
||||
savedStateHandle = get(),
|
||||
agentRepository = get(),
|
||||
configRepository = get(),
|
||||
mcpRepository = get(),
|
||||
contentReader = get(),
|
||||
initialAgentId = params.getOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package com.garfiec.librechat.feature.agents.navigation
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.agents.screen.AgentDetailScreen
|
||||
import com.garfiec.librechat.feature.agents.screen.AgentEditorScreen
|
||||
import com.garfiec.librechat.feature.agents.screen.AgentMarketplaceScreen
|
||||
|
|
@ -10,51 +10,52 @@ import kotlinx.serialization.modules.SerializersModule
|
|||
import kotlinx.serialization.modules.polymorphic
|
||||
import kotlinx.serialization.modules.subclass
|
||||
|
||||
@Serializable sealed interface AgentsRoute
|
||||
@Serializable sealed interface AgentsRoute : NavKey
|
||||
|
||||
@Serializable data object AgentMarketplace : AgentsRoute
|
||||
@Serializable data class AgentDetail(val agentId: String) : AgentsRoute
|
||||
@Serializable data object AgentEditorCreate : AgentsRoute
|
||||
@Serializable data class AgentEditorEdit(val agentId: String) : AgentsRoute
|
||||
|
||||
fun NavGraphBuilder.agentsGraph(
|
||||
onAgentClick: (String) -> Unit,
|
||||
fun EntryProviderScope<NavKey>.agentsEntries(
|
||||
onNavigate: (NavKey) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onStartChat: (String) -> Unit,
|
||||
onCreateAgent: () -> Unit,
|
||||
onEditAgent: (String) -> Unit,
|
||||
) {
|
||||
composable<AgentMarketplace> {
|
||||
entry<AgentMarketplace> {
|
||||
AgentMarketplaceScreen(
|
||||
onAgentClick = onAgentClick,
|
||||
onCreateAgent = onCreateAgent,
|
||||
onAgentClick = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
|
||||
onCreateAgent = { onNavigate(AgentEditorCreate) },
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
composable<AgentDetail> {
|
||||
entry<AgentDetail> { key ->
|
||||
AgentDetailScreen(
|
||||
onBack = onBack,
|
||||
onStartChat = onStartChat,
|
||||
onEdit = onEditAgent,
|
||||
onDuplicated = onAgentClick,
|
||||
onEdit = { agentId -> onNavigate(AgentEditorEdit(agentId = agentId)) },
|
||||
onDuplicated = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
|
||||
agentId = key.agentId,
|
||||
)
|
||||
}
|
||||
composable<AgentEditorCreate> {
|
||||
entry<AgentEditorCreate> {
|
||||
AgentEditorScreen(
|
||||
onBack = onBack,
|
||||
onSaved = { agentId -> onAgentClick(agentId) },
|
||||
onSaved = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
|
||||
agentId = null,
|
||||
)
|
||||
}
|
||||
composable<AgentEditorEdit> {
|
||||
entry<AgentEditorEdit> { key ->
|
||||
AgentEditorScreen(
|
||||
onBack = onBack,
|
||||
onSaved = { agentId -> onAgentClick(agentId) },
|
||||
onSaved = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
|
||||
agentId = key.agentId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val agentsSerializersModule = SerializersModule {
|
||||
polymorphic(AgentsRoute::class) {
|
||||
polymorphic(NavKey::class) {
|
||||
subclass(AgentMarketplace::class, AgentMarketplace.serializer())
|
||||
subclass(AgentDetail::class, AgentDetail.serializer())
|
||||
subclass(AgentEditorCreate::class, AgentEditorCreate.serializer())
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import librechat_mobile.feature.agents.generated.resources.*
|
|||
import com.garfiec.librechat.feature.agents.viewmodel.AgentDetailEvent
|
||||
import com.garfiec.librechat.feature.agents.viewmodel.AgentDetailViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
|
|
@ -65,8 +66,9 @@ fun AgentDetailScreen(
|
|||
onStartChat: (String) -> Unit,
|
||||
onEdit: (String) -> Unit,
|
||||
onDuplicated: (String) -> Unit,
|
||||
agentId: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AgentDetailViewModel = koinViewModel(),
|
||||
viewModel: AgentDetailViewModel = koinViewModel { parametersOf(agentId) },
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
|
|
|
|||
|
|
@ -71,16 +71,18 @@ import com.garfiec.librechat.feature.agents.components.ToolSelectDialog
|
|||
import com.garfiec.librechat.feature.agents.viewmodel.AgentEditorEvent
|
||||
import com.garfiec.librechat.feature.agents.viewmodel.AgentEditorViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AgentEditorScreen(
|
||||
onBack: () -> Unit,
|
||||
onSaved: (String) -> Unit,
|
||||
agentId: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
onDeleted: () -> Unit = onBack,
|
||||
onDuplicated: (String) -> Unit = onSaved,
|
||||
viewModel: AgentEditorViewModel = koinViewModel(),
|
||||
viewModel: AgentEditorViewModel = koinViewModel { parametersOf(agentId) },
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var showToolDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
|
|
|||
|
|
@ -36,9 +36,10 @@ class AgentDetailViewModel(
|
|||
savedStateHandle: SavedStateHandle,
|
||||
private val agentRepository: AgentRepository,
|
||||
private val serverDataStore: ServerDataStore,
|
||||
initialAgentId: String? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
private val agentId: String = checkNotNull(savedStateHandle["agentId"])
|
||||
private val agentId: String = checkNotNull(initialAgentId) { "agentId must be provided" }
|
||||
|
||||
private val _uiState = MutableStateFlow(AgentDetailUiState())
|
||||
val uiState: StateFlow<AgentDetailUiState> = _uiState.asStateFlow()
|
||||
|
|
|
|||
|
|
@ -107,9 +107,10 @@ class AgentEditorViewModel(
|
|||
private val configRepository: ConfigRepository,
|
||||
private val mcpRepository: McpRepository,
|
||||
private val contentReader: ContentReader,
|
||||
initialAgentId: String? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
private val editAgentId: String? = savedStateHandle["agentId"]
|
||||
private val editAgentId: String? = initialAgentId
|
||||
|
||||
private val _uiState = MutableStateFlow(
|
||||
AgentEditorUiState(
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@
|
|||
- **TwoFactorScreen** -- 6-digit OTP input with backup code fallback
|
||||
|
||||
## Navigation
|
||||
- Graph route: `AUTH_GRAPH_ROUTE` ("auth_graph"), start destination: `SERVER_URL_ROUTE`
|
||||
- Flow: ServerUrl -> Login -> (2FA if `tempToken` returned) -> `onAuthComplete`
|
||||
- Sealed interface: `AuthRoute : NavKey` with typed route classes
|
||||
- Routes: `ServerUrl`, `Login`, `Register`, `ForgotPassword`, `TwoFactor(tempToken)`, `VerifyEmail(email)`, `ResetPassword(userId, token)`, `Terms` (all `@Serializable`)
|
||||
- Feature entries registered via `EntryProviderScope<NavKey>.authEntries()`
|
||||
- Flow: `ServerUrl` → `Login` → (2FA if `tempToken` returned) → `onAuthComplete`
|
||||
- Register and ForgotPassword are lateral routes from Login
|
||||
- `TWO_FACTOR_ROUTE` takes `{tempToken}` nav argument
|
||||
- `TwoFactor(val tempToken: String)` data class carries the nav argument directly
|
||||
|
||||
## OAuth Flow
|
||||
- `OAuthManager` opens Chrome Custom Tabs to `{serverUrl}/api/oauth/{provider}`
|
||||
|
|
@ -37,7 +39,7 @@
|
|||
|
||||
### Terms Screen
|
||||
- `TermsScreen` + `TermsViewModel` — displays server terms, "I Accept" button
|
||||
- Route: `TERMS_ROUTE = "auth/terms"` in `AuthNavigation.kt`
|
||||
- Route: `Terms` data object (part of `AuthRoute` sealed interface) in `AuthNavigation.kt`
|
||||
- Loads terms text via `UserRepository`, posts acceptance on confirm
|
||||
- `TermsViewModel.consumeAccepted()` resets navigation trigger
|
||||
- **Gotcha**: Terms check should happen after login if `startupConfig.requireTerms` is true
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.garfiec.librechat.feature.auth.viewmodel.TermsViewModel
|
|||
import com.garfiec.librechat.feature.auth.viewmodel.TwoFactorViewModel
|
||||
import com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
|
|
@ -19,9 +20,28 @@ val authModule = module {
|
|||
viewModelOf(::ServerUrlViewModel)
|
||||
viewModelOf(::LoginViewModel)
|
||||
viewModelOf(::RegisterViewModel)
|
||||
viewModelOf(::VerifyEmailViewModel)
|
||||
viewModel { params ->
|
||||
VerifyEmailViewModel(
|
||||
savedStateHandle = get(),
|
||||
userRepository = get(),
|
||||
initialEmail = params.getOrNull(),
|
||||
)
|
||||
}
|
||||
viewModelOf(::ForgotPasswordViewModel)
|
||||
viewModelOf(::ResetPasswordViewModel)
|
||||
viewModelOf(::TwoFactorViewModel)
|
||||
viewModel { params ->
|
||||
ResetPasswordViewModel(
|
||||
savedStateHandle = get(),
|
||||
authRepository = get(),
|
||||
initialUserId = params.getOrNull(),
|
||||
initialToken = params.getOrNull(),
|
||||
)
|
||||
}
|
||||
viewModel { params ->
|
||||
TwoFactorViewModel(
|
||||
savedStateHandle = get(),
|
||||
authRepository = get(),
|
||||
initialTempToken = params.getOrNull(),
|
||||
)
|
||||
}
|
||||
viewModelOf(::TermsViewModel)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
package com.garfiec.librechat.feature.auth.navigation
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navigation
|
||||
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.auth.screen.ForgotPasswordScreen
|
||||
import com.garfiec.librechat.feature.auth.screen.LoginScreen
|
||||
import com.garfiec.librechat.feature.auth.screen.RegisterScreen
|
||||
|
|
@ -18,7 +15,7 @@ import kotlinx.serialization.modules.SerializersModule
|
|||
import kotlinx.serialization.modules.polymorphic
|
||||
import kotlinx.serialization.modules.subclass
|
||||
|
||||
@Serializable sealed interface AuthRoute
|
||||
@Serializable sealed interface AuthRoute : NavKey
|
||||
|
||||
@Serializable data object ServerUrl : AuthRoute
|
||||
@Serializable data object Login : AuthRoute
|
||||
|
|
@ -29,90 +26,69 @@ import kotlinx.serialization.modules.subclass
|
|||
@Serializable data object Terms : AuthRoute
|
||||
@Serializable data class ResetPassword(val userId: String, val token: String) : AuthRoute
|
||||
|
||||
fun NavController.navigateToVerifyEmail(email: String) {
|
||||
navigate(VerifyEmail(email = email))
|
||||
}
|
||||
|
||||
fun NavController.navigateToResetPassword(userId: String, token: String) {
|
||||
navigate(ResetPassword(userId = userId, token = token))
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.authGraph(
|
||||
navController: NavController,
|
||||
fun EntryProviderScope<NavKey>.authEntries(
|
||||
onNavigate: (NavKey) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onAuthComplete: () -> Unit,
|
||||
) {
|
||||
navigation<AuthRoute>(startDestination = ServerUrl::class) {
|
||||
composable<ServerUrl> {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
ServerUrlScreen(
|
||||
onServerValidated = { navController.navigate(Login) },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable<Login> {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
LoginScreen(
|
||||
onLoginSuccess = onAuthComplete,
|
||||
onNavigateToRegister = { navController.navigate(Register) },
|
||||
onNavigateToForgotPassword = { navController.navigate(ForgotPassword) },
|
||||
onNavigateToTwoFactor = { tempToken ->
|
||||
navController.navigate(TwoFactor(tempToken = tempToken))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
composable<Register> {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
RegisterScreen(
|
||||
onRegistered = { navController.popBackStack<Login>(inclusive = false) },
|
||||
onNavigateToLogin = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable<ForgotPassword> {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
ForgotPasswordScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable<TwoFactor> {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
TwoFactorScreen(
|
||||
onVerified = onAuthComplete,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable<VerifyEmail> {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
VerifyEmailScreen(
|
||||
onVerified = { navController.popBackStack<Login>(inclusive = false) },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable<Terms> {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
TermsScreen(
|
||||
onAccepted = onAuthComplete,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable<ResetPassword> {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
ResetPasswordScreen(
|
||||
onResetComplete = { navController.popBackStack<Login>(inclusive = false) },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
entry<ServerUrl> {
|
||||
ServerUrlScreen(
|
||||
onServerValidated = { onNavigate(Login) },
|
||||
)
|
||||
}
|
||||
entry<Login> {
|
||||
LoginScreen(
|
||||
onLoginSuccess = onAuthComplete,
|
||||
onNavigateToRegister = { onNavigate(Register) },
|
||||
onNavigateToForgotPassword = { onNavigate(ForgotPassword) },
|
||||
onNavigateToTwoFactor = { tempToken ->
|
||||
onNavigate(TwoFactor(tempToken = tempToken))
|
||||
},
|
||||
)
|
||||
}
|
||||
entry<Register> {
|
||||
RegisterScreen(
|
||||
onRegistered = onBack,
|
||||
onNavigateToLogin = onBack,
|
||||
)
|
||||
}
|
||||
entry<ForgotPassword> {
|
||||
ForgotPasswordScreen(
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
entry<TwoFactor> { key ->
|
||||
TwoFactorScreen(
|
||||
onVerified = onAuthComplete,
|
||||
onBack = onBack,
|
||||
tempToken = key.tempToken,
|
||||
)
|
||||
}
|
||||
entry<VerifyEmail> { key ->
|
||||
VerifyEmailScreen(
|
||||
onVerified = onBack,
|
||||
onBack = onBack,
|
||||
email = key.email,
|
||||
)
|
||||
}
|
||||
entry<Terms> {
|
||||
TermsScreen(
|
||||
onAccepted = onAuthComplete,
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
entry<ResetPassword> { key ->
|
||||
ResetPasswordScreen(
|
||||
onResetComplete = onBack,
|
||||
onBack = onBack,
|
||||
userId = key.userId,
|
||||
token = key.token,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val authSerializersModule = SerializersModule {
|
||||
polymorphic(AuthRoute::class) {
|
||||
polymorphic(NavKey::class) {
|
||||
subclass(ServerUrl::class, ServerUrl.serializer())
|
||||
subclass(Login::class, Login.serializer())
|
||||
subclass(Register::class, Register.serializer())
|
||||
|
|
|
|||
|
|
@ -35,14 +35,17 @@ import librechat_mobile.feature.auth.generated.resources.Res
|
|||
import librechat_mobile.feature.auth.generated.resources.*
|
||||
import com.garfiec.librechat.feature.auth.viewmodel.ResetPasswordViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ResetPasswordScreen(
|
||||
onResetComplete: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
userId: String? = null,
|
||||
token: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ResetPasswordViewModel = koinViewModel(),
|
||||
viewModel: ResetPasswordViewModel = koinViewModel { parametersOf(userId, token) },
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
|
|
|
|||
|
|
@ -42,14 +42,16 @@ import librechat_mobile.feature.auth.generated.resources.Res
|
|||
import librechat_mobile.feature.auth.generated.resources.*
|
||||
import com.garfiec.librechat.feature.auth.viewmodel.TwoFactorViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TwoFactorScreen(
|
||||
onVerified: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
tempToken: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TwoFactorViewModel = koinViewModel(),
|
||||
viewModel: TwoFactorViewModel = koinViewModel { parametersOf(tempToken) },
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
|
|
|
|||
|
|
@ -34,14 +34,16 @@ import librechat_mobile.feature.auth.generated.resources.Res
|
|||
import librechat_mobile.feature.auth.generated.resources.*
|
||||
import com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VerifyEmailScreen(
|
||||
onVerified: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
email: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: VerifyEmailViewModel = koinViewModel(),
|
||||
viewModel: VerifyEmailViewModel = koinViewModel { parametersOf(email) },
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val email = uiState.email
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@ data class ResetPasswordUiState(
|
|||
class ResetPasswordViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val authRepository: AuthRepository,
|
||||
initialUserId: String? = null,
|
||||
initialToken: String? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
private val userId: String = savedStateHandle["userId"] ?: ""
|
||||
private val token: String = savedStateHandle["token"] ?: ""
|
||||
private val userId: String = initialUserId ?: ""
|
||||
private val token: String = initialToken ?: ""
|
||||
|
||||
private val _uiState = MutableStateFlow(ResetPasswordUiState())
|
||||
val uiState: StateFlow<ResetPasswordUiState> = _uiState.asStateFlow()
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ data class TwoFactorUiState(
|
|||
class TwoFactorViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val authRepository: AuthRepository,
|
||||
initialTempToken: String? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
private val tempToken: String = savedStateHandle["tempToken"] ?: ""
|
||||
private val tempToken: String = initialTempToken ?: ""
|
||||
|
||||
private val _uiState = MutableStateFlow(TwoFactorUiState())
|
||||
val uiState: StateFlow<TwoFactorUiState> = _uiState.asStateFlow()
|
||||
|
|
|
|||
|
|
@ -26,10 +26,11 @@ data class VerifyEmailUiState(
|
|||
class VerifyEmailViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val userRepository: UserRepository,
|
||||
initialEmail: String? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(
|
||||
VerifyEmailUiState(email = savedStateHandle["email"] ?: ""),
|
||||
VerifyEmailUiState(email = initialEmail ?: ""),
|
||||
)
|
||||
val uiState: StateFlow<VerifyEmailUiState> = _uiState.asStateFlow()
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@
|
|||
- **Active**: message list + streaming content + input area
|
||||
|
||||
## Navigation
|
||||
- `CHAT_GRAPH_ROUTE` with `NEW_CHAT_ROUTE` (landing) and `CHAT_ROUTE` ("chat/{conversationId}")
|
||||
- `PROMPTS_LIBRARY_ROUTE` is co-located here for prompts library screen
|
||||
- When a new conversation starts from the landing page, `pendingNavigationConversationId` triggers navigation to `chat/{id}` after the first stream completes (data persisted to Room), keeping `new_chat` in the back stack
|
||||
- `navigateToChat()` replaces the current chat entry when switching between chats so back returns to `new_chat`
|
||||
- Sealed interface: `ChatRoute : NavKey` with typed route classes
|
||||
- Routes: `NewChat` (landing), `Chat(conversationId: String? = null)`, `PromptsLibrary`, `PromptEditor(groupId: String? = null)` (all `@Serializable`)
|
||||
- Feature entries registered via `EntryProviderScope<NavKey>.chatEntries()`
|
||||
- When a new conversation starts from the landing page, `pendingNavigationConversationId` triggers navigation to `Chat(id)` after the first stream completes (data persisted to Room), keeping `NewChat` in the back stack
|
||||
- `navigateToChat()` replaces the current chat entry on the `NavBackStack` when switching between chats so back returns to `NewChat`
|
||||
|
||||
## Message Tree
|
||||
- Messages stored flat, linked by `parentMessageId` (root = `NO_PARENT` UUID)
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@ actual val chatPlatformModule: Module = module {
|
|||
)
|
||||
}
|
||||
|
||||
viewModel {
|
||||
viewModel { params ->
|
||||
ChatViewModel(
|
||||
savedStateHandle = get(),
|
||||
initialConversationId = params.getOrNull(),
|
||||
agentRepository = get(),
|
||||
chatRepository = get(),
|
||||
messageRepository = get(),
|
||||
|
|
|
|||
|
|
@ -97,18 +97,20 @@ import com.garfiec.librechat.feature.chat.viewmodel.ChatScreenState
|
|||
import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
actual fun ChatScreen(
|
||||
modifier: Modifier,
|
||||
conversationId: String?,
|
||||
onConversationStarted: ((String) -> Unit)?,
|
||||
onNavigateToConversation: ((String) -> Unit)?,
|
||||
onOpenDrawer: (() -> Unit)?,
|
||||
onNavigateToPromptsLibrary: (() -> Unit)?,
|
||||
onNavigateBack: (() -> Unit)?,
|
||||
) {
|
||||
val viewModel: ChatViewModel = koinViewModel()
|
||||
val viewModel: ChatViewModel = koinViewModel { parametersOf(conversationId) }
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle()
|
||||
val shareLinkUrl by viewModel.shareLinkUrl.collectAsStateWithLifecycle()
|
||||
|
|
@ -232,9 +234,9 @@ actual fun ChatScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// When a new conversation starts, navigate to chat/{conversationId} immediately
|
||||
// (at StreamEvent.Created) so the new_chat landing page stays clean in the back
|
||||
// stack. The new ChatViewModel at chat/{id} will resume the active stream.
|
||||
// When a new conversation starts, navigate to Chat(conversationId) immediately
|
||||
// (at StreamEvent.Created) so the NewChat landing page stays clean in the back
|
||||
// stack. The new ChatViewModel at Chat(id) will resume the active stream.
|
||||
// onPendingNavigationHandled() resets this ViewModel to a fresh landing state.
|
||||
LaunchedEffect(uiState.pendingNavigationConversationId) {
|
||||
val pendingId = uiState.pendingNavigationConversationId
|
||||
|
|
|
|||
|
|
@ -3,13 +3,20 @@ package com.garfiec.librechat.feature.chat.di
|
|||
import com.garfiec.librechat.feature.chat.prompts.PromptEditorViewModel
|
||||
import com.garfiec.librechat.feature.chat.prompts.PromptsViewModel
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val chatModule = module {
|
||||
includes(chatPlatformModule)
|
||||
viewModelOf(::PromptsViewModel)
|
||||
viewModelOf(::PromptEditorViewModel)
|
||||
viewModel { params ->
|
||||
PromptEditorViewModel(
|
||||
promptRepository = get(),
|
||||
savedStateHandle = get(),
|
||||
initialGroupId = params.getOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
expect val chatPlatformModule: Module
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
package com.garfiec.librechat.feature.chat.navigation
|
||||
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navigation
|
||||
import kotlin.reflect.KClass
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.chat.prompts.PromptEditorScreen
|
||||
import com.garfiec.librechat.feature.chat.prompts.PromptsLibraryScreen
|
||||
import com.garfiec.librechat.feature.chat.screen.ChatScreen
|
||||
|
|
@ -17,96 +11,56 @@ import kotlinx.serialization.modules.SerializersModule
|
|||
import kotlinx.serialization.modules.polymorphic
|
||||
import kotlinx.serialization.modules.subclass
|
||||
|
||||
@Serializable sealed interface ChatRoute
|
||||
@Serializable sealed interface ChatRoute : NavKey
|
||||
|
||||
@Serializable data object NewChat : ChatRoute
|
||||
@Serializable data class Chat(val conversationId: String? = null) : ChatRoute
|
||||
@Serializable data object PromptsLibrary : ChatRoute
|
||||
@Serializable data class PromptEditor(val groupId: String? = null) : ChatRoute
|
||||
|
||||
/** Checks if this destination's route matches the given typed route class. */
|
||||
internal fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
|
||||
val qualifiedName = routeClass.qualifiedName ?: return false
|
||||
return this?.route?.startsWith(qualifiedName) == true
|
||||
}
|
||||
|
||||
fun NavController.navigateToChat(conversationId: String) {
|
||||
val currentEntry = currentBackStackEntry
|
||||
val isCurrentlyInChat = currentEntry?.destination.isRoute(Chat::class)
|
||||
val currentDestId = currentEntry?.destination?.id
|
||||
navigate(Chat(conversationId = conversationId)) {
|
||||
if (isCurrentlyInChat && currentDestId != null) {
|
||||
// Already viewing a chat -- replace it so switching chats
|
||||
// doesn't stack entries. Back will go to whatever was
|
||||
// before the first chat (e.g. new_chat, settings, agents).
|
||||
popUpTo(currentDestId) { inclusive = true }
|
||||
}
|
||||
// From non-chat screens just push onto the backstack normally
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToPromptsLibrary() {
|
||||
navigate(PromptsLibrary)
|
||||
}
|
||||
|
||||
fun NavController.navigateToPromptEditor(groupId: String? = null) {
|
||||
navigate(PromptEditor(groupId = groupId))
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.chatGraph(
|
||||
navController: NavController,
|
||||
fun EntryProviderScope<NavKey>.chatEntries(
|
||||
onNavigate: (NavKey) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToChat: (String) -> Unit,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
) {
|
||||
navigation<ChatRoute>(startDestination = NewChat::class) {
|
||||
composable<NewChat>(
|
||||
enterTransition = { EnterTransition.None },
|
||||
exitTransition = { ExitTransition.None },
|
||||
popEnterTransition = { null },
|
||||
popExitTransition = { null },
|
||||
) {
|
||||
NewChatScreen(
|
||||
onConversationStarted = { conversationId ->
|
||||
navController.navigateToChat(conversationId)
|
||||
},
|
||||
onOpenDrawer = onOpenDrawer,
|
||||
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() },
|
||||
)
|
||||
}
|
||||
composable<Chat>(
|
||||
enterTransition = { EnterTransition.None },
|
||||
exitTransition = { ExitTransition.None },
|
||||
popEnterTransition = { null },
|
||||
popExitTransition = { null },
|
||||
) { _ ->
|
||||
ChatScreen(
|
||||
onOpenDrawer = onOpenDrawer,
|
||||
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() },
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToConversation = { conversationId -> navController.navigateToChat(conversationId) },
|
||||
)
|
||||
}
|
||||
composable<PromptsLibrary> {
|
||||
PromptsLibraryScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onUseInChat = { promptText ->
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToEditor = { groupId ->
|
||||
navController.navigateToPromptEditor(groupId)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable<PromptEditor> {
|
||||
PromptEditorScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
entry<NewChat> {
|
||||
NewChatScreen(
|
||||
onConversationStarted = { conversationId ->
|
||||
onNavigateToChat(conversationId)
|
||||
},
|
||||
onOpenDrawer = onOpenDrawer,
|
||||
onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) },
|
||||
)
|
||||
}
|
||||
entry<Chat> { key ->
|
||||
ChatScreen(
|
||||
conversationId = key.conversationId,
|
||||
onOpenDrawer = onOpenDrawer,
|
||||
onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) },
|
||||
onNavigateBack = onBack,
|
||||
onNavigateToConversation = { conversationId -> onNavigateToChat(conversationId) },
|
||||
)
|
||||
}
|
||||
entry<PromptsLibrary> {
|
||||
PromptsLibraryScreen(
|
||||
onNavigateBack = onBack,
|
||||
onUseInChat = { _ -> onBack() },
|
||||
onNavigateToEditor = { groupId ->
|
||||
onNavigate(PromptEditor(groupId = groupId))
|
||||
},
|
||||
)
|
||||
}
|
||||
entry<PromptEditor> { key ->
|
||||
PromptEditorScreen(
|
||||
onBack = onBack,
|
||||
groupId = key.groupId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val chatSerializersModule = SerializersModule {
|
||||
polymorphic(ChatRoute::class) {
|
||||
polymorphic(NavKey::class) {
|
||||
subclass(NewChat::class, NewChat.serializer())
|
||||
subclass(Chat::class, Chat.serializer())
|
||||
subclass(PromptsLibrary::class, PromptsLibrary.serializer())
|
||||
|
|
|
|||
|
|
@ -38,13 +38,15 @@ import com.garfiec.librechat.core.ui.components.LoadingIndicator
|
|||
import librechat_mobile.feature.chat.generated.resources.Res
|
||||
import librechat_mobile.feature.chat.generated.resources.*
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PromptEditorScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PromptEditorViewModel = koinViewModel(),
|
||||
groupId: String? = null,
|
||||
viewModel: PromptEditorViewModel = koinViewModel { parametersOf(groupId) },
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
|
|
|||
|
|
@ -40,13 +40,14 @@ data class PromptEditorUiState(
|
|||
class PromptEditorViewModel(
|
||||
private val promptRepository: PromptRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
initialGroupId: String? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(PromptEditorUiState())
|
||||
val uiState: StateFlow<PromptEditorUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
val groupId = savedStateHandle.get<String>("groupId")
|
||||
val groupId = initialGroupId
|
||||
if (groupId != null) {
|
||||
loadGroup(groupId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
|
|||
@Composable
|
||||
expect fun ChatScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
conversationId: String? = null,
|
||||
onConversationStarted: ((String) -> Unit)? = null,
|
||||
onNavigateToConversation: ((String) -> Unit)? = null,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ data class ChatUiState(
|
|||
val showDeleteConfirmation: Boolean = false,
|
||||
val duplicatedConversationId: String? = null,
|
||||
/** Set at StreamEvent.Created when a new conversation's conversationId becomes available.
|
||||
* The UI navigates to chat/{id} and then clears this via [ChatViewModel.onPendingNavigationHandled],
|
||||
* The UI navigates to Chat(id) and then clears this via [ChatViewModel.onPendingNavigationHandled],
|
||||
* which also resets this ViewModel to a clean landing state. */
|
||||
val pendingNavigationConversationId: String? = null,
|
||||
// Model comparison state
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import kotlin.uuid.Uuid
|
|||
|
||||
class ChatViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
initialConversationId: String? = null,
|
||||
private val agentRepository: AgentRepository,
|
||||
private val chatRepository: ChatRepository,
|
||||
private val messageRepository: MessageRepository,
|
||||
|
|
@ -163,7 +164,7 @@ class ChatViewModel(
|
|||
private var titleGenerationRequested = false
|
||||
|
||||
init {
|
||||
val conversationId = savedStateHandle.get<String>("conversationId")
|
||||
val conversationId = initialConversationId
|
||||
isNewConversation = conversationId == null
|
||||
if (conversationId != null) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
|
|
@ -174,7 +175,7 @@ class ChatViewModel(
|
|||
loadConversationModel(conversationId)
|
||||
restoreDraft(conversationId)
|
||||
// Check if there's an active stream for this conversation (e.g. when
|
||||
// navigating here from new_chat immediately after sending). If so,
|
||||
// navigating here from NewChat immediately after sending). If so,
|
||||
// resume it so the user sees streaming content on this screen.
|
||||
resumeActiveStreamIfNeeded(conversationId)
|
||||
} else {
|
||||
|
|
@ -255,7 +256,7 @@ class ChatViewModel(
|
|||
}
|
||||
|
||||
// Restore MCP server and tool selections from DataStore so they
|
||||
// survive the new_chat -> chat/{id} navigation re-creation.
|
||||
// survive the NewChat -> Chat(id) navigation re-creation.
|
||||
viewModelScope.launch {
|
||||
val mcpServers = settingsDataStore.selectedMcpServers.first()
|
||||
val tools = settingsDataStore.enabledTools.first()
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@ actual val chatPlatformModule: Module = module {
|
|||
)
|
||||
}
|
||||
|
||||
viewModel {
|
||||
viewModel { params ->
|
||||
ChatViewModel(
|
||||
savedStateHandle = get(),
|
||||
initialConversationId = params.getOrNull(),
|
||||
agentRepository = get(),
|
||||
chatRepository = get(),
|
||||
messageRepository = get(),
|
||||
|
|
|
|||
|
|
@ -81,18 +81,20 @@ import librechat_mobile.feature.chat.generated.resources.Res
|
|||
import librechat_mobile.feature.chat.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
actual fun ChatScreen(
|
||||
modifier: Modifier,
|
||||
conversationId: String?,
|
||||
onConversationStarted: ((String) -> Unit)?,
|
||||
onNavigateToConversation: ((String) -> Unit)?,
|
||||
onOpenDrawer: (() -> Unit)?,
|
||||
onNavigateToPromptsLibrary: (() -> Unit)?,
|
||||
onNavigateBack: (() -> Unit)?,
|
||||
) {
|
||||
val viewModel: ChatViewModel = koinViewModel()
|
||||
val viewModel: ChatViewModel = koinViewModel { parametersOf(conversationId) }
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle()
|
||||
val prefs by viewModel.chatPreferences.collectAsStateWithLifecycle()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# feature:conversations
|
||||
|
||||
## Screen
|
||||
`ConversationListScreen` -- the drawer content showing all conversations. Single route: `CONVERSATIONS_ROUTE`.
|
||||
`ConversationListScreen` -- the drawer content showing all conversations. Sealed interface: `ConversationsRoute : NavKey` with routes `Conversations` and `ArchivedConversations` (both `@Serializable` data objects).
|
||||
|
||||
## Pagination
|
||||
- Cursor-based via `ConversationRepository.loadNextPage(cursor, tags)`
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
package com.garfiec.librechat.feature.conversations.navigation
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.conversations.screen.ArchivedConversationsScreen
|
||||
import com.garfiec.librechat.feature.conversations.screen.ConversationListScreen
|
||||
import kotlinx.serialization.Serializable
|
||||
|
|
@ -10,35 +9,31 @@ import kotlinx.serialization.modules.SerializersModule
|
|||
import kotlinx.serialization.modules.polymorphic
|
||||
import kotlinx.serialization.modules.subclass
|
||||
|
||||
@Serializable sealed interface ConversationsRoute
|
||||
@Serializable sealed interface ConversationsRoute : NavKey
|
||||
|
||||
@Serializable data object Conversations : ConversationsRoute
|
||||
@Serializable data object ArchivedConversations : ConversationsRoute
|
||||
|
||||
fun NavGraphBuilder.conversationsGraph(
|
||||
fun EntryProviderScope<NavKey>.conversationsEntries(
|
||||
onConversationClick: (String) -> Unit,
|
||||
onNavigateToArchived: () -> Unit = {},
|
||||
onNavigateBackFromArchived: () -> Unit = {},
|
||||
onBack: () -> Unit = {},
|
||||
) {
|
||||
composable<Conversations> {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
ConversationListScreen(
|
||||
onConversationClick = onConversationClick,
|
||||
onNavigateToArchived = onNavigateToArchived,
|
||||
)
|
||||
}
|
||||
entry<Conversations> {
|
||||
ConversationListScreen(
|
||||
onConversationClick = onConversationClick,
|
||||
onNavigateToArchived = onNavigateToArchived,
|
||||
)
|
||||
}
|
||||
composable<ArchivedConversations> {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
ArchivedConversationsScreen(
|
||||
onNavigateBack = onNavigateBackFromArchived,
|
||||
)
|
||||
}
|
||||
entry<ArchivedConversations> {
|
||||
ArchivedConversationsScreen(
|
||||
onNavigateBack = onBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val conversationsSerializersModule = SerializersModule {
|
||||
polymorphic(ConversationsRoute::class) {
|
||||
polymorphic(NavKey::class) {
|
||||
subclass(Conversations::class, Conversations.serializer())
|
||||
subclass(ArchivedConversations::class, ArchivedConversations.serializer())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# feature:files
|
||||
|
||||
## Screen
|
||||
`FilesScreen` -- single route `FILES_ROUTE`. Lists uploaded files with upload and delete actions.
|
||||
`FilesScreen` -- sealed interface `FilesRoute : NavKey` with single route `Files` (`@Serializable` data object). Lists uploaded files with upload and delete actions.
|
||||
|
||||
## ViewModel
|
||||
`FilesViewModel` depends on `FileRepository` and application `Context` (for content resolver).
|
||||
|
|
|
|||
|
|
@ -1,30 +1,27 @@
|
|||
package com.garfiec.librechat.feature.files.navigation
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.files.screen.FilesScreen
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
import kotlinx.serialization.modules.subclass
|
||||
|
||||
@Serializable sealed interface FilesRoute
|
||||
@Serializable sealed interface FilesRoute : NavKey
|
||||
|
||||
@Serializable data object Files : FilesRoute
|
||||
|
||||
fun NavGraphBuilder.filesGraph(
|
||||
fun EntryProviderScope<NavKey>.filesEntries(
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
composable<Files> {
|
||||
ScreenTransitionWrapper(transition) {
|
||||
FilesScreen(onBack = onBack)
|
||||
}
|
||||
entry<Files> {
|
||||
FilesScreen(onBack = onBack)
|
||||
}
|
||||
}
|
||||
|
||||
val filesSerializersModule = SerializersModule {
|
||||
polymorphic(FilesRoute::class) {
|
||||
polymorphic(NavKey::class) {
|
||||
subclass(Files::class, Files.serializer())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# feature:settings
|
||||
|
||||
## Screen
|
||||
`SettingsScreen` -- single route `SETTINGS_ROUTE`. Receives `onLogout` and `onNavigateBack` callbacks.
|
||||
`SettingsScreen` -- sealed interface `SettingsRoute : NavKey` with primary route `SettingsTabbed` (`@Serializable` data object). Receives `onLogout` and `onNavigateBack` callbacks.
|
||||
|
||||
## Sections
|
||||
- **Account**: displays user profile from `UserApi.getUser()`
|
||||
|
|
@ -46,14 +46,14 @@
|
|||
- `McpServersScreen` — server list with status badges, CRUD, reinitialize, tools sheet (`McpViewModel`)
|
||||
- `PresetManagerScreen` — list/delete presets (`PresetManagerViewModel`)
|
||||
- `CommandsConfigScreen` — enable/disable slash commands
|
||||
- Routes: `MEMORIES_ROUTE`, `MCP_SERVERS_ROUTE`, `PRESET_MANAGER_ROUTE`, `COMMANDS_CONFIG_ROUTE`
|
||||
- Routes: `Memories`, `McpServers`, `PresetManager` (all `@Serializable` data objects extending `SettingsRoute`)
|
||||
- **Gotcha**: Memories and MCP navigation routes defined in their own files (`MemoriesNavigation.kt`, `McpNavigation.kt`) but wired through `SettingsNavigation.kt`
|
||||
- **Gotcha**: `McpServerDialog` uses `McpServerType` enum (SSE, STREAMABLE_HTTP) — must match backend expectations
|
||||
- **Gotcha**: Memory keys are immutable after creation (edit dialog disables key field)
|
||||
|
||||
### API Keys
|
||||
- `ApiKeysScreen` — list/create/delete API keys, own ViewModel (`ApiKeysViewModel`)
|
||||
- Route: `API_KEYS_ROUTE` ("settings/api_keys"), accessible from Settings → Security section
|
||||
- Route: `ApiKeys` (`@Serializable` data object extending `SettingsRoute`), accessible from Settings → Security section
|
||||
- `ApiKeyCreateDialog` is two-phase: name input → show created key value with copy button
|
||||
- **Gotcha**: The key value is only available in the creation response — it cannot be retrieved again from the server. The dialog must show it immediately after creation.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
package com.garfiec.librechat.feature.settings.navigation
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.settings.screen.McpServersScreen
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data object McpServers : SettingsRoute
|
||||
|
||||
fun NavGraphBuilder.mcpServersScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
fun EntryProviderScope<NavKey>.mcpServersEntry(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
composable<McpServers> {
|
||||
entry<McpServers> {
|
||||
McpServersScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateBack = onBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
package com.garfiec.librechat.feature.settings.navigation
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.settings.screen.MemoriesScreen
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data object Memories : SettingsRoute
|
||||
|
||||
fun NavGraphBuilder.memoriesScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
fun EntryProviderScope<NavKey>.memoriesEntry(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
composable<Memories> {
|
||||
entry<Memories> {
|
||||
MemoriesScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateBack = onBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package com.garfiec.librechat.feature.settings.navigation
|
|||
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.garfiec.librechat.feature.settings.screen.AccountSettingsScreen
|
||||
import com.garfiec.librechat.feature.settings.screen.ApiKeysScreen
|
||||
import com.garfiec.librechat.feature.settings.screen.ChatSettingsScreen
|
||||
|
|
@ -19,7 +19,7 @@ import kotlinx.serialization.modules.polymorphic
|
|||
import kotlinx.serialization.modules.subclass
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@Serializable sealed interface SettingsRoute
|
||||
@Serializable sealed interface SettingsRoute : NavKey
|
||||
|
||||
@Serializable data object SettingsTabbed : SettingsRoute
|
||||
@Serializable data object SettingsGeneral : SettingsRoute
|
||||
|
|
@ -30,54 +30,48 @@ import org.koin.compose.viewmodel.koinViewModel
|
|||
@Serializable data object PresetManager : SettingsRoute
|
||||
@Serializable data object ApiKeys : SettingsRoute
|
||||
|
||||
fun NavGraphBuilder.settingsGraph(
|
||||
fun EntryProviderScope<NavKey>.settingsEntries(
|
||||
onNavigate: (NavKey) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToArchived: () -> Unit = {},
|
||||
onNavigateToSharedLinks: () -> Unit = {},
|
||||
onNavigateBackFromSharedLinks: () -> Unit = {},
|
||||
onNavigateToPresets: () -> Unit = {},
|
||||
onNavigateBackFromPresets: () -> Unit = {},
|
||||
onNavigateToApiKeys: () -> Unit = {},
|
||||
onNavigateBackFromApiKeys: () -> Unit = {},
|
||||
) {
|
||||
composable<SettingsTabbed> {
|
||||
entry<SettingsTabbed> {
|
||||
TabbedSettingsScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateBack = onBack,
|
||||
onLogout = onLogout,
|
||||
onNavigateToArchived = onNavigateToArchived,
|
||||
onNavigateToSharedLinks = onNavigateToSharedLinks,
|
||||
onNavigateToPresets = onNavigateToPresets,
|
||||
onNavigateToApiKeys = onNavigateToApiKeys,
|
||||
onNavigateToSharedLinks = { onNavigate(SharedLinks) },
|
||||
onNavigateToPresets = { onNavigate(PresetManager) },
|
||||
onNavigateToApiKeys = { onNavigate(ApiKeys) },
|
||||
)
|
||||
}
|
||||
|
||||
composable<SettingsGeneral> {
|
||||
entry<SettingsGeneral> {
|
||||
GeneralSettingsScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateBack = onBack,
|
||||
)
|
||||
}
|
||||
composable<SettingsChat> {
|
||||
entry<SettingsChat> {
|
||||
ChatSettingsScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToPresets = onNavigateToPresets,
|
||||
onNavigateBack = onBack,
|
||||
onNavigateToPresets = { onNavigate(PresetManager) },
|
||||
)
|
||||
}
|
||||
composable<SettingsAccount> {
|
||||
entry<SettingsAccount> {
|
||||
AccountSettingsScreen(
|
||||
onLogout = onLogout,
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToApiKeys = onNavigateToApiKeys,
|
||||
onNavigateBack = onBack,
|
||||
onNavigateToApiKeys = { onNavigate(ApiKeys) },
|
||||
)
|
||||
}
|
||||
composable<SettingsData> {
|
||||
entry<SettingsData> {
|
||||
DataSettingsScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateBack = onBack,
|
||||
onNavigateToArchived = onNavigateToArchived,
|
||||
onNavigateToSharedLinks = onNavigateToSharedLinks,
|
||||
onNavigateToSharedLinks = { onNavigate(SharedLinks) },
|
||||
)
|
||||
}
|
||||
composable<SharedLinks> {
|
||||
entry<SharedLinks> {
|
||||
val viewModel: SettingsViewModel = koinViewModel()
|
||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
|
|
@ -93,23 +87,23 @@ fun NavGraphBuilder.settingsGraph(
|
|||
onLoadMore = viewModel::loadMoreSharedLinks,
|
||||
onToggleVisibility = viewModel::toggleSharedLinkVisibility,
|
||||
onDelete = viewModel::deleteSharedLink,
|
||||
onNavigateBack = onNavigateBackFromSharedLinks,
|
||||
onNavigateBack = onBack,
|
||||
)
|
||||
}
|
||||
composable<PresetManager> {
|
||||
entry<PresetManager> {
|
||||
PresetManagerScreen(
|
||||
onNavigateBack = onNavigateBackFromPresets,
|
||||
onNavigateBack = onBack,
|
||||
)
|
||||
}
|
||||
composable<ApiKeys> {
|
||||
entry<ApiKeys> {
|
||||
ApiKeysScreen(
|
||||
onNavigateBack = onNavigateBackFromApiKeys,
|
||||
onNavigateBack = onBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val settingsSerializersModule = SerializersModule {
|
||||
polymorphic(SettingsRoute::class) {
|
||||
polymorphic(NavKey::class) {
|
||||
subclass(SettingsTabbed::class, SettingsTabbed.serializer())
|
||||
subclass(SettingsGeneral::class, SettingsGeneral.serializer())
|
||||
subclass(SettingsChat::class, SettingsChat.serializer())
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ compose-bom = "2025.05.00"
|
|||
compose-multiplatform = "1.11.0-beta01"
|
||||
material3 = "1.3.1"
|
||||
activity-compose = "1.10.1"
|
||||
navigation-compose = "2.9.2"
|
||||
lifecycle = "2.9.0"
|
||||
lifecycle-kmp = "2.9.0"
|
||||
navigation-kmp = "2.9.2"
|
||||
navigation3-kmp = "1.0.0-alpha06"
|
||||
lifecycle-viewmodel-nav3-kmp = "2.10.0-alpha05"
|
||||
|
||||
# DI
|
||||
koin = "4.2.0"
|
||||
|
|
@ -82,14 +82,13 @@ compose-foundation = { group = "androidx.compose.foundation", name = "foundation
|
|||
|
||||
# Activity / Navigation
|
||||
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
|
||||
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }
|
||||
|
||||
# Lifecycle
|
||||
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
lifecycle-runtime-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-kmp" }
|
||||
lifecycle-viewmodel-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-kmp" }
|
||||
navigation-compose-kmp = { group = "org.jetbrains.androidx.navigation", name = "navigation-compose", version.ref = "navigation-kmp" }
|
||||
navigation3-ui-kmp = { group = "org.jetbrains.androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3-kmp" }
|
||||
lifecycle-viewmodel-navigation3-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycle-viewmodel-nav3-kmp" }
|
||||
|
||||
# Koin
|
||||
koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" }
|
||||
|
|
|
|||
|
|
@ -42,9 +42,10 @@ kotlin {
|
|||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(libs.kermit)
|
||||
implementation(libs.navigation.compose.kmp)
|
||||
implementation(libs.navigation3.ui.kmp)
|
||||
implementation(libs.lifecycle.runtime.compose.kmp)
|
||||
implementation(libs.lifecycle.viewmodel.compose.kmp)
|
||||
implementation(libs.lifecycle.viewmodel.navigation3.kmp)
|
||||
implementation(libs.koin.compose)
|
||||
implementation(libs.koin.compose.viewmodel)
|
||||
implementation(libs.koin.compose.viewmodel.navigation)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.garfiec.librechat.shared.navigation
|
||||
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
|
|
@ -8,7 +7,9 @@ import androidx.compose.animation.fadeIn
|
|||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -26,43 +27,36 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.toRoute
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import kotlin.reflect.KClass
|
||||
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.core.ui.components.BannerDisplay
|
||||
import com.garfiec.librechat.feature.agents.navigation.AgentDetail
|
||||
import com.garfiec.librechat.feature.agents.navigation.AgentEditorCreate
|
||||
import com.garfiec.librechat.feature.agents.navigation.AgentEditorEdit
|
||||
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
|
||||
import com.garfiec.librechat.feature.agents.navigation.agentsGraph
|
||||
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.authGraph
|
||||
import com.garfiec.librechat.feature.auth.navigation.authEntries
|
||||
import com.garfiec.librechat.feature.chat.navigation.Chat
|
||||
import com.garfiec.librechat.feature.chat.navigation.ChatRoute
|
||||
import com.garfiec.librechat.feature.chat.navigation.NewChat
|
||||
import com.garfiec.librechat.feature.chat.navigation.chatGraph
|
||||
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
|
||||
import com.garfiec.librechat.feature.chat.navigation.chatEntries
|
||||
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
|
||||
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
|
||||
import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries
|
||||
import com.garfiec.librechat.feature.files.navigation.Files
|
||||
import com.garfiec.librechat.feature.files.navigation.filesGraph
|
||||
import com.garfiec.librechat.feature.settings.navigation.ApiKeys
|
||||
import com.garfiec.librechat.feature.settings.navigation.PresetManager
|
||||
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.SharedLinks
|
||||
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
|
||||
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 librechat_mobile.shared.generated.resources.Res
|
||||
import librechat_mobile.shared.generated.resources.dismiss
|
||||
|
|
@ -72,12 +66,6 @@ import librechat_mobile.shared.generated.resources.version_mismatch_title
|
|||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
/** Checks if this destination's route matches the given typed route class. */
|
||||
private fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
|
||||
val qualifiedName = routeClass.qualifiedName ?: return false
|
||||
return this?.route?.startsWith(qualifiedName) == true
|
||||
}
|
||||
|
||||
/** Maps a [SettingsCategory] to its corresponding typed navigation route. */
|
||||
fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
|
||||
SettingsCategory.GENERAL -> SettingsGeneral
|
||||
|
|
@ -99,41 +87,35 @@ fun LibreChatNavHost(
|
|||
modifier: Modifier = Modifier,
|
||||
navHostViewModel: NavHostViewModel = koinViewModel(),
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
|
||||
|
||||
val isInAuthFlow = currentDestination.isRoute(AuthRoute::class) ||
|
||||
currentDestination?.parent.isRoute(AuthRoute::class)
|
||||
// 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(navBackStackEntry) {
|
||||
val conversationId = if (navBackStackEntry?.destination.isRoute(Chat::class)) {
|
||||
navBackStackEntry?.toRoute<Chat>()?.conversationId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
LaunchedEffect(navigator.currentRoute) {
|
||||
val conversationId = (navigator.currentRoute as? Chat)?.conversationId
|
||||
navHostViewModel.setActiveConversation(conversationId)
|
||||
}
|
||||
|
||||
// Handle session expiry
|
||||
LaunchedEffect(Unit) {
|
||||
navHostViewModel.sessionExpired.collect {
|
||||
navController.navigate(ServerUrl) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
navigator.navigateToAuth()
|
||||
}
|
||||
}
|
||||
|
||||
val startDestination: Any = if (isLoggedIn) ChatRoute::class else AuthRoute::class
|
||||
|
||||
PhoneLayout(
|
||||
navController = navController,
|
||||
navigator = navigator,
|
||||
navHostViewModel = navHostViewModel,
|
||||
startDestination = startDestination,
|
||||
isInAuthFlow = isInAuthFlow,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
|
|
@ -185,10 +167,8 @@ private fun VersionMismatchDialog(
|
|||
|
||||
@Composable
|
||||
private fun PhoneLayout(
|
||||
navController: androidx.navigation.NavHostController,
|
||||
navigator: Navigator,
|
||||
navHostViewModel: NavHostViewModel,
|
||||
startDestination: Any,
|
||||
isInAuthFlow: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
|
|
@ -205,54 +185,42 @@ private fun PhoneLayout(
|
|||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = !isInAuthFlow,
|
||||
gesturesEnabled = !navigator.isInAuthFlow,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
SidebarScaffold(
|
||||
viewModel = navHostViewModel,
|
||||
onNewChat = {
|
||||
scope.launch { drawerState.close() }
|
||||
val currentRoute = navController.currentBackStackEntry?.destination
|
||||
if (!currentRoute.isRoute(NewChat::class)) {
|
||||
navController.navigate(NewChat) {
|
||||
popUpTo<ChatRoute> { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
if (navigator.currentRoute !is NewChat) {
|
||||
navigator.navigateToTopLevel(NewChat)
|
||||
}
|
||||
},
|
||||
onConversationClick = { conversationId ->
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigateToChat(conversationId)
|
||||
navigator.navigateToChat(conversationId)
|
||||
},
|
||||
onSettingsClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigate(SettingsTabbed) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(SettingsTabbed)
|
||||
},
|
||||
onSettingsCategorySelected = { category ->
|
||||
navController.navigate(category.toRoute()) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(category.toRoute())
|
||||
},
|
||||
onAgentsClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigate(AgentMarketplace) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(AgentMarketplace)
|
||||
},
|
||||
onFilesClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigate(Files) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navigator.navigate(Files)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
if (!isInAuthFlow && banners.isNotEmpty()) {
|
||||
if (!navigator.isInAuthFlow && banners.isNotEmpty()) {
|
||||
BannerDisplay(
|
||||
banners = banners,
|
||||
dismissedIds = dismissedBannerIds,
|
||||
|
|
@ -260,124 +228,88 @@ private fun PhoneLayout(
|
|||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
||||
animationSpec = tween(300),
|
||||
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() },
|
||||
)
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(150))
|
||||
},
|
||||
popEnterTransition = {
|
||||
fadeIn(
|
||||
initialAlpha = 0.5f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
) + scaleIn(
|
||||
initialScale = 0.92f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOut(
|
||||
targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) },
|
||||
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
||||
) + scaleOut(
|
||||
targetScale = 0.92f,
|
||||
animationSpec = tween(300, easing = LinearEasing),
|
||||
)
|
||||
},
|
||||
) {
|
||||
authGraph(
|
||||
navController = navController,
|
||||
onAuthComplete = {
|
||||
navHostViewModel.onAuthComplete()
|
||||
navController.navigate(NewChat) {
|
||||
popUpTo<AuthRoute> { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
chatGraph(
|
||||
navController = navController,
|
||||
onOpenDrawer = {
|
||||
scope.launch { drawerState.open() }
|
||||
},
|
||||
)
|
||||
conversationsGraph(
|
||||
onConversationClick = { conversationId ->
|
||||
navController.navigateToChat(conversationId)
|
||||
},
|
||||
onNavigateToArchived = {
|
||||
navController.navigate(ArchivedConversations)
|
||||
},
|
||||
onNavigateBackFromArchived = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
agentsGraph(
|
||||
onAgentClick = { agentId ->
|
||||
navController.navigate(AgentDetail(agentId = agentId))
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
onStartChat = { agentId ->
|
||||
navController.navigate(NewChat) {
|
||||
popUpTo<ChatRoute> { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onCreateAgent = {
|
||||
navController.navigate(AgentEditorCreate)
|
||||
},
|
||||
onEditAgent = { agentId ->
|
||||
navController.navigate(AgentEditorEdit(agentId = agentId))
|
||||
},
|
||||
)
|
||||
filesGraph(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
settingsGraph(
|
||||
onLogout = {
|
||||
navHostViewModel.logout()
|
||||
navController.navigate(ServerUrl) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToArchived = {
|
||||
navController.navigate(ArchivedConversations) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateToSharedLinks = {
|
||||
navController.navigate(SharedLinks) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateBackFromSharedLinks = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToPresets = {
|
||||
navController.navigate(PresetManager) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateBackFromPresets = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToApiKeys = {
|
||||
navController.navigate(ApiKeys) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateBackFromApiKeys = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.garfiec.librechat.shared.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
|
||||
|
|
@ -12,7 +13,7 @@ import kotlinx.serialization.modules.plus
|
|||
/**
|
||||
* Combined [SerializersModule] for all navigation route types.
|
||||
* Registers polymorphic serializers for each feature module's sealed route hierarchy.
|
||||
* This will be used by Nav 3's SavedStateConfiguration for type-safe state saving.
|
||||
* Used by Nav 3's SavedStateConfiguration for type-safe state saving.
|
||||
*/
|
||||
val navigationSerializersModule: SerializersModule =
|
||||
authSerializersModule +
|
||||
|
|
@ -21,3 +22,11 @@ val navigationSerializersModule: SerializersModule =
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
package com.garfiec.librechat.shared.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<NavKey>) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
package com.garfiec.librechat.shared.navigation
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Forum
|
||||
import androidx.compose.material.icons.filled.SmartToy
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/**
|
||||
* Destinations accessible from the navigation drawer footer.
|
||||
* The primary navigation is sidebar-first (drawer with conversation list),
|
||||
* matching the web frontend pattern.
|
||||
*/
|
||||
enum class TopLevelDestination(
|
||||
val route: String,
|
||||
val icon: ImageVector,
|
||||
val label: String,
|
||||
) {
|
||||
CHAT("chat_graph", Icons.AutoMirrored.Filled.Chat, "Chat"),
|
||||
CONVERSATIONS("conversations", Icons.Default.Forum, "Conversations"),
|
||||
AGENTS("agents", Icons.Default.SmartToy, "Agents"),
|
||||
FILES("files", Icons.Default.Folder, "Files"),
|
||||
}
|
||||
Loading…
Reference in a new issue