chore: vendor client into main repo
This commit is contained in:
50
client/app/(app)/_layout.tsx
Normal file
50
client/app/(app)/_layout.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { Pressable, Text } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
export default function AppLayout() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTitleAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="home"
|
||||
options={{
|
||||
title: t('home.title'),
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
onPress={() => router.push('/(app)/settings')}
|
||||
hitSlop={10}
|
||||
>
|
||||
<Text>{t('home.settings')}</Text>
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => (
|
||||
<Pressable onPress={() => router.push('/(app)/favorites')} hitSlop={10}>
|
||||
<Text>{t('home.favorites')}</Text>
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="favorites"
|
||||
options={{
|
||||
title: t('favorites.title'),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: t('settings.title'),
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
61
client/app/(app)/favorites.tsx
Normal file
61
client/app/(app)/favorites.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FlatList, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
||||
import { getFavorites } from '@/src/storage/appStorage';
|
||||
|
||||
export default function FavoritesScreen() {
|
||||
const { t } = useTranslation();
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const list = await getFavorites();
|
||||
if (!cancelled) setIds(list);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c]));
|
||||
return ids.map((id) => map.get(id)).filter(Boolean) as { id: string; text: string }[];
|
||||
}, [ids]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{items.length === 0 ? (
|
||||
<Text style={styles.empty}>{t('favorites.empty')}</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(it) => it.id}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.text}>{item.text}</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 16 },
|
||||
empty: { color: '#6B7280', fontSize: 16, textAlign: 'center', marginTop: 40 },
|
||||
list: { gap: 12, paddingBottom: 24 },
|
||||
row: {
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
text: { color: '#111827', fontSize: 16, lineHeight: 22 },
|
||||
});
|
||||
|
||||
76
client/app/(app)/home.tsx
Normal file
76
client/app/(app)/home.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
||||
import { addFavorite, setReaction } from '@/src/storage/appStorage';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { t } = useTranslation();
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const item = useMemo(() => MOCK_CONTENT[index % MOCK_CONTENT.length], [index]);
|
||||
|
||||
async function onLike() {
|
||||
await setReaction(item.id, 'like');
|
||||
await addFavorite(item.id);
|
||||
setIndex((i) => i + 1);
|
||||
}
|
||||
|
||||
async function onDislike() {
|
||||
await setReaction(item.id, 'dislike');
|
||||
setIndex((i) => i + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.text}>{item.text}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={[styles.button, styles.dislike]} onPress={onDislike}>
|
||||
<Text style={styles.buttonText}>{t('home.dislike')}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.button, styles.like]} onPress={onLike}>
|
||||
<Text style={styles.buttonText}>{t('home.like')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
backgroundColor: '#F6F7FB',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
text: {
|
||||
fontSize: 20,
|
||||
lineHeight: 28,
|
||||
color: '#111827',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
},
|
||||
like: { backgroundColor: '#16A34A' },
|
||||
dislike: { backgroundColor: '#EF4444' },
|
||||
buttonText: { color: 'white', fontSize: 16, fontWeight: '600' },
|
||||
});
|
||||
|
||||
50
client/app/(app)/settings.tsx
Normal file
50
client/app/(app)/settings.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import Constants from 'expo-constants';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const version =
|
||||
Constants.expoConfig?.version ??
|
||||
// 兜底:部分环境下 expoConfig 可能为空
|
||||
'unknown';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.label}>{t('settings.version')}</Text>
|
||||
<Text style={styles.value}>{version}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>{t('settings.widgetTitle')}</Text>
|
||||
<Text style={styles.cardText}>{t('settings.widgetDesc')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 16, gap: 16 },
|
||||
section: {
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: '#E5E7EB',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
label: { color: '#374151', fontSize: 16 },
|
||||
value: { color: '#111827', fontSize: 16, fontWeight: '600' },
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
backgroundColor: '#111827',
|
||||
},
|
||||
cardTitle: { color: 'white', fontSize: 16, fontWeight: '700', marginBottom: 8 },
|
||||
cardText: { color: '#E5E7EB', fontSize: 14, lineHeight: 20 },
|
||||
});
|
||||
|
||||
18
client/app/(onboarding)/_layout.tsx
Normal file
18
client/app/(onboarding)/_layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function OnboardingLayout() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTitleAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="onboarding" options={{ title: t('onboarding.title') }} />
|
||||
<Stack.Screen name="push-prompt" options={{ title: t('push.title') }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
104
client/app/(onboarding)/onboarding.tsx
Normal file
104
client/app/(onboarding)/onboarding.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { setOnboardingCompleted } from '@/src/storage/appStorage';
|
||||
|
||||
type OnboardingPage = {
|
||||
title: string;
|
||||
desc: string;
|
||||
};
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 3–5 页:这里默认 4 页,后续可按产品调整为 3 或 5
|
||||
const pages = useMemo<OnboardingPage[]>(
|
||||
() => [
|
||||
{ title: t('onboarding.q1Title'), desc: t('onboarding.q1Desc') },
|
||||
{ title: t('onboarding.q2Title'), desc: t('onboarding.q2Desc') },
|
||||
{ title: t('onboarding.q3Title'), desc: t('onboarding.q3Desc') },
|
||||
{ title: t('onboarding.q4Title'), desc: t('onboarding.q4Desc') }
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const total = pages.length;
|
||||
const [step, setStep] = useState(0);
|
||||
const page = pages[Math.min(step, total - 1)];
|
||||
|
||||
async function finishAndNext() {
|
||||
await setOnboardingCompleted(true);
|
||||
router.replace('/(onboarding)/push-prompt');
|
||||
}
|
||||
|
||||
async function onNext() {
|
||||
if (step >= total - 1) {
|
||||
await finishAndNext();
|
||||
return;
|
||||
}
|
||||
setStep((s) => s + 1);
|
||||
}
|
||||
|
||||
async function onSkipPage() {
|
||||
// 每页可跳过:直接进入下一页(最后一页则结束)
|
||||
await onNext();
|
||||
}
|
||||
|
||||
async function onSkipAll() {
|
||||
// 一键跳过整个 Onboarding
|
||||
await finishAndNext();
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.progress}>
|
||||
{t('onboarding.progress', { current: step + 1, total })}
|
||||
</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>{page.title}</Text>
|
||||
<Text style={styles.desc}>{page.desc}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={[styles.btn, styles.secondary]} onPress={onSkipPage}>
|
||||
<Text style={[styles.btnText, styles.secondaryText]}>{t('onboarding.skip')}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.btn, styles.primary]} onPress={onNext}>
|
||||
<Text style={styles.btnText}>{t('onboarding.next')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable style={styles.skipAll} onPress={onSkipAll}>
|
||||
<Text style={styles.skipAllText}>{t('onboarding.skipAll')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 20, justifyContent: 'center', gap: 16 },
|
||||
progress: { textAlign: 'center', color: '#6B7280' },
|
||||
card: {
|
||||
borderRadius: 18,
|
||||
padding: 20,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: '#E5E7EB',
|
||||
gap: 10
|
||||
},
|
||||
title: { fontSize: 22, fontWeight: '700', color: '#111827' },
|
||||
desc: { fontSize: 16, lineHeight: 22, color: '#374151' },
|
||||
actions: { flexDirection: 'row', gap: 12 },
|
||||
btn: { flex: 1, paddingVertical: 14, borderRadius: 14, alignItems: 'center' },
|
||||
primary: { backgroundColor: '#111827' },
|
||||
secondary: { backgroundColor: '#F3F4F6' },
|
||||
btnText: { fontSize: 16, fontWeight: '600', color: '#FFFFFF' },
|
||||
secondaryText: { color: '#111827' },
|
||||
skipAll: { alignItems: 'center', paddingTop: 10 },
|
||||
skipAllText: { color: '#6B7280', textDecorationLine: 'underline' }
|
||||
});
|
||||
|
||||
74
client/app/(onboarding)/push-prompt.tsx
Normal file
74
client/app/(onboarding)/push-prompt.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
|
||||
import { setPushPromptState } from '@/src/storage/appStorage';
|
||||
|
||||
export default function PushPromptScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function goHome() {
|
||||
router.replace('/(app)/home');
|
||||
}
|
||||
|
||||
async function onLater() {
|
||||
await setPushPromptState('skipped');
|
||||
await goHome();
|
||||
}
|
||||
|
||||
async function onEnableNow() {
|
||||
// 触发系统权限申请(可失败,但不阻塞进入主功能)
|
||||
setLoading(true);
|
||||
try {
|
||||
await Notifications.requestPermissionsAsync();
|
||||
await setPushPromptState('enabled');
|
||||
await goHome();
|
||||
} catch (e) {
|
||||
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
|
||||
await goHome();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>{t('push.cardTitle')}</Text>
|
||||
<Text style={styles.desc}>{t('push.cardDesc')}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={[styles.btn, styles.secondary]} onPress={onLater} disabled={loading}>
|
||||
<Text style={[styles.btnText, styles.secondaryText]}>{t('push.later')}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.btn, styles.primary]} onPress={onEnableNow} disabled={loading}>
|
||||
<Text style={styles.btnText}>{loading ? t('push.loading') : t('push.enable')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 20, justifyContent: 'center', gap: 16 },
|
||||
card: {
|
||||
borderRadius: 18,
|
||||
padding: 20,
|
||||
backgroundColor: '#111827',
|
||||
gap: 10
|
||||
},
|
||||
title: { color: 'white', fontSize: 20, fontWeight: '700' },
|
||||
desc: { color: '#E5E7EB', fontSize: 15, lineHeight: 21 },
|
||||
actions: { flexDirection: 'row', gap: 12 },
|
||||
btn: { flex: 1, paddingVertical: 14, borderRadius: 14, alignItems: 'center' },
|
||||
primary: { backgroundColor: '#16A34A' },
|
||||
secondary: { backgroundColor: '#F3F4F6' },
|
||||
btnText: { fontSize: 16, fontWeight: '600', color: '#FFFFFF' },
|
||||
secondaryText: { color: '#111827' }
|
||||
});
|
||||
|
||||
38
client/app/+html.tsx
Normal file
38
client/app/+html.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
40
client/app/+not-found.tsx
Normal file
40
client/app/+not-found.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { Text, View } from '@/components/Themed';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>This screen doesn't exist.</Text>
|
||||
|
||||
<Link href="/" style={styles.link}>
|
||||
<Text style={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 14,
|
||||
color: '#2e78b7',
|
||||
},
|
||||
});
|
||||
81
client/app/_layout.tsx
Normal file
81
client/app/_layout.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { useEffect, useState } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { initI18n } from '@/src/i18n';
|
||||
|
||||
export {
|
||||
// Catch any errors thrown by the Layout component.
|
||||
ErrorBoundary,
|
||||
} from 'expo-router';
|
||||
|
||||
export const unstable_settings = {
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: 'index',
|
||||
};
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
...FontAwesome.font,
|
||||
});
|
||||
const [i18nReady, setI18nReady] = useState(false);
|
||||
|
||||
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
||||
useEffect(() => {
|
||||
if (error) throw error;
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
initI18n()
|
||||
.catch((e) => {
|
||||
// i18n 初始化失败不应阻塞 App 启动,先打印错误再继续
|
||||
console.error('i18n 初始化失败', e);
|
||||
})
|
||||
.finally(() => setI18nReady(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 等字体与 i18n 都准备好后再隐藏启动页,避免文案闪烁
|
||||
if (loaded && i18nReady) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [loaded, i18nReady]);
|
||||
|
||||
if (!loaded || !i18nReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RootLayoutNav />;
|
||||
}
|
||||
|
||||
function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
{/* 启动分发页:根据 onboarding 状态跳转 */}
|
||||
<Stack.Screen name="index" />
|
||||
|
||||
{/* Onboarding 分组 */}
|
||||
<Stack.Screen name="(onboarding)" />
|
||||
|
||||
{/* 主应用分组(不使用 Tabs) */}
|
||||
<Stack.Screen name="(app)" />
|
||||
|
||||
{/* 其他 */}
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
35
client/app/index.tsx
Normal file
35
client/app/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
import { getOnboardingCompleted } from '@/src/storage/appStorage';
|
||||
|
||||
/**
|
||||
* 启动分发:根据 onboarding 状态跳转
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const completed = await getOnboardingCompleted();
|
||||
if (cancelled) return;
|
||||
router.replace(completed ? '/(app)/home' : '/(onboarding)/onboarding');
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
|
||||
35
client/app/modal.tsx
Normal file
35
client/app/modal.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import EditScreenInfo from '@/components/EditScreenInfo';
|
||||
import { Text, View } from '@/components/Themed';
|
||||
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Modal</Text>
|
||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
||||
<EditScreenInfo path="app/modal.tsx" />
|
||||
|
||||
{/* Use a light status bar on iOS to account for the black space above the modal */}
|
||||
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
separator: {
|
||||
marginVertical: 30,
|
||||
height: 1,
|
||||
width: '80%',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user