diff --git a/src/App.tsx b/src/App.tsx index c954b901b..62874b544 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,7 @@ import { ShortcutCallbackManager } from './aiCore/tools/SystemTools/ShortcutCall import { DialogProvider } from './hooks/useDialog' import { ToastProvider } from './hooks/useToast' import MainStackNavigator from './navigators/MainStackNavigator' +import { navigationRef, resetNavigationRef } from './navigators/navigationRef' import { runAppDataMigrations } from './services/AppInitializationService' // Prevent the splash screen from auto-hiding before asset loading is complete. @@ -100,10 +101,17 @@ function ThemedApp() { Uniwind.setTheme(isDark ? 'dark' : 'light') }, [isDark]) + // Cleanup navigation ref on unmount to prevent memory leaks + useEffect(() => { + return () => { + resetNavigationRef() + } + }, []) + return ( - + diff --git a/src/componentsV2/features/ChatScreen/Header/index.tsx b/src/componentsV2/features/ChatScreen/Header/index.tsx index 6503df587..ac1f07370 100644 --- a/src/componentsV2/features/ChatScreen/Header/index.tsx +++ b/src/componentsV2/features/ChatScreen/Header/index.tsx @@ -1,12 +1,10 @@ -import type { DrawerNavigationProp } from '@react-navigation/drawer' -import type { ParamListBase } from '@react-navigation/native' -import { DrawerActions, useNavigation } from '@react-navigation/native' import React from 'react' import { IconButton } from '@/componentsV2/base/IconButton' import { Menu } from '@/componentsV2/icons/LucideIcon' import XStack from '@/componentsV2/layout/XStack' import { useAssistant } from '@/hooks/useAssistant' +import { useDrawer } from '@/hooks/useDrawer' import type { Topic } from '@/types/assistant' import { AssistantSelection } from './AssistantSelection' @@ -18,11 +16,11 @@ interface HeaderBarProps { } export const ChatScreenHeader = ({ topic }: HeaderBarProps) => { - const navigation = useNavigation>() + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const { assistant, isLoading } = useAssistant(topic.assistantId) const handleMenuPress = () => { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } if (isLoading || !assistant) { @@ -32,7 +30,7 @@ export const ChatScreenHeader = ({ topic }: HeaderBarProps) => { return ( - } /> + {!isDrawerAlwaysVisible && } />} diff --git a/src/componentsV2/features/HeaderBar/index.tsx b/src/componentsV2/features/HeaderBar/index.tsx index 6f3dfa3ea..b7c424c26 100644 --- a/src/componentsV2/features/HeaderBar/index.tsx +++ b/src/componentsV2/features/HeaderBar/index.tsx @@ -1,12 +1,31 @@ -import { useNavigation } from '@react-navigation/native' +import { useNavigation, useNavigationState } from '@react-navigation/native' import React from 'react' import { Pressable } from 'react-native' import Text from '@/componentsV2/base/Text' import XStack from '@/componentsV2/layout/XStack' +import { useResponsive } from '@/hooks/useResponsive' import { ArrowLeft } from '../../icons/LucideIcon' +// 这几个界面不显示返回按钮 +const SETTINGS_MAIN_SCREENS = new Set([ + 'ProviderListScreen', + 'AssistantSettingsScreen', + 'WebSearchSettingsScreen', + 'GeneralSettingsScreen', + 'DataSettingsScreen', + 'AboutScreen' +]) + +function getCurrentRouteName(state: any): string | undefined { + let route = state.routes[state.index] as any + while (route.state?.routes) { + route = route.state.routes[route.state.index ?? 0] as any + } + return route?.name as string | undefined +} + export interface HeaderBarButton { icon: React.ReactNode onPress: () => void @@ -31,6 +50,11 @@ export const HeaderBar: React.FC = ({ }) => { const buttonsToRender = rightButtons || (rightButton ? [rightButton] : []) const navigation = useNavigation() + const { isTablet, isLandscape } = useResponsive() + const currentRoute = useNavigationState(getCurrentRouteName) + + const isSettingsMainScreen = SETTINGS_MAIN_SCREENS.has(currentRoute ?? '') + const shouldShowBackButton = showBackButton && !(isTablet && isLandscape && isSettingsMainScreen) const handleBack = () => { if (onBackPress) return onBackPress() @@ -48,7 +72,7 @@ export const HeaderBar: React.FC = ({ style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}> {leftButton.icon} - ) : showBackButton ? ( + ) : shouldShowBackButton ? ( ({ opacity: pressed ? 0.7 : 1 })}> diff --git a/src/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown.tsx b/src/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown.tsx new file mode 100644 index 000000000..6a2a6bb84 --- /dev/null +++ b/src/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Pressable } from 'react-native' + +import SelectionDropdown, { type SelectionDropdownItem } from '@/componentsV2/base/SelectionDropdown' +import Text from '@/componentsV2/base/Text' +import { ChevronsUpDown, PanelLeft, PanelRight } from '@/componentsV2/icons' +import { usePreference } from '@/hooks/usePreference' +import type { TabletSidebarPosition } from '@/shared/data/preference/preferenceTypes' + +const positionOptions: { value: TabletSidebarPosition; labelKey: string; icon: React.ReactNode }[] = [ + { value: 'left', labelKey: 'settings.general.tablet_sidebar.left', icon: }, + { value: 'right', labelKey: 'settings.general.tablet_sidebar.right', icon: } +] + +export function TabletSidebarPositionDropdown() { + const { t } = useTranslation() + const [currentPosition, setCurrentPosition] = usePreference('ui.tablet_sidebar_position') + + const handlePositionChange = (position: TabletSidebarPosition) => { + setCurrentPosition(position) + } + + const positionDropdownOptions: SelectionDropdownItem[] = positionOptions.map(opt => ({ + id: opt.value, + label: t(opt.labelKey), + icon: opt.icon, + isSelected: currentPosition === opt.value, + onSelect: () => handlePositionChange(opt.value) + })) + + const getCurrentPositionLabel = () => { + const current = positionOptions.find(item => item.value === currentPosition) + return current ? t(current.labelKey) : t('settings.general.tablet_sidebar.left') + } + + return ( + + + + {getCurrentPositionLabel()} + + + + + ) +} diff --git a/src/componentsV2/icons/LucideIcon/index.tsx b/src/componentsV2/icons/LucideIcon/index.tsx index e9c51a495..f48e055c7 100644 --- a/src/componentsV2/icons/LucideIcon/index.tsx +++ b/src/componentsV2/icons/LucideIcon/index.tsx @@ -56,6 +56,8 @@ import { MoreHorizontal, Package, Palette, + PanelLeft, + PanelRight, PenLine, Plus, Radio, @@ -159,6 +161,8 @@ const MicIcon = createIcon(Mic) const MinusIcon = createIcon(Minus) const MoreHorizontalIcon = createIcon(MoreHorizontal) const PackageIcon = createIcon(Package) +const PanelLeftIcon = createIcon(PanelLeft) +const PanelRightIcon = createIcon(PanelRight) const PenLineIcon = createIcon(PenLine) const PlusIcon = createIcon(Plus) const RadioIcon = createIcon(Radio) @@ -244,6 +248,8 @@ export { MoreHorizontalIcon as MoreHorizontal, PackageIcon as Package, PaletteIcon as Palette, + PanelLeftIcon as PanelLeft, + PanelRightIcon as PanelRight, PenLineIcon as PenLine, PlusIcon as Plus, RadioIcon as Radio, diff --git a/src/componentsV2/layout/DrawerGestureWrapper/index.tsx b/src/componentsV2/layout/DrawerGestureWrapper/index.tsx index 69ef4509b..4f685a21b 100644 --- a/src/componentsV2/layout/DrawerGestureWrapper/index.tsx +++ b/src/componentsV2/layout/DrawerGestureWrapper/index.tsx @@ -1,9 +1,9 @@ -import type { DrawerNavigationProp } from '@react-navigation/drawer' -import { DrawerActions, useNavigation } from '@react-navigation/native' import type { PropsWithChildren } from 'react' import React from 'react' import { PanGestureHandler, State } from 'react-native-gesture-handler' +import { useDrawer } from '@/hooks/useDrawer' + interface DrawerGestureWrapperProps extends PropsWithChildren { enabled?: boolean } @@ -11,12 +11,13 @@ interface DrawerGestureWrapperProps extends PropsWithChildren { /** * Common wrapper component for handling drawer opening gesture * Swipe right from anywhere on the screen to open the drawer + * In tablet landscape mode, the drawer is always visible so gestures are ignored */ export const DrawerGestureWrapper = ({ children, enabled = true }: DrawerGestureWrapperProps) => { - const navigation = useNavigation>() + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const handleSwipeGesture = (event: any) => { - if (!enabled) return + if (!enabled || isDrawerAlwaysVisible) return const { translationX, velocityX, state } = event.nativeEvent @@ -28,12 +29,12 @@ export const DrawerGestureWrapper = ({ children, enabled = true }: DrawerGesture const hasExcellentDistance = translationX > 80 if ((hasGoodDistance && hasGoodVelocity) || hasExcellentDistance) { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } } } - if (!enabled) { + if (!enabled || isDrawerAlwaysVisible) { return <>{children} } diff --git a/src/componentsV2/layout/TabletSidebar/DefaultSidebar.tsx b/src/componentsV2/layout/TabletSidebar/DefaultSidebar.tsx new file mode 100644 index 000000000..1dd19b019 --- /dev/null +++ b/src/componentsV2/layout/TabletSidebar/DefaultSidebar.tsx @@ -0,0 +1,127 @@ +import { Divider } from 'heroui-native' +import React from 'react' +import { useTranslation } from 'react-i18next' + +import { IconButton } from '@/componentsV2/base/IconButton' +import Image from '@/componentsV2/base/Image' +import Text from '@/componentsV2/base/Text' +import { AssistantList } from '@/componentsV2/features/Menu/AssistantList' +import { MenuTabContent } from '@/componentsV2/features/Menu/MenuTabContent' +import { MarketIcon, MCPIcon, Settings } from '@/componentsV2/icons' +import PressableRow from '@/componentsV2/layout/PressableRow' +import RowRightArrow from '@/componentsV2/layout/Row/RowRightArrow' +import XStack from '@/componentsV2/layout/XStack' +import YStack from '@/componentsV2/layout/YStack' +import { useAssistants } from '@/hooks/useAssistant' +import { useSettings } from '@/hooks/useSettings' +import { useCurrentTopic } from '@/hooks/useTopic' +import { navigationRef } from '@/navigators/navigationRef' +import { loggerService } from '@/services/LoggerService' +import { topicService } from '@/services/TopicService' +import type { Assistant } from '@/types/assistant' + +const logger = loggerService.withContext('DefaultSidebar') + +interface DefaultSidebarProps { + onNavigateSettings: () => void +} + +export function DefaultSidebar({ onNavigateSettings }: DefaultSidebarProps) { + const { t } = useTranslation() + const { avatar, userName } = useSettings() + const { switchTopic } = useCurrentTopic() + const { assistants, isLoading: isAssistantsLoading } = useAssistants() + + const handleNavigateAssistantScreen = () => { + navigationRef.current?.navigate('Assistant', { screen: 'AssistantScreen' }) + } + + const handleNavigateAssistantMarketScreen = () => { + navigationRef.current?.navigate('AssistantMarket', { screen: 'AssistantMarketScreen' }) + } + + const handleNavigateMcpScreen = () => { + navigationRef.current?.navigate('Mcp', { screen: 'McpScreen' }) + } + + const handleNavigatePersonalScreen = () => { + navigationRef.current?.navigate('Home', { screen: 'AboutSettings', params: { screen: 'PersonalScreen' } }) + } + + const handleNavigateChatScreen = (topicId: string) => { + navigationRef.current?.navigate('Home', { screen: 'ChatScreen', params: { topicId: topicId } }) + } + + const handleAssistantItemPress = async (assistant: Assistant) => { + try { + const assistantTopics = await topicService.getTopicsByAssistantId(assistant.id) + const latestTopic = assistantTopics[0] + + if (latestTopic) { + await switchTopic(latestTopic.id) + handleNavigateChatScreen(latestTopic.id) + return + } + + const newTopic = await topicService.createTopic(assistant) + await switchTopic(newTopic.id) + handleNavigateChatScreen(newTopic.id) + } catch (error) { + logger.error('Failed to open assistant topic from sidebar', error as Error) + } + } + + return ( + <> + + + + + + {t('assistants.market.title')} + + + + + + + + {t('mcp.server.title')} + + + + + + + + + + + + + + + + + + + + + {userName || t('common.cherry_studio')} + + } onPress={onNavigateSettings} style={{ paddingRight: 16 }} /> + + + ) +} diff --git a/src/componentsV2/layout/TabletSidebar/SettingsSidebar.tsx b/src/componentsV2/layout/TabletSidebar/SettingsSidebar.tsx new file mode 100644 index 000000000..ba0239a92 --- /dev/null +++ b/src/componentsV2/layout/TabletSidebar/SettingsSidebar.tsx @@ -0,0 +1,133 @@ +import { useNavigationState } from '@react-navigation/native' +import { Divider } from 'heroui-native' +import React from 'react' +import { useTranslation } from 'react-i18next' + +import Image from '@/componentsV2/base/Image' +import Text from '@/componentsV2/base/Text' +import { Cloud, Globe, HardDrive, Info, Package, Settings2 } from '@/componentsV2/icons/LucideIcon' +import PressableRow from '@/componentsV2/layout/PressableRow' +import XStack from '@/componentsV2/layout/XStack' +import YStack from '@/componentsV2/layout/YStack' +import { navigationRef } from '@/navigators/navigationRef' + +export function SettingsSidebar() { + const { t } = useTranslation() + + // 获取当前路由名称 + const currentRoute = useNavigationState(state => { + let route = state.routes[state.index] as any + while (route.state?.routes) { + route = route.state.routes[route.state.index ?? 0] as any + } + return route?.name as string | undefined + }) + + const settingsItems = [ + { + title: t('settings.modelAndService'), + items: [ + { + title: t('settings.provider.title'), + screen: 'ProvidersSettings', + specificScreen: 'ProviderListScreen', + icon: + }, + { + title: t('settings.assistant.title'), + screen: 'AssistantSettings', + specificScreen: 'AssistantSettingsScreen', + icon: + }, + { + title: t('settings.websearch.title'), + screen: 'WebSearchSettings', + specificScreen: 'WebSearchSettingsScreen', + icon: + } + ] + }, + { + title: t('settings.title'), + items: [ + { + title: t('settings.general.title'), + screen: 'GeneralSettings', + specificScreen: 'GeneralSettingsScreen', + icon: + }, + { + title: t('settings.data.title'), + screen: 'DataSourcesSettings', + specificScreen: 'DataSettingsScreen', + icon: + } + ] + }, + { + title: t('settings.dataAndSecurity'), + items: [ + { + title: t('settings.about.title'), + screen: 'AboutSettings', + specificScreen: 'AboutScreen', + icon: + } + ] + } + ] + + const handlePress = (screen: string, specificScreen?: string) => { + // 使用全局导航引用,导航路径: Home -> 设置Stack -> 具体设置页面 + if (specificScreen) { + navigationRef.current?.navigate('Home', { + screen: screen as any, + params: { screen: specificScreen } + }) + } else { + navigationRef.current?.navigate('Home', { screen: screen as any }) + } + } + + const isItemActive = (screen: string, specificScreen?: string) => { + if (specificScreen && currentRoute === specificScreen) return true + if (currentRoute === screen) return true + return false + } + + return ( + + {settingsItems.map((group, idx) => ( + + {group.title && {group.title}} + + {group.items.map((item, itemIdx) => { + const active = isItemActive(item.screen, item.specificScreen) + return ( + handlePress(item.screen, item.specificScreen)}> + + + {typeof item.icon === 'string' ? ( + + ) : ( + item.icon + )} + {item.title} + + + + ) + })} + + + + ))} + + ) +} diff --git a/src/componentsV2/layout/TabletSidebar/index.tsx b/src/componentsV2/layout/TabletSidebar/index.tsx new file mode 100644 index 000000000..e50a76eef --- /dev/null +++ b/src/componentsV2/layout/TabletSidebar/index.tsx @@ -0,0 +1,160 @@ +import { CommonActions, useNavigationState } from '@react-navigation/native' +import { Divider } from 'heroui-native' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet, View } from 'react-native' + +import { IconButton } from '@/componentsV2/base/IconButton' +import Image from '@/componentsV2/base/Image' +import Text from '@/componentsV2/base/Text' +import { Settings } from '@/componentsV2/icons' +import PressableRow from '@/componentsV2/layout/PressableRow' +import XStack from '@/componentsV2/layout/XStack' +import YStack from '@/componentsV2/layout/YStack' +import { usePreference } from '@/hooks/usePreference' +import { useSafeArea } from '@/hooks/useSafeArea' +import { useSettings } from '@/hooks/useSettings' +import { useTheme } from '@/hooks/useTheme' +import { navigationRef } from '@/navigators/navigationRef' + +import { DefaultSidebar } from './DefaultSidebar' +import { SettingsSidebar } from './SettingsSidebar' + +const SIDEBAR_WIDTH = 280 + +// 设置路由集合(包含所有设置相关的路由和子路由) +export const SETTINGS_ROUTES = new Set([ + // 主设置路由 + 'SettingsScreen', + 'GeneralSettings', + 'AssistantSettings', + 'ProvidersSettings', + 'DataSourcesSettings', + 'WebSearchSettings', + 'AboutSettings', + 'StreamableHttpTest', + // ProvidersSettings 子路由 + 'ProviderListScreen', + 'ProviderSettingsScreen', + 'ManageModelsScreen', + 'ApiServiceScreen', + 'AddProviderScreen', + // AboutSettings 子路由 + 'PersonalScreen', + 'AboutScreen', + // AssistantSettings 子路由 + 'AssistantSettingsScreen', + // DataSourcesSettings 子路由 + 'DataSettingsScreen', + 'BasicDataSettingsScreen', + 'LanTransferScreen', + // GeneralSettings 子路由 + 'GeneralSettingsScreen', + // WebSearchSettings 子路由 + 'WebSearchSettingsScreen', + 'WebSearchProviderSettingsScreen' +]) + +// 获取当前路由名称的工具函数 +export function getCurrentRoute(state: any): string | undefined { + let route = state.routes[state.index] as any + while (route.state?.routes) { + route = route.state.routes[route.state.index ?? 0] as any + } + return route?.name as string | undefined +} + +export function TabletSidebar() { + const { t } = useTranslation() + const { isDark } = useTheme() + const { avatar, userName } = useSettings() + const insets = useSafeArea() + const [sidebarPosition] = usePreference('ui.tablet_sidebar_position') + const isRightSide = sidebarPosition === 'right' + + const currentRoute = useNavigationState(getCurrentRoute) + const isInSettings = SETTINGS_ROUTES.has(currentRoute ?? '') + const hasNavigatedToProvider = React.useRef(false) + + React.useEffect(() => { + if (currentRoute === 'SettingsScreen' && !hasNavigatedToProvider.current) { + hasNavigatedToProvider.current = true + navigationRef.current?.navigate('Home', { + screen: 'ProvidersSettings', + params: { screen: 'ProviderListScreen' } + }) + } + }, [currentRoute]) + + const handleNavigateSettingsScreen = () => { + if (isInSettings) { + navigationRef.current?.dispatch(CommonActions.reset({ index: 0, routes: [{ name: 'Home' }] })) + } else { + navigationRef.current?.navigate('Home', { + screen: 'ProvidersSettings', + params: { screen: 'ProviderListScreen' } + }) + } + } + + const handleNavigatePersonalScreen = () => { + navigationRef.current?.navigate('Home', { screen: 'AboutSettings', params: { screen: 'PersonalScreen' } }) + } + + const sidebarStyle = [ + styles.sidebar, + isRightSide ? styles.sidebarRight : styles.sidebarLeft, + { + paddingTop: insets.top, + paddingBottom: insets.bottom, + backgroundColor: isDark ? '#121213' : '#f7f7f7' + } + ] + + // 如果当前在设置路由中,渲染设置侧边栏 + if (isInSettings) { + return ( + + + + + + + + + {userName || t('common.cherry_studio')} + + } + onPress={handleNavigateSettingsScreen} + style={{ paddingRight: 16 }} + /> + + + ) + } + + return ( + + + + ) +} + +const styles = StyleSheet.create({ + sidebar: { + width: SIDEBAR_WIDTH, + height: '100%' + }, + sidebarLeft: { + borderRightWidth: StyleSheet.hairlineWidth, + borderRightColor: 'rgba(0,0,0,0.1)' + }, + sidebarRight: { + borderLeftWidth: StyleSheet.hairlineWidth, + borderLeftColor: 'rgba(0,0,0,0.1)' + } +}) diff --git a/src/hooks/useDrawer.ts b/src/hooks/useDrawer.ts new file mode 100644 index 000000000..ddb48f0a7 --- /dev/null +++ b/src/hooks/useDrawer.ts @@ -0,0 +1,46 @@ +import { DrawerActions, useNavigation } from '@react-navigation/native' +import { useCallback } from 'react' + +import { useResponsive } from './useResponsive' + +/** + * Hook for safely handling drawer operations + * In tablet landscape mode, the drawer is always visible and doesn't need open/close actions + */ +export function useDrawer() { + const navigation = useNavigation() + const { isTablet, isLandscape } = useResponsive() + const isTabletLandscape = isTablet && isLandscape + + const openDrawer = useCallback(() => { + // In tablet landscape mode, drawer is always visible, no need to open + if (isTabletLandscape) { + return + } + navigation.dispatch(DrawerActions.openDrawer()) + }, [navigation, isTabletLandscape]) + + const closeDrawer = useCallback(() => { + // In tablet landscape mode, drawer is always visible, no need to close + if (isTabletLandscape) { + return + } + navigation.dispatch(DrawerActions.closeDrawer()) + }, [navigation, isTabletLandscape]) + + const toggleDrawer = useCallback(() => { + // In tablet landscape mode, drawer is always visible, no need to toggle + if (isTabletLandscape) { + return + } + navigation.dispatch(DrawerActions.toggleDrawer()) + }, [navigation, isTabletLandscape]) + + return { + openDrawer, + closeDrawer, + toggleDrawer, + // Indicates if currently in tablet landscape mode (drawer is always visible in this mode) + isDrawerAlwaysVisible: isTabletLandscape + } +} diff --git a/src/hooks/useResponsive.ts b/src/hooks/useResponsive.ts index 8a5d2bae9..82065696a 100644 --- a/src/hooks/useResponsive.ts +++ b/src/hooks/useResponsive.ts @@ -53,6 +53,8 @@ const getOrientation = (width: number, height: number): Orientation => { */ export function useResponsive(): ResponsiveInfo { const { width, height } = useWindowDimensions() + + // Direct computation - useMemo not needed for cheap calculations const deviceType = getDeviceType(width, height) const orientation = getOrientation(width, height) diff --git a/src/i18n/locales/en-us.json b/src/i18n/locales/en-us.json index 8449829d7..be20d0633 100644 --- a/src/i18n/locales/en-us.json +++ b/src/i18n/locales/en-us.json @@ -706,6 +706,12 @@ "anonymous": "Anonymous report errors and statistics", "title": "Privacy Settings" }, + "tablet_sidebar": { + "title": "Tablet Sidebar", + "position": "Sidebar Position", + "left": "Left", + "right": "Right" + }, "theme": { "auto": "Auto", "dark": "Dark", diff --git a/src/i18n/locales/ja-jp.json b/src/i18n/locales/ja-jp.json index 0483c83a2..1324d2cf7 100644 --- a/src/i18n/locales/ja-jp.json +++ b/src/i18n/locales/ja-jp.json @@ -706,6 +706,12 @@ "anonymous": "エラーと統計を匿名で報告", "title": "プライバシー設定" }, + "tablet_sidebar": { + "title": "タブレットサイドバー", + "position": "サイドバー位置", + "left": "左", + "right": "右" + }, "theme": { "auto": "自動", "dark": "ダーク", diff --git a/src/i18n/locales/ru-ru.json b/src/i18n/locales/ru-ru.json index cf46971e1..70027ef1d 100644 --- a/src/i18n/locales/ru-ru.json +++ b/src/i18n/locales/ru-ru.json @@ -706,6 +706,12 @@ "anonymous": "Анонимно сообщать об ошибках и статистике", "title": "Настройки конфиденциальности" }, + "tablet_sidebar": { + "title": "Боковая панель планшета", + "position": "Положение боковой панели", + "left": "Слева", + "right": "Справа" + }, "theme": { "auto": "Авто", "dark": "Тёмная", diff --git a/src/i18n/locales/zh-cn.json b/src/i18n/locales/zh-cn.json index 4a3264d7b..bdff8b1f9 100644 --- a/src/i18n/locales/zh-cn.json +++ b/src/i18n/locales/zh-cn.json @@ -706,6 +706,12 @@ "anonymous": "匿名报告错误和统计", "title": "隐私设置" }, + "tablet_sidebar": { + "title": "平板导航栏", + "position": "导航栏位置", + "left": "左侧", + "right": "右侧" + }, "theme": { "auto": "自动", "dark": "深色", diff --git a/src/i18n/locales/zh-tw.json b/src/i18n/locales/zh-tw.json index e16a4486b..24332396d 100644 --- a/src/i18n/locales/zh-tw.json +++ b/src/i18n/locales/zh-tw.json @@ -706,6 +706,12 @@ "anonymous": "匿名回報錯誤和統計", "title": "隱私設定" }, + "tablet_sidebar": { + "title": "平板導航欄", + "position": "導航欄位置", + "left": "左側", + "right": "右側" + }, "theme": { "auto": "自動", "dark": "深色", diff --git a/src/navigators/AppDrawerNavigator.tsx b/src/navigators/AppDrawerNavigator.tsx index a1d07f96d..2a6b46814 100644 --- a/src/navigators/AppDrawerNavigator.tsx +++ b/src/navigators/AppDrawerNavigator.tsx @@ -4,14 +4,18 @@ import '@/i18n' import type { DrawerNavigationOptions } from '@react-navigation/drawer' import { createDrawerNavigator } from '@react-navigation/drawer' import { getFocusedRouteNameFromRoute, type RouteProp } from '@react-navigation/native' -import React from 'react' +import React, { useMemo } from 'react' +import { StyleSheet, View } from 'react-native' import CustomDrawerContent from '@/componentsV2/features/Menu/CustomDrawerContent' -import AssistantMarketStackNavigator from '@/navigators/AssistantMarketStackNavigator' -import AssistantStackNavigator from '@/navigators/AssistantStackNavigator' -import HomeStackNavigator from '@/navigators/HomeStackNavigator' +import { TabletSidebar } from '@/componentsV2/layout/TabletSidebar' +import { usePreference } from '@/hooks/usePreference' +import { useResponsive } from '@/hooks/useResponsive' import { Width } from '@/utils/device' +import AssistantMarketStackNavigator from './AssistantMarketStackNavigator' +import AssistantStackNavigator from './AssistantStackNavigator' +import HomeStackNavigator from './HomeStackNavigator' import McpStackNavigator from './McpStackNavigator' const Drawer = createDrawerNavigator() @@ -72,18 +76,106 @@ const getMcpScreenOptions = ({ } } -export default function AppDrawerNavigator() { - return ( - } screenOptions={screenOptions}> - {/* Main grouped navigators */} - - - - - - {/* Individual screens for backward compatibility */} - {/* - */} - + + +/** + * 平板横屏双栏布局导航器 + * 使用 DrawerNavigator 但隐藏抽屉UI,通过固定侧边栏控制导航 + */ +function TabletLandscapeNavigator() { + const [sidebarPosition] = usePreference('ui.tablet_sidebar_position') + const isRightSide = sidebarPosition === 'right' + + const drawerNavigator = useMemo( + () => ( + + {/* 固定侧边栏 */} + + + + + {/* 内容区域 - 使用 DrawerNavigator 但隐藏抽屉 */} + + } + screenOptions={{ + ...screenOptions, + // 隐藏抽屉UI + drawerType: 'permanent', + drawerStyle: { width: 0, opacity: 0 }, + swipeEnabled: false + }}> + + + + + + + + ), + [isRightSide] + ) + + return drawerNavigator +} + +/** + * 移动端抽屉导航器 + */ +function MobileDrawerNavigator() { + const drawerNavigator = useMemo( + () => ( + } screenOptions={screenOptions}> + + + + + + ), + [] ) + + return drawerNavigator } + +/** + * 统一的导航器组件 + */ +export default function AppDrawerNavigator() { + const { isTablet, isLandscape } = useResponsive() + const isTabletLandscape = isTablet && isLandscape + + // 平板横屏时:使用双栏布局 + if (isTabletLandscape) { + return + } + + // 移动端或平板竖屏:使用抽屉导航 + return +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'row' + }, + containerReversed: { + flexDirection: 'row-reverse' + }, + sidebarContainer: { + width: 280, + height: '100%' + }, + sidebarLeft: { + borderRightWidth: StyleSheet.hairlineWidth, + borderRightColor: 'rgba(0,0,0,0.1)' + }, + sidebarRight: { + borderLeftWidth: StyleSheet.hairlineWidth, + borderLeftColor: 'rgba(0,0,0,0.1)' + }, + content: { + flex: 1, + height: '100%' + } +}) diff --git a/src/navigators/navigationRef.ts b/src/navigators/navigationRef.ts new file mode 100644 index 000000000..bc3f7e893 --- /dev/null +++ b/src/navigators/navigationRef.ts @@ -0,0 +1,33 @@ +import type { NavigationContainerRef, ParamListBase } from '@react-navigation/native' + +// Use a mutable ref object instead of createRef to avoid potential memory leaks +// and allow proper cleanup when NavigationContainer unmounts +export const navigationRef: { current: NavigationContainerRef | null } = { + current: null +} + +/** + * Check if navigation is ready (NavigationContainer is mounted) + */ +export function isNavigationReady(): boolean { + return navigationRef.current !== null && navigationRef.current !== undefined +} + +/** + * Safely navigate to a screen + * @param name - Screen name + * @param params - Navigation params + */ +export function safeNavigate(name: string, params?: object): void { + if (isNavigationReady()) { + navigationRef.current?.navigate(name, params) + } +} + +/** + * Reset the navigation ref (call this when NavigationContainer unmounts) + * This prevents memory leaks by releasing the reference + */ +export function resetNavigationRef(): void { + navigationRef.current = null +} diff --git a/src/screens/assistant/AssistantMarketScreen.tsx b/src/screens/assistant/AssistantMarketScreen.tsx index 4b18d981c..114d3b5f3 100644 --- a/src/screens/assistant/AssistantMarketScreen.tsx +++ b/src/screens/assistant/AssistantMarketScreen.tsx @@ -1,4 +1,4 @@ -import { DrawerActions, useNavigation } from '@react-navigation/native' +import { useNavigation } from '@react-navigation/native' import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { View } from 'react-native' @@ -15,6 +15,7 @@ import { presentAssistantItemSheet } from '@/componentsV2/features/Assistant/Ass import AssistantsTabContent from '@/componentsV2/features/Assistant/AssistantsTabContent' import { Menu } from '@/componentsV2/icons' import { useBuiltInAssistants } from '@/hooks/useAssistant' +import { useDrawer } from '@/hooks/useDrawer' import { useSearch } from '@/hooks/useSearch' import { useSkeletonLoading } from '@/hooks/useSkeletonLoading' import type { Assistant } from '@/types/assistant' @@ -23,6 +24,7 @@ import type { DrawerNavigationProps } from '@/types/naviagate' export default function AssistantMarketScreen() { const { t } = useTranslation() const navigation = useNavigation() + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const { assistants: builtInAssistants } = useBuiltInAssistants() const { @@ -38,7 +40,7 @@ export default function AssistantMarketScreen() { const showSkeleton = useSkeletonLoading(isLoading) const handleMenuPress = () => { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } const onChatNavigation = async (topicId: string) => { @@ -59,10 +61,10 @@ export default function AssistantMarketScreen() { , onPress: handleMenuPress - }} + } : undefined} /> () + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const toast = useToast() const { assistants, isLoading } = useAssistants() @@ -81,7 +83,7 @@ export default function AssistantScreen() { } const handleMenuPress = () => { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } const handleEnterMultiSelectMode = useCallback((assistantId: string) => { @@ -184,10 +186,10 @@ export default function AssistantScreen() { ) : ( , onPress: handleMenuPress - }} + } : undefined} rightButtons={[ { icon: , diff --git a/src/screens/home/ChatScreen.tsx b/src/screens/home/ChatScreen.tsx index e2db234bb..1febf6c75 100644 --- a/src/screens/home/ChatScreen.tsx +++ b/src/screens/home/ChatScreen.tsx @@ -1,5 +1,4 @@ -import type { DrawerNavigationProp } from '@react-navigation/drawer' -import { DrawerActions, useNavigation } from '@react-navigation/native' +import { useNavigation } from '@react-navigation/native' import type { StackNavigationProp } from '@react-navigation/stack' import React from 'react' import { ActivityIndicator, Platform, View } from 'react-native' @@ -13,6 +12,7 @@ import { MessageInputContainer } from '@/componentsV2/features/ChatScreen/Messag import { CitationSheet } from '@/componentsV2/features/Sheet/CitationSheet' import { useAssistant } from '@/hooks/useAssistant' import { useBottom } from '@/hooks/useBottom' +import { useDrawer } from '@/hooks/useDrawer' import { usePreference } from '@/hooks/usePreference' import { useCurrentTopic } from '@/hooks/useTopic' import type { HomeStackParamList } from '@/navigators/HomeStackNavigator' @@ -21,11 +21,12 @@ import ChatContent from './ChatContent' KeyboardController.preload() -type ChatScreenNavigationProp = DrawerNavigationProp & StackNavigationProp +type ChatScreenNavigationProp = StackNavigationProp const ChatScreen = () => { const insets = useSafeAreaInsets() const navigation = useNavigation() + const { openDrawer } = useDrawer() const [topicId] = usePreference('topic.current_id') const { currentTopic } = useCurrentTopic() @@ -44,7 +45,7 @@ const ChatScreen = () => { const hasExcellentDistance = translationX > 80 if ((hasGoodDistance && hasGoodVelocity) || hasExcellentDistance) { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } } // 左滑 → 跳转到 TopicScreen diff --git a/src/screens/mcp/McpScreen.tsx b/src/screens/mcp/McpScreen.tsx index 86b4493fd..3e8c8cbfc 100644 --- a/src/screens/mcp/McpScreen.tsx +++ b/src/screens/mcp/McpScreen.tsx @@ -1,4 +1,4 @@ -import { DrawerActions, useNavigation } from '@react-navigation/native' +import { useNavigation } from '@react-navigation/native' import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { View } from 'react-native' @@ -13,18 +13,20 @@ import { } from '@/componentsV2' import { McpMarketContent } from '@/componentsV2/features/MCP/McpMarketContent' import { Menu, Plus, Store } from '@/componentsV2/icons/LucideIcon' +import { useDrawer } from '@/hooks/useDrawer' import { useMcpServers } from '@/hooks/useMcp' import { useSearch } from '@/hooks/useSearch' import { useSkeletonLoading } from '@/hooks/useSkeletonLoading' import { useToast } from '@/hooks/useToast' import { mcpService } from '@/services/McpService' import type { MCPServer } from '@/types/mcp' -import type { DrawerNavigationProps, McpNavigationProps } from '@/types/naviagate' +import type { McpNavigationProps } from '@/types/naviagate' import { uuid } from '@/utils' export default function McpScreen() { const { t } = useTranslation() - const navigation = useNavigation() + const navigation = useNavigation() + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const toast = useToast() const { mcpServers, isLoading, updateMcpServers } = useMcpServers() const { @@ -39,7 +41,7 @@ export default function McpScreen() { const showSkeleton = useSkeletonLoading(isLoading) const handleMenuPress = () => { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } const handleNavigateToMarket = () => { @@ -78,10 +80,10 @@ export default function McpScreen() { , onPress: handleMenuPress - }} + } : undefined} rightButtons={[ { icon: , diff --git a/src/screens/settings/SettingsScreen.tsx b/src/screens/settings/SettingsScreen.tsx index 0cc39556b..d8def2308 100644 --- a/src/screens/settings/SettingsScreen.tsx +++ b/src/screens/settings/SettingsScreen.tsx @@ -5,7 +5,6 @@ import { ScrollView, View } from 'react-native' import { GestureDetector } from 'react-native-gesture-handler' import { - Container, Group, GroupTitle, HeaderBar, @@ -109,25 +108,23 @@ export default function SettingsScreen() { - - - - {settingsItems.map((group, index) => ( - - {group.items.map((item, index) => ( - - ))} - - ))} - - - + + + {settingsItems.map((group, index) => ( + + {group.items.map((item, index) => ( + + ))} + + ))} + + diff --git a/src/screens/settings/about/AboutScreen.tsx b/src/screens/settings/about/AboutScreen.tsx index eab4f6dbf..f95cd4399 100644 --- a/src/screens/settings/about/AboutScreen.tsx +++ b/src/screens/settings/about/AboutScreen.tsx @@ -1,20 +1,10 @@ import * as ExpoLinking from 'expo-linking' import React from 'react' import { useTranslation } from 'react-i18next' +import { ScrollView } from 'react-native' import FastSquircleView from 'react-native-fast-squircle' -import { - Container, - Group, - HeaderBar, - Image, - PressableRow, - Row, - SafeAreaContainer, - Text, - XStack, - YStack -} from '@/componentsV2' +import { Group, HeaderBar, Image, PressableRow, Row, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' import { ArrowUpRight, Copyright, Github, Globe, Mail, Rss } from '@/componentsV2/icons/LucideIcon' import { loggerService } from '@/services/LoggerService' @@ -42,8 +32,8 @@ export default function AboutScreen() { onPress: async () => await openLink('https://github.com/CherryHQ/cherry-studio-app') }} /> - - + + {/* Logo and Description */} @@ -57,7 +47,7 @@ export default function AboutScreen() { cornerSmoothing={0.6}> - + {t('common.cherry_studio')} {t('common.cherry_studio_description')} @@ -109,7 +99,7 @@ export default function AboutScreen() { - + ) } diff --git a/src/screens/settings/assistant/AssistantSettingsScreen.tsx b/src/screens/settings/assistant/AssistantSettingsScreen.tsx index 6c1d53d91..a4185e325 100644 --- a/src/screens/settings/assistant/AssistantSettingsScreen.tsx +++ b/src/screens/settings/assistant/AssistantSettingsScreen.tsx @@ -3,9 +3,9 @@ import type { StackNavigationProp } from '@react-navigation/stack' import { Button } from 'heroui-native' import React from 'react' import { useTranslation } from 'react-i18next' -import { ActivityIndicator } from 'react-native' +import { ActivityIndicator, ScrollView } from 'react-native' -import { Container, HeaderBar, IconButton, Image, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' +import { HeaderBar, IconButton, Image, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' import { presentModelSheet } from '@/componentsV2/features/Sheet/ModelSheet' import { ChevronDown, Languages, MessageSquareMore, Rocket, Settings2 } from '@/componentsV2/icons/LucideIcon' import { useAssistant } from '@/hooks/useAssistant' @@ -37,7 +37,7 @@ function ModelPicker({ assistant, onPress }: { assistant: Assistant; onPress: () {model ? ( <> @@ -160,7 +160,7 @@ export default function AssistantSettingsScreen() { return ( - + {assistantItems.map(item => ( ))} - + ) } diff --git a/src/screens/settings/data/BasicDataSettingsScreen.tsx b/src/screens/settings/data/BasicDataSettingsScreen.tsx index 86667166d..9e45b7215 100644 --- a/src/screens/settings/data/BasicDataSettingsScreen.tsx +++ b/src/screens/settings/data/BasicDataSettingsScreen.tsx @@ -7,10 +7,9 @@ import * as IntentLauncher from 'expo-intent-launcher' import { delay } from 'lodash' import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { InteractionManager, Platform } from 'react-native' +import { InteractionManager, Platform, ScrollView } from 'react-native' import { - Container, dismissDialog, Group, GroupTitle, @@ -270,8 +269,8 @@ export default function BasicDataSettingsScreen() { - - + + {settingsItems.map(group => ( {group.items.map(item => ( @@ -280,7 +279,7 @@ export default function BasicDataSettingsScreen() { ))} - + - - - - {settingsItems.map(group => ( - - {group.items.map(item => ( - - ))} - - ))} - - - + + + {settingsItems.map(group => ( + + {group.items.map(item => ( + + ))} + + ))} + + ) } diff --git a/src/screens/settings/general/GeneralSettingsScreen.tsx b/src/screens/settings/general/GeneralSettingsScreen.tsx index 486b01422..83b3ccbbe 100644 --- a/src/screens/settings/general/GeneralSettingsScreen.tsx +++ b/src/screens/settings/general/GeneralSettingsScreen.tsx @@ -1,22 +1,26 @@ import { Switch } from 'heroui-native' import React from 'react' import { useTranslation } from 'react-i18next' +import { ScrollView } from 'react-native' -import { Container, Group, GroupTitle, HeaderBar, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' +import { Group, GroupTitle, HeaderBar, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' import { LanguageDropdown } from '@/componentsV2/features/SettingsScreen/general/LanguageDropdown' +import { TabletSidebarPositionDropdown } from '@/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown' import { ThemeDropdown } from '@/componentsV2/features/SettingsScreen/general/ThemeDropdown' import { usePreference } from '@/hooks/usePreference' +import { useResponsive } from '@/hooks/useResponsive' export default function GeneralSettingsScreen() { const { t } = useTranslation() + const { isTablet } = useResponsive() const [developerMode, setDeveloperMode] = usePreference('app.developer_mode') const [autoScroll, setAutoScroll] = usePreference('chat.auto_scroll') return ( - - + + {/* Display settings */} {t('settings.general.display.title')} @@ -39,6 +43,18 @@ export default function GeneralSettingsScreen() { + {/* Tablet sidebar position - only visible on tablet devices */} + {isTablet && ( + + {t('settings.general.tablet_sidebar.title')} + + + {t('settings.general.tablet_sidebar.position')} + + + + + )} {/* Chat settings */} {t('settings.general.auto_scroll.title')} @@ -66,7 +82,7 @@ export default function GeneralSettingsScreen() { - + ) } diff --git a/src/screens/settings/websearch/WebSearchSettingsScreen.tsx b/src/screens/settings/websearch/WebSearchSettingsScreen.tsx index 3fe985383..b042e0cae 100644 --- a/src/screens/settings/websearch/WebSearchSettingsScreen.tsx +++ b/src/screens/settings/websearch/WebSearchSettingsScreen.tsx @@ -1,9 +1,8 @@ import React from 'react' import { useTranslation } from 'react-i18next' -import { View } from 'react-native' import { KeyboardAwareScrollView } from 'react-native-keyboard-controller' -import { Container, HeaderBar, SafeAreaContainer, YStack } from '@/componentsV2' +import { HeaderBar, SafeAreaContainer, YStack } from '@/componentsV2' import GeneralSettings from './GeneralSettings' import ProviderSettings from './ProviderSettings' @@ -13,17 +12,13 @@ export default function WebSearchSettingsScreen() { return ( - - - - - - + + + + - - - - + + ) diff --git a/src/shared/data/preference/preferenceSchemas.ts b/src/shared/data/preference/preferenceSchemas.ts index 94478626a..633b6b500 100644 --- a/src/shared/data/preference/preferenceSchemas.ts +++ b/src/shared/data/preference/preferenceSchemas.ts @@ -41,6 +41,11 @@ export const DefaultPreferences: PreferenceSchemas = { // - system: Follow system theme preference 'ui.theme_mode': ThemeMode.system, + // Tablet sidebar position (only visible on tablet devices) + // - left: Sidebar on the left side + // - right: Sidebar on the right side + 'ui.tablet_sidebar_position': 'left', + // === Topic State === // Currently active conversation topic ID // Empty string means no active topic @@ -93,6 +98,7 @@ export const PreferenceDescriptions: Record