refactor(App, navigation): migrate to Expo Router and clean up navigation structure
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m27s
Frontend CI / ota-android (push) Successful in 11m6s
Frontend CI / build-android-apk (push) Successful in 1h16m45s

- Updated App entry point to utilize Expo Router, simplifying the navigation setup.
- Removed legacy navigation components and services to streamline the codebase.
- Adjusted package.json to reflect the new entry point and updated dependencies for compatibility.
- Enhanced overall application structure by consolidating navigation logic and improving maintainability.
This commit is contained in:
lafay
2026-03-24 14:21:31 +08:00
parent b91e78c921
commit 2ddb9cadd8
111 changed files with 1829 additions and 3214 deletions

View File

@@ -0,0 +1,96 @@
import { Platform, useWindowDimensions } from 'react-native';
import { Tabs } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, shadows } from '../../../src/theme';
import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
import { useTotalUnreadCount } from '../../../src/stores';
const TAB_BAR_HEIGHT = 64;
const TAB_BAR_MARGIN = 14;
const TAB_BAR_FLOATING_MARGIN = 12;
export default function TabsLayout() {
const { width } = useWindowDimensions();
const insets = useSafeAreaInsets();
const unreadCount = useTotalUnreadCount();
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.primary.main,
tabBarInactiveTintColor: colors.text.secondary,
tabBarStyle: hideTabBar
? { display: 'none', height: 0, overflow: 'hidden' }
: {
position: 'absolute',
left: TAB_BAR_MARGIN,
right: TAB_BAR_MARGIN,
bottom: TAB_BAR_FLOATING_MARGIN + insets.bottom,
height: TAB_BAR_HEIGHT,
borderRadius: 24,
backgroundColor: colors.background.paper,
...shadows.lg,
borderTopWidth: 0,
elevation: 100,
zIndex: 100,
},
tabBarShowLabel: true,
tabBarLabelStyle: { fontSize: 12, fontWeight: '600', marginTop: -2 },
}}
>
<Tabs.Screen
name="home"
options={{
title: '首页',
tabBarIcon: ({ color, focused }) => (
<MaterialCommunityIcons
name={focused ? 'home' : 'home-outline'}
size={focused ? 26 : 24}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="messages"
options={{
title: '消息',
tabBarBadge: unreadCount > 0 ? (unreadCount > 99 ? 99 : unreadCount) : undefined,
tabBarIcon: ({ color, focused }) => (
<MaterialCommunityIcons
name={focused ? 'message-text' : 'message-text-outline'}
size={focused ? 26 : 24}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="schedule"
options={{
title: '课表',
tabBarIcon: ({ color, focused }) => (
<MaterialCommunityIcons name="calendar-today" size={focused ? 26 : 24} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: '我的',
tabBarIcon: ({ color, focused }) => (
<MaterialCommunityIcons
name={focused ? 'account' : 'account-outline'}
size={focused ? 26 : 24}
color={color}
/>
),
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function HomeStackLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -0,0 +1,5 @@
import { HomeScreen } from '../../../../src/screens/home';
export default function HomeRoute() {
return <HomeScreen />;
}

View File

@@ -0,0 +1,5 @@
import { SearchScreen } from '../../../../src/screens/home';
export default function HomeSearchRoute() {
return <SearchScreen />;
}

View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function MessagesStackLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -0,0 +1,5 @@
import { MessageListScreen } from '../../../../src/screens/message';
export default function MessagesRoute() {
return <MessageListScreen />;
}

View File

@@ -0,0 +1,5 @@
import { NotificationsScreen } from '../../../../src/screens/message';
export default function NotificationsRoute() {
return <NotificationsScreen />;
}

View File

@@ -0,0 +1,36 @@
import { Stack } from 'expo-router';
import { useRouter } from 'expo-router';
import { AppBackButton } from '../../../../src/components/common';
import { colors } from '../../../../src/theme';
const headerOptions = {
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerTitleStyle: { fontWeight: '600' as const },
headerShadowVisible: false,
headerBackTitle: '',
};
export default function ProfileStackLayout() {
const router = useRouter();
return (
<Stack
screenOptions={{
...headerOptions,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: true, title: '设置' }} />
<Stack.Screen name="edit-profile" options={{ title: '编辑资料' }} />
<Stack.Screen name="account-security" options={{ title: '账号安全' }} />
<Stack.Screen name="my-posts" options={{ title: '我的帖子' }} />
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
<Stack.Screen name="notification-settings" options={{ title: '通知设置' }} />
<Stack.Screen name="blocked-users" options={{ title: '黑名单' }} />
</Stack>
);
}

View File

@@ -0,0 +1,5 @@
import { AccountSecurityScreen } from '../../../../src/screens/profile';
export default function AccountSecurityRoute() {
return <AccountSecurityScreen />;
}

View File

@@ -0,0 +1,5 @@
import { BlockedUsersScreen } from '../../../../src/screens/profile';
export default function BlockedUsersRoute() {
return <BlockedUsersScreen />;
}

View File

@@ -0,0 +1,5 @@
import { ProfileScreen } from '../../../../src/screens/profile';
export default function BookmarksRoute() {
return <ProfileScreen />;
}

View File

@@ -0,0 +1,5 @@
import { EditProfileScreen } from '../../../../src/screens/profile';
export default function EditProfileRoute() {
return <EditProfileScreen />;
}

View File

@@ -0,0 +1,5 @@
import { ProfileScreen } from '../../../../src/screens/profile';
export default function ProfileRoute() {
return <ProfileScreen />;
}

View File

@@ -0,0 +1,5 @@
import { ProfileScreen } from '../../../../src/screens/profile';
export default function MyPostsRoute() {
return <ProfileScreen />;
}

View File

@@ -0,0 +1,5 @@
import { NotificationSettingsScreen } from '../../../../src/screens/profile';
export default function NotificationSettingsRoute() {
return <NotificationSettingsScreen />;
}

View File

@@ -0,0 +1,5 @@
import { SettingsScreen } from '../../../../src/screens/profile';
export default function SettingsRoute() {
return <SettingsScreen />;
}

View File

@@ -0,0 +1,17 @@
import { Stack } from 'expo-router';
export default function ScheduleStackLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen
name="course"
options={{
headerShown: false,
presentation: 'transparentModal',
animation: 'fade',
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,5 @@
import { CourseDetailScreen } from '../../../../src/screens/schedule';
export default function CourseDetailRoute() {
return <CourseDetailScreen />;
}

View File

@@ -0,0 +1,5 @@
import { ScheduleScreen } from '../../../../src/screens/schedule';
export default function ScheduleRoute() {
return <ScheduleScreen />;
}

19
app/(app)/_layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { Redirect } from 'expo-router';
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
import { messageManager, useAuthStore } from '../../src/stores';
export default function AppLayout() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => {
messageManager.initialize();
}, []);
if (!isAuthenticated) {
return <Redirect href="/login" />;
}
return <AppRouteStack />;
}

28
app/(app)/_layout.web.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { useEffect } from 'react';
import { useWindowDimensions } from 'react-native';
import { Redirect } from 'expo-router';
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
import { BREAKPOINTS } from '../../src/hooks/useResponsive';
import { messageManager, useAuthStore } from '../../src/stores';
export default function AppLayoutWeb() {
const { width } = useWindowDimensions();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const useDesktopShell = width >= BREAKPOINTS.desktop;
useEffect(() => {
messageManager.initialize();
}, []);
if (!isAuthenticated) {
return <Redirect href="/login" />;
}
if (useDesktopShell) {
return <AppDesktopShell />;
}
return <AppRouteStack />;
}

View File

@@ -0,0 +1,5 @@
import { ChatScreen } from '../../../src/screens/message';
export default function ChatRoute() {
return <ChatScreen />;
}

View File

@@ -0,0 +1,5 @@
import { PrivateChatInfoScreen } from '../../../src/screens/message';
export default function PrivateChatInfoRoute() {
return <PrivateChatInfoScreen />;
}

View File

@@ -0,0 +1,5 @@
import { GroupInfoScreen } from '../../../../src/screens/message';
export default function GroupInfoRoute() {
return <GroupInfoScreen />;
}

View File

@@ -0,0 +1,5 @@
import { GroupMembersScreen } from '../../../../src/screens/message';
export default function GroupMembersRoute() {
return <GroupMembersScreen />;
}

View File

@@ -0,0 +1,5 @@
import { CreateGroupScreen } from '../../../src/screens/message';
export default function CreateGroupRoute() {
return <CreateGroupScreen />;
}

View File

@@ -0,0 +1,5 @@
import GroupInviteDetailScreen from '../../../src/screens/message/GroupInviteDetailScreen';
export default function GroupInviteRoute() {
return <GroupInviteDetailScreen />;
}

5
app/(app)/group/join.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { JoinGroupScreen } from '../../../src/screens/message';
export default function JoinGroupRoute() {
return <JoinGroupScreen />;
}

View File

@@ -0,0 +1,5 @@
import GroupRequestDetailScreen from '../../../src/screens/message/GroupRequestDetailScreen';
export default function GroupRequestRoute() {
return <GroupRequestDetailScreen />;
}

View File

@@ -0,0 +1,5 @@
import { CreatePostScreen } from '../../../src/screens/create';
export default function CreatePostRoute() {
return <CreatePostScreen />;
}

View File

@@ -0,0 +1,5 @@
import { QRCodeConfirmScreen } from '../../../../src/screens/auth/QRCodeConfirmScreen';
export default function QrLoginConfirmRoute() {
return <QRCodeConfirmScreen />;
}

View File

@@ -0,0 +1,5 @@
import FollowListScreen from '../../../../src/screens/profile/FollowListScreen';
export default function FollowListRoute() {
return <FollowListScreen />;
}

5
app/(auth)/_layout.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -0,0 +1,5 @@
import { ForgotPasswordScreen } from '../../src/screens/auth';
export default function ForgotPasswordRoute() {
return <ForgotPasswordScreen />;
}

5
app/(auth)/login.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { LoginScreen } from '../../src/screens/auth';
export default function LoginRoute() {
return <LoginScreen />;
}

5
app/(auth)/register.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { RegisterScreen } from '../../src/screens/auth';
export default function RegisterRoute() {
return <RegisterScreen />;
}

173
app/_layout.tsx Normal file
View File

@@ -0,0 +1,173 @@
import React, { useEffect, useRef, useState } from 'react';
import { AppState, AppStateStatus, Platform, View, ActivityIndicator, StyleSheet } from 'react-native';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { PaperProvider } from 'react-native-paper';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as Notifications from 'expo-notifications';
import { paperTheme } from '../src/theme';
import { AppBackButton } from '../src/components/common';
import AppPromptBar from '../src/components/common/AppPromptBar';
import AppDialogHost from '../src/components/common/AppDialogHost';
import { installAlertOverride } from '../src/services/alertOverride';
import { useAuthStore } from '../src/stores';
import { colors } from '../src/theme';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 5 * 60 * 1000,
},
},
});
installAlertOverride();
if (Platform.OS === 'web' && typeof document !== 'undefined') {
const style = document.createElement('style');
style.textContent = `
input:focus, textarea:focus, select:focus {
outline: none !important;
outline-width: 0 !important;
box-shadow: none !important;
}
input:focus-visible, textarea:focus-visible {
outline: none !important;
}
*:focus { outline: none !important; }
*:focus-visible { outline: none !important; }
`;
document.head.appendChild(style);
}
function SessionGate({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
useEffect(() => {
fetchCurrentUser().finally(() => setReady(true));
}, [fetchCurrentUser]);
if (!ready) {
return (
<View style={gateStyles.loading}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
);
}
return <>{children}</>;
}
function NotificationBootstrap() {
const appState = useRef<AppStateStatus>(AppState.currentState);
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);
useEffect(() => {
const initNotifications = async () => {
const { systemNotificationService } = await import('../src/services/systemNotificationService');
await systemNotificationService.initialize();
const { initBackgroundService } = await import('../src/services/backgroundService');
await initBackgroundService();
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
systemNotificationService.clearBadge();
}
appState.current = nextAppState;
});
notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
void response.notification.request.content.data;
}
);
const notificationReceivedSubscription = Notifications.addNotificationReceivedListener(
(notification) => {
void notification;
}
);
return () => {
subscription.remove();
notificationResponseListener.current?.remove();
notificationReceivedSubscription.remove();
};
};
initNotifications();
}, []);
return null;
}
export default function RootLayout() {
const router = useRouter();
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<PaperProvider theme={paperTheme}>
<QueryClientProvider client={queryClient}>
<StatusBar style="light" />
<SessionGate>
<NotificationBootstrap />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(app)" options={{ headerShown: false }} />
<Stack.Screen
name="post/[postId]"
options={{
headerShown: true,
title: '',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
/>
<Stack.Screen
name="user/[userId]"
options={{
headerShown: true,
title: '用户主页',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
/>
</Stack>
</SessionGate>
<AppPromptBar />
<AppDialogHost />
</QueryClientProvider>
</PaperProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
const gateStyles = StyleSheet.create({
loading: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.default,
},
});

11
app/index.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { Redirect } from 'expo-router';
import { useAuthStore } from '../src/stores';
export default function Index() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (isAuthenticated) {
return <Redirect href="/home" />;
}
return <Redirect href="/login" />;
}

5
app/post/[postId].tsx Normal file
View File

@@ -0,0 +1,5 @@
import { PostDetailScreen } from '../../src/screens/home';
export default function PostDetailRoute() {
return <PostDetailScreen />;
}

5
app/user/[userId].tsx Normal file
View File

@@ -0,0 +1,5 @@
import { UserScreen } from '../../src/screens/profile';
export default function UserProfileRoute() {
return <UserScreen />;
}