Librechat-Mobile/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt

376 lines
15 KiB
Kotlin

package com.garfiec.librechat.navigation
import android.net.Uri
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import com.garfiec.librechat.MainActivity
import com.garfiec.librechat.R
import com.garfiec.librechat.core.ui.components.BannerDisplay
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.agents.navigation.agentsEntries
import com.garfiec.librechat.feature.auth.navigation.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
import com.garfiec.librechat.feature.auth.navigation.authEntries
import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatEntries
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries
import com.garfiec.librechat.feature.files.navigation.Files
import com.garfiec.librechat.feature.files.navigation.filesEntries
import com.garfiec.librechat.feature.settings.navigation.SettingsAccount
import com.garfiec.librechat.feature.settings.navigation.SettingsChat
import com.garfiec.librechat.feature.settings.navigation.SettingsData
import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral
import com.garfiec.librechat.feature.settings.navigation.SettingsRoute
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import com.garfiec.librechat.feature.settings.navigation.memoriesEntry
import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry
import com.garfiec.librechat.feature.settings.navigation.settingsEntries
import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
import co.touchlab.kermit.Logger
/** Maps a [SettingsCategory] to its corresponding typed navigation route. */
fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
SettingsCategory.GENERAL -> SettingsGeneral
SettingsCategory.CHAT -> SettingsChat
SettingsCategory.ACCOUNT -> SettingsAccount
SettingsCategory.DATA -> SettingsData
}
@Composable
fun LibreChatNavHost(
modifier: Modifier = Modifier,
windowSizeClass: WindowSizeClass? = null,
deepLinkUri: Uri? = null,
onDeepLinkConsumed: () -> Unit = {},
shareNavigationTrigger: Int = 0,
navHostViewModel: NavHostViewModel = koinViewModel(),
) {
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
// Stable start key — auth redirect handled via LaunchedEffect below.
val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat)
val navigator = Navigator(backStack)
// Redirect to auth if not logged in (on first composition only).
LaunchedEffect(Unit) {
if (!navHostViewModel.isLoggedIn.value) {
navigator.navigateToAuth()
}
}
// Track active conversation from nav back stack
LaunchedEffect(navigator.currentRoute) {
val conversationId = (navigator.currentRoute as? Chat)?.conversationId
navHostViewModel.setActiveConversation(conversationId)
}
// Handle session expiry
LaunchedEffect(Unit) {
navHostViewModel.sessionExpired.collect {
navigator.navigateToAuth()
}
}
val isTablet = windowSizeClass?.widthSizeClass?.let {
it >= WindowWidthSizeClass.Medium
} ?: false
if (isTablet) {
TabletLayout(
navigator = navigator,
navHostViewModel = navHostViewModel,
deepLinkUri = deepLinkUri,
onDeepLinkConsumed = onDeepLinkConsumed,
shareNavigationTrigger = shareNavigationTrigger,
modifier = modifier,
)
} else {
PhoneLayout(
navigator = navigator,
navHostViewModel = navHostViewModel,
deepLinkUri = deepLinkUri,
onDeepLinkConsumed = onDeepLinkConsumed,
shareNavigationTrigger = shareNavigationTrigger,
modifier = modifier,
)
}
// Version mismatch warning dialog (shown over any layout)
val versionMismatch by navHostViewModel.versionMismatch.collectAsStateWithLifecycle()
versionMismatch?.let { mismatch ->
VersionMismatchDialog(
supportedVersion = mismatch.supportedVersion,
backendVersion = mismatch.backendVersion,
onDismiss = navHostViewModel::dismissVersionWarning,
onDismissPermanently = navHostViewModel::dismissVersionWarningPermanently,
)
}
}
/**
* Dialog shown when the backend server version does not match the version
* this app was built for. Offers "Dismiss" (session-only) and
* "Don't warn again" (persisted per backend version) options.
*/
@Composable
private fun VersionMismatchDialog(
supportedVersion: String,
backendVersion: String,
onDismiss: () -> Unit,
onDismissPermanently: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(R.string.version_mismatch_title),
style = MaterialTheme.typography.headlineSmall,
)
},
text = {
Text(
text = stringResource(R.string.version_mismatch_message, supportedVersion, backendVersion),
style = MaterialTheme.typography.bodyMedium,
)
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dismiss))
}
},
dismissButton = {
TextButton(onClick = onDismissPermanently) {
Text(stringResource(R.string.dont_warn_again))
}
},
)
}
@Composable
private fun PhoneLayout(
navigator: Navigator,
navHostViewModel: NavHostViewModel,
deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit,
shareNavigationTrigger: Int,
modifier: Modifier = Modifier,
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
// Handle deep links
LaunchedEffect(deepLinkUri) {
deepLinkUri?.let { uri ->
if (uri.scheme == "librechat" && uri.host == "conversation") {
uri.lastPathSegment?.let { conversationId ->
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
navigator.navigateToChat(conversationId)
} else {
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
}
}
}
onDeepLinkConsumed()
}
}
// Handle share intent
LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) {
val currentRoute = navigator.currentRoute
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
if (!isOnChatScreen) {
navigator.navigateToTopLevel(NewChat)
}
}
}
// Reset sidebar mode to Conversations when the drawer closes
LaunchedEffect(drawerState.isClosed) {
if (drawerState.isClosed) {
navHostViewModel.setSidebarMode(SidebarMode.Conversations)
}
}
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = !navigator.isInAuthFlow,
drawerContent = {
ModalDrawerSheet {
SidebarScaffold(
viewModel = navHostViewModel,
onNewChat = {
scope.launch { drawerState.close() }
if (navigator.currentRoute !is NewChat) {
navigator.navigateToTopLevel(NewChat)
}
},
onConversationClick = { conversationId ->
scope.launch { drawerState.close() }
navigator.navigateToChat(conversationId)
},
onSettingsClick = {
scope.launch { drawerState.close() }
navigator.navigate(SettingsTabbed)
},
onSettingsCategorySelected = { category ->
navigator.navigate(category.toRoute())
},
onAgentsClick = {
scope.launch { drawerState.close() }
navigator.navigate(AgentMarketplace)
},
onFilesClick = {
scope.launch { drawerState.close() }
navigator.navigate(Files)
},
)
}
},
) {
Column(modifier = modifier.fillMaxSize()) {
if (!navigator.isInAuthFlow && banners.isNotEmpty()) {
BannerDisplay(
banners = banners,
dismissedIds = dismissedBannerIds,
onDismiss = navHostViewModel::dismissBanner,
modifier = Modifier.padding(top = 4.dp),
)
}
MainNavDisplay(
navigator = navigator,
navHostViewModel = navHostViewModel,
onOpenDrawer = { scope.launch { drawerState.open() } },
modifier = Modifier.fillMaxSize(),
)
}
}
}
@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() },
)
},
)
}