feature:基本功能
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
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 (
|
||||
@@ -16,26 +13,9 @@ export default function AppLayout() {
|
||||
<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'),
|
||||
// 卡片页标题按设计留空(右上角为 icon 按钮)
|
||||
title: '',
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -1,45 +1,220 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { Pressable } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigation } from 'expo-router';
|
||||
import Animated, {
|
||||
Easing,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
||||
import { addFavorite, setReaction } from '@/src/storage/appStorage';
|
||||
import {
|
||||
addFavorite,
|
||||
getThemeMode,
|
||||
getUserProfile,
|
||||
setReaction,
|
||||
setThemeMode,
|
||||
type ThemeMode,
|
||||
} from '@/src/storage/appStorage';
|
||||
|
||||
import ProfileModal from '@/components/home/ProfileModal';
|
||||
import ThemeModal from '@/components/home/ThemeModal';
|
||||
|
||||
import ThemeIcon from '@/assets/images/home/theme.svg';
|
||||
import MyIcon from '@/assets/images/home/my.svg';
|
||||
import LikeOutlineIcon from '@/assets/images/home/like.svg';
|
||||
import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
|
||||
import HateIcon from '@/assets/images/home/hate.svg';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
||||
const [themeOpen, setThemeOpen] = useState(false);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [likeFilled, setLikeFilled] = useState(false);
|
||||
|
||||
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);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const mode = await getThemeMode();
|
||||
const profile = await getUserProfile();
|
||||
if (cancelled) return;
|
||||
setThemeModeState(mode);
|
||||
setProfileName(profile.name);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const backgroundColor = themeMode === 'color' ? '#F3D0E1' : '#F4D6C2';
|
||||
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerShadowVisible: false,
|
||||
headerStyle: { backgroundColor },
|
||||
headerRight: () => (
|
||||
<View style={styles.headerRight}>
|
||||
<CircleIconButton
|
||||
onPress={() => setThemeOpen(true)}
|
||||
accessibilityLabel={t('home.theme')}
|
||||
>
|
||||
<ThemeIcon width={18} height={18} />
|
||||
</CircleIconButton>
|
||||
<CircleIconButton
|
||||
onPress={() => setProfileOpen(true)}
|
||||
accessibilityLabel={t('home.profile')}
|
||||
>
|
||||
<MyIcon width={18} height={18} />
|
||||
</CircleIconButton>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [backgroundColor, navigation, t]);
|
||||
|
||||
const likeScale = useSharedValue(1);
|
||||
const hateScale = useSharedValue(1);
|
||||
|
||||
const likeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: likeScale.value }],
|
||||
}));
|
||||
|
||||
const hateAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: hateScale.value }],
|
||||
}));
|
||||
|
||||
function playLikeAnimationAndThen(next: () => void) {
|
||||
setLikeFilled(true);
|
||||
likeScale.value = withSequence(
|
||||
withTiming(0.92, { duration: 90, easing: Easing.out(Easing.cubic) }),
|
||||
withTiming(1.14, { duration: 120, easing: Easing.out(Easing.cubic) }),
|
||||
withTiming(1, { duration: 140, easing: Easing.out(Easing.cubic) }, (finished) => {
|
||||
if (finished) runOnJS(next)();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function onDislike() {
|
||||
await setReaction(item.id, 'dislike');
|
||||
setIndex((i) => i + 1);
|
||||
function playHateAnimationAndThen(next: () => void) {
|
||||
hateScale.value = withSequence(
|
||||
withTiming(0.92, { duration: 90, easing: Easing.out(Easing.cubic) }),
|
||||
withTiming(1.06, { duration: 110, easing: Easing.out(Easing.cubic) }),
|
||||
withTiming(1, { duration: 120, easing: Easing.out(Easing.cubic) }, (finished) => {
|
||||
if (finished) runOnJS(next)();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function onPressLike() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
playLikeAnimationAndThen(async () => {
|
||||
await setReaction(item.id, 'like');
|
||||
await addFavorite(item.id);
|
||||
setIndex((i) => i + 1);
|
||||
setTimeout(() => setLikeFilled(false), 220);
|
||||
setBusy(false);
|
||||
});
|
||||
}
|
||||
|
||||
function onPressHate() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
playHateAnimationAndThen(async () => {
|
||||
await setReaction(item.id, 'dislike');
|
||||
setIndex((i) => i + 1);
|
||||
setBusy(false);
|
||||
});
|
||||
}
|
||||
|
||||
async function onSelectTheme(next: ThemeMode) {
|
||||
setThemeModeState(next);
|
||||
await setThemeMode(next);
|
||||
setThemeOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.container, { backgroundColor }]}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.text}>{item.text}</Text>
|
||||
<Animated.Text style={styles.text}>{item.text}</Animated.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>
|
||||
<Animated.View style={[styles.reactionButton, hateAnimatedStyle]}>
|
||||
<Pressable
|
||||
onPress={onPressHate}
|
||||
onPressIn={() => (hateScale.value = withTiming(0.92, { duration: 80 }))}
|
||||
onPressOut={() => (hateScale.value = withTiming(1, { duration: 120 }))}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('home.dislike')}
|
||||
hitSlop={10}
|
||||
style={styles.reactionInner}
|
||||
>
|
||||
<HateIcon width={30} height={30} />
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View style={[styles.reactionButton, likeAnimatedStyle]}>
|
||||
<Pressable
|
||||
onPress={onPressLike}
|
||||
onPressIn={() => (likeScale.value = withTiming(0.92, { duration: 80 }))}
|
||||
onPressOut={() => (likeScale.value = withTiming(1, { duration: 120 }))}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('home.like')}
|
||||
hitSlop={10}
|
||||
style={styles.reactionInner}
|
||||
>
|
||||
{likeFilled ? (
|
||||
<LikeFilledIcon width={30} height={30} />
|
||||
) : (
|
||||
<LikeOutlineIcon width={30} height={30} />
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
<ThemeModal visible={themeOpen} mode={themeMode} onSelect={onSelectTheme} onClose={() => setThemeOpen(false)} />
|
||||
<ProfileModal
|
||||
visible={profileOpen}
|
||||
name={profileName}
|
||||
onClose={() => setProfileOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CircleIconButton({
|
||||
onPress,
|
||||
accessibilityLabel,
|
||||
children,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
accessibilityLabel: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
style={styles.circleBtn}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
@@ -47,30 +222,51 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
paddingRight: 10,
|
||||
},
|
||||
circleBtn: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
backgroundColor: '#F6F7FB',
|
||||
backgroundColor: 'rgba(255,255,255,0.65)',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
text: {
|
||||
fontSize: 20,
|
||||
lineHeight: 28,
|
||||
color: '#111827',
|
||||
color: '#5E2A28',
|
||||
fontWeight: '700',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 14,
|
||||
reactionButton: {
|
||||
width: 58,
|
||||
height: 58,
|
||||
borderRadius: 29,
|
||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
reactionInner: {
|
||||
width: 58,
|
||||
height: 58,
|
||||
borderRadius: 29,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
like: { backgroundColor: '#16A34A' },
|
||||
dislike: { backgroundColor: '#EF4444' },
|
||||
buttonText: { color: 'white', fontSize: 16, fontWeight: '600' },
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export default function OnboardingLayout() {
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTitleAlign: 'center',
|
||||
headerShown: false, // 隐藏原生导航栏,使用自定义布局
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="onboarding" options={{ title: t('onboarding.title') }} />
|
||||
|
||||
@@ -1,104 +1,68 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { setOnboardingCompleted } from '@/src/storage/appStorage';
|
||||
|
||||
type OnboardingPage = {
|
||||
title: string;
|
||||
desc: string;
|
||||
};
|
||||
import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
|
||||
import { NameInputStep } from '@/components/onboarding/NameInputStep';
|
||||
import { IntentSelectionStep } from '@/components/onboarding/IntentSelectionStep';
|
||||
import { setOnboardingCompleted, setUserProfile } from '@/src/storage/appStorage';
|
||||
|
||||
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)];
|
||||
const [name, setName] = useState('');
|
||||
const [intents, setIntents] = useState<string[]>([]);
|
||||
|
||||
async function finishAndNext() {
|
||||
async function onFinish() {
|
||||
// Save whatever input we have
|
||||
await setUserProfile({ name, intents });
|
||||
await setOnboardingCompleted(true);
|
||||
router.replace('/(onboarding)/push-prompt');
|
||||
}
|
||||
|
||||
async function onNext() {
|
||||
if (step >= total - 1) {
|
||||
await finishAndNext();
|
||||
return;
|
||||
if (step === 0) {
|
||||
setStep(1);
|
||||
} else {
|
||||
await onFinish();
|
||||
}
|
||||
setStep((s) => s + 1);
|
||||
}
|
||||
|
||||
async function onSkipPage() {
|
||||
// 每页可跳过:直接进入下一页(最后一页则结束)
|
||||
await onNext();
|
||||
async function onSkip() {
|
||||
// Skip logic: move to next step regardless of input
|
||||
if (step === 0) {
|
||||
setStep(1);
|
||||
} else {
|
||||
await onFinish();
|
||||
}
|
||||
}
|
||||
|
||||
async function onSkipAll() {
|
||||
// 一键跳过整个 Onboarding
|
||||
await finishAndNext();
|
||||
}
|
||||
// Step 0: Next button requires input
|
||||
// Step 1: Next button always enabled (can proceed with empty selection)
|
||||
const nextEnabled = step === 0 ? name.trim().length > 0 : true;
|
||||
|
||||
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>
|
||||
<OnboardingLayout
|
||||
onSkip={onSkip}
|
||||
onNext={onNext}
|
||||
nextEnabled={nextEnabled}
|
||||
>
|
||||
{step === 0 ? (
|
||||
<NameInputStep
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
onSubmitEditing={name.trim().length > 0 ? onNext : undefined}
|
||||
/>
|
||||
) : (
|
||||
<IntentSelectionStep
|
||||
selectedIds={intents}
|
||||
onToggle={(id) => {
|
||||
setIntents(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(i => i !== id)
|
||||
: [...prev, id]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</OnboardingLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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' }
|
||||
});
|
||||
|
||||
|
||||
10
client/app/(splash)/_layout.tsx
Normal file
10
client/app/(splash)/_layout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import React from 'react';
|
||||
|
||||
export default function SplashLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="splash" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
163
client/app/(splash)/splash.tsx
Normal file
163
client/app/(splash)/splash.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, Image, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
export default function SplashScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [showConsent, setShowConsent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkConsent();
|
||||
}, []);
|
||||
|
||||
const checkConsent = async () => {
|
||||
const accepted = await getConsentAccepted();
|
||||
setShowConsent(!accepted);
|
||||
if (accepted) {
|
||||
// 如果已经同意过,直接跳转到首页分发
|
||||
// 注意:这里需要与 app/index.tsx 配合,如果 app/index.tsx 已经判断了 consent=true 不会跳过来,
|
||||
// 那么这里其实是防守。
|
||||
// 但如果用户是通过 deep link 或其他方式强制进入 splash,这个逻辑会把他们送走。
|
||||
router.replace('/');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAgree = async () => {
|
||||
await setConsentAccepted(true);
|
||||
setShowConsent(false);
|
||||
router.replace('/');
|
||||
};
|
||||
|
||||
const openLink = async (url: string) => {
|
||||
try {
|
||||
await WebBrowser.openBrowserAsync(url);
|
||||
} catch (error) {
|
||||
Alert.alert(t('common.error'), t('common.openLinkError'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Image
|
||||
source={require('../../assets/images/index/index_flowers.png')}
|
||||
style={styles.image}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<Text style={styles.title}>{t('consent.title')}</Text>
|
||||
<Text style={styles.subtitle}>{t('consent.subtitle')}</Text>
|
||||
</View>
|
||||
|
||||
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
|
||||
{showConsent && (
|
||||
<>
|
||||
<TouchableOpacity onPress={handleAgree} activeOpacity={0.8}>
|
||||
<LinearGradient
|
||||
colors={['#F69F7B', '#F99CC0']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.button}
|
||||
>
|
||||
<Text style={styles.buttonText}>{t('consent.agree')}</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.linksContainer}>
|
||||
<TouchableOpacity onPress={() => openLink('https://example.com/privacy')}>
|
||||
<Text style={styles.linkText}>{t('consent.privacy')}</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity onPress={() => openLink('https://example.com/terms')}>
|
||||
<Text style={styles.linkText}>{t('consent.terms')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFF9F0', // 假设的背景色,根据图片调整
|
||||
alignItems: 'center',
|
||||
},
|
||||
image: {
|
||||
width: width * 0.8,
|
||||
height: height * 0.5,
|
||||
marginTop: height * 0.1,
|
||||
},
|
||||
contentContainer: {
|
||||
alignItems: 'center',
|
||||
marginTop: 20,
|
||||
paddingHorizontal: 30,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#4A3427', // 深褐色
|
||||
marginBottom: 10,
|
||||
textAlign: 'center',
|
||||
fontFamily: Platform.OS === 'ios' ? 'Georgia' : 'serif', // 尝试匹配衬线体
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#4A3427', // 深褐色
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Georgia' : 'serif',
|
||||
},
|
||||
bottomContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
paddingBottom: 20,
|
||||
},
|
||||
button: {
|
||||
width: width * 0.8,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 20,
|
||||
shadowColor: '#F69F7B',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#FFF',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
linksContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
divider: {
|
||||
width: 1,
|
||||
height: 12,
|
||||
backgroundColor: '#CCC',
|
||||
marginHorizontal: 15,
|
||||
},
|
||||
});
|
||||
@@ -2,10 +2,10 @@ import { useEffect } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
import { getOnboardingCompleted } from '@/src/storage/appStorage';
|
||||
import { getOnboardingCompleted, getConsentAccepted } from '@/src/storage/appStorage';
|
||||
|
||||
/**
|
||||
* 启动分发:根据 onboarding 状态跳转
|
||||
* 启动分发:根据 consent 和 onboarding 状态跳转
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
@@ -13,8 +13,17 @@ export default function Index() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const completed = await getOnboardingCompleted();
|
||||
// 1. 检查是否同意协议
|
||||
const consentAccepted = await getConsentAccepted();
|
||||
if (cancelled) return;
|
||||
|
||||
if (!consentAccepted) {
|
||||
router.replace('/(splash)/splash');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 检查 Onboarding
|
||||
const completed = await getOnboardingCompleted();
|
||||
router.replace(completed ? '/(app)/home' : '/(onboarding)/onboarding');
|
||||
})();
|
||||
return () => {
|
||||
|
||||
Reference in New Issue
Block a user