feat: 完善个人中心与小组件功能,优化 Onboarding 交互与手势体验

This commit is contained in:
1
2026-01-31 19:02:54 +08:00
parent c4c7d7d251
commit e675cbbbfb
49 changed files with 1536 additions and 522 deletions

View File

@@ -1,8 +1,7 @@
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { Pressable } from 'react-native';
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useNavigation } from 'expo-router';
import { useNavigation, useFocusEffect } from 'expo-router';
import Animated, {
Easing,
runOnJS,
@@ -27,9 +26,10 @@ 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';
import LikeIcon from '@/assets/images/icon/like_icon.svg';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
export default function HomeScreen() {
const { t } = useTranslation();
@@ -44,19 +44,27 @@ export default function HomeScreen() {
const item = useMemo(() => MOCK_CONTENT[index % MOCK_CONTENT.length], [index]);
useEffect(() => {
let cancelled = false;
(async () => {
const mode = await getThemeMode();
const profile = await getUserProfile();
if (cancelled) return;
setThemeModeState(mode);
setProfileName(profile.name);
})();
return () => {
cancelled = true;
};
}, []);
// 动画相关 Shared Values
const translateY = useSharedValue(0);
const opacity = useSharedValue(1);
const likeScale = useSharedValue(1);
// 每次进入页面或页面获得焦点时刷新个人信息
useFocusEffect(
useCallback(() => {
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';
@@ -83,60 +91,109 @@ export default function HomeScreen() {
});
}, [backgroundColor, navigation, t]);
const likeScale = useSharedValue(1);
const hateScale = useSharedValue(1);
const textAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
opacity: opacity.value,
}));
const likeAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: likeScale.value }],
}));
const hateAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: hateScale.value }],
}));
// 切换到下一条文案的统一动画逻辑
const triggerNextContent = useCallback(() => {
if (busy) return;
setBusy(true);
function playLikeAnimationAndThen(next: () => void) {
// 1. 当前文案向上移动并消失
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
if (finished) {
// 2. 切换数据索引
runOnJS(setIndex)(index + 1);
runOnJS(setLikeFilled)(false);
// 3. 准备下一条文案:先瞬移到下方 40pt
translateY.value = 40;
// 4. 下一条文案向上移动到原位并显现
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
if (finished) {
runOnJS(setBusy)(false);
}
});
}
});
}, [busy, index, translateY, opacity]);
const lastTapRef = useRef<number>(0);
// 使用 Ref 解决 PanResponder 闭包陷阱,确保手势回调能拿到最新的 state 和 function
const handlersRef = useRef({ onPressLike, triggerNextContent });
useEffect(() => {
handlersRef.current = { onPressLike, triggerNextContent };
}, [onPressLike, triggerNextContent]);
// 使用系统自带的 PanResponder 代替第三方手势库
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true, // 允许开始捕获
onMoveShouldSetPanResponder: (_, gestureState) => {
// 只有当垂直滑动距离大于 20 时才接管位移手势
return Math.abs(gestureState.dy) > 20;
},
onPanResponderRelease: (_, gestureState) => {
const now = Date.now();
const DOUBLE_TAP_DELAY = 300;
// 1. 双击逻辑判定
if (now - lastTapRef.current < DOUBLE_TAP_DELAY) {
// 判定为双击
if (Math.abs(gestureState.dx) < 10 && Math.abs(gestureState.dy) < 10) {
runOnJS(handlersRef.current.onPressLike)();
lastTapRef.current = 0; // 重置
return;
}
}
lastTapRef.current = now;
// 2. 上滑逻辑判定
if (gestureState.dy < -50) { // 上滑超过 50pt
runOnJS(handlersRef.current.triggerNextContent)();
}
},
})
).current;
async function onPressLike() {
if (busy) return;
setLikeFilled(true);
// 1. 获取当前日期
const now = new Date();
const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
// 2. 保存到收藏夹,包含当前背景信息
await addFavorite({
id: item.id,
date: dateStr,
themeMode: themeMode,
background: backgroundColor, // 目前存储的是颜色值
});
// 3. 爱心缩放动画
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)();
withTiming(0.8, { duration: 100 }),
withTiming(1.2, { duration: 150 }),
withTiming(1, { duration: 100 }, (finished) => {
if (finished) {
runOnJS(triggerNextContent)();
}
})
);
}
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);
@@ -144,40 +201,24 @@ export default function HomeScreen() {
}
return (
<View style={[styles.container, { backgroundColor }]}>
<View style={styles.card}>
<Animated.Text style={styles.text}>{item.text}</Animated.Text>
</View>
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
<Animated.View style={[styles.card, textAnimatedStyle]}>
<Text style={styles.text}>{item.text}</Text>
</Animated.View>
<View style={styles.actions}>
<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}
hitSlop={20}
style={styles.reactionInner}
>
{likeFilled ? (
<LikeFilledIcon width={30} height={30} />
<LikeFilledIcon width={35} height={36} />
) : (
<LikeOutlineIcon width={30} height={30} />
<LikeIcon width={35} height={36} />
)}
</Pressable>
</Animated.View>
@@ -220,7 +261,7 @@ const styles = StyleSheet.create({
flex: 1,
padding: 20,
justifyContent: 'center',
gap: 16,
alignItems: 'center',
},
headerRight: {
flexDirection: 'row',
@@ -236,37 +277,31 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
card: {
borderRadius: 16,
padding: 20,
backgroundColor: 'rgba(255,255,255,0.65)',
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#E5E7EB',
paddingHorizontal: 30,
alignItems: 'center',
justifyContent: 'center',
},
text: {
fontSize: 20,
lineHeight: 28,
fontSize: 22,
lineHeight: 32,
color: '#5E2A28',
fontWeight: '700',
textAlign: 'center',
},
actions: {
position: 'absolute',
bottom: SCREEN_HEIGHT * 0.16,
left: 0,
right: 0,
flexDirection: 'row',
gap: 12,
justifyContent: 'center',
},
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',
},
});

View File

@@ -1,66 +1,147 @@
import { useState } from 'react';
import { useRouter } from 'expo-router';
import * as Notifications from 'expo-notifications';
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';
import { SelectionStep } from '@/components/onboarding/SelectionStep';
import { ReminderStep } from '@/components/onboarding/ReminderStep';
import { setOnboardingCompleted, setUserProfile, setDailyReminderSettings } from '@/src/storage/appStorage';
const STEPS = [
{ id: 'name', type: 'name', title: '我可以怎么称呼你?' },
{
id: 'status',
type: 'selection',
title: '媽媽的狀態?',
options: [
{ id: 'pregnant', label: '懷孕中/準備成為媽媽' },
{ id: 'has_kids', label: '已經有孩子' },
{ id: 'no_fill', label: '不想填寫' },
]
},
{
id: 'emotion',
type: 'selection',
title: '當下情緒狀態?',
options: [
{ id: 'happy', label: '愉悅、滿足' },
{ id: 'calm', label: '平靜、安穩' },
{ id: 'stressed', label: '被壓得有點喘不過氣' },
{ id: 'low', label: '情緒低落' },
]
},
{
id: 'influence',
type: 'selection',
title: '是什麼影響了你最近的感受?',
options: [
{ id: 'family', label: '家庭與孩子' },
{ id: 'work', label: '工作或學習' },
{ id: 'relationship', label: '親密關係' },
{ id: 'friends', label: '朋友與人際' },
{ id: 'health', label: '身心健康' },
]
},
{
id: 'support',
type: 'selection',
title: '最需要什麼支持?',
options: [
{ id: 'emotional', label: '情緒支持' },
{ id: 'parenting', label: '育兒壓力' },
{ id: 'self_worth', label: '自我價值' },
{ id: 'anxiety', label: '焦慮舒緩' },
{ id: 'balance', label: '休息與平衡' },
]
},
{ id: 'reminder', type: 'reminder', title: '你需要每天几次提醒?' },
];
export default function OnboardingScreen() {
const router = useRouter();
const [step, setStep] = useState(0);
const [stepIndex, setStepIndex] = useState(0);
const [name, setName] = useState('');
const [intents, setIntents] = useState<string[]>([]);
const [selections, setSelections] = useState<Record<string, string[]>>({});
const [reminderTimes, setReminderTimes] = useState(3);
const currentStep = STEPS[stepIndex];
async function onFinish() {
// Save whatever input we have
await setUserProfile({ name, intents });
// 请求推送权限
const { status } = await Notifications.requestPermissionsAsync();
const pushEnabled = status === 'granted';
await setUserProfile({
name,
intents: Object.values(selections).flat()
});
await setDailyReminderSettings({
timesPerDay: reminderTimes,
pushEnabled: pushEnabled
});
await setOnboardingCompleted(true);
router.replace('/(onboarding)/push-prompt');
router.replace('/(app)/home');
}
async function onNext() {
if (step === 0) {
setStep(1);
const onNext = () => {
if (stepIndex < STEPS.length - 1) {
setStepIndex(stepIndex + 1);
} else {
await onFinish();
onFinish();
}
}
};
async function onSkip() {
// Skip logic: move to next step regardless of input
if (step === 0) {
setStep(1);
} else {
await onFinish();
const onBack = () => {
if (stepIndex > 0) {
setStepIndex(stepIndex - 1);
}
}
};
// 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;
const onSkip = () => {
router.replace('/(app)/home');
};
const handleToggleSelection = (id: string) => {
setSelections(prev => {
const currentIds = prev[currentStep.id] || [];
const nextIds = currentIds.includes(id)
? currentIds.filter(i => i !== id)
: [...currentIds, id];
return { ...prev, [currentStep.id]: nextIds };
});
};
return (
<OnboardingLayout
title={currentStep.title}
currentStep={stepIndex}
totalSteps={STEPS.length - 1}
onSkip={onSkip}
onNext={onNext}
nextEnabled={nextEnabled}
onBack={onBack}
showBackButton={stepIndex > 0}
>
{step === 0 ? (
{currentStep.type === 'name' && (
<NameInputStep
value={name}
onChangeText={setName}
onSubmitEditing={name.trim().length > 0 ? onNext : undefined}
onNext={onNext}
/>
) : (
<IntentSelectionStep
selectedIds={intents}
onToggle={(id) => {
setIntents(prev =>
prev.includes(id)
? prev.filter(i => i !== id)
: [...prev, id]
);
}}
)}
{currentStep.type === 'selection' && (
<SelectionStep
options={currentStep.options!}
selectedIds={selections[currentStep.id] || []}
onToggle={handleToggleSelection}
onNext={onNext}
/>
)}
{currentStep.type === 'reminder' && (
<ReminderStep
value={reminderTimes}
onChange={setReminderTimes}
onFinish={onFinish}
/>
)}
</OnboardingLayout>

View File

@@ -1,12 +1,15 @@
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 { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
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';
// 导入 SVG 组件
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
import WelcomeBtn from '../../assets/images/index/welcome_btn.svg';
const { width, height } = Dimensions.get('window');
export default function SplashScreen() {
@@ -22,18 +25,15 @@ export default function SplashScreen() {
const accepted = await getConsentAccepted();
setShowConsent(!accepted);
if (accepted) {
// 如果已经同意过,直接跳转到首页分发
// 注意:这里需要与 app/index.tsx 配合,如果 app/index.tsx 已经判断了 consent=true 不会跳过来,
// 那么这里其实是防守。
// 但如果用户是通过 deep link 或其他方式强制进入 splash这个逻辑会把他们送走。
router.replace('/');
router.replace('/');
}
};
const handleAgree = async () => {
await setConsentAccepted(true);
setShowConsent(false);
router.replace('/');
// 跳转到 onboarding 流程
router.replace('/(onboarding)/onboarding');
};
const openLink = async (url: string) => {
@@ -44,31 +44,40 @@ export default function SplashScreen() {
}
};
const bgDecorationTop = 363;
const bgDecorationHeight = height * 0.6;
const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25);
return (
<View style={styles.container}>
<Image
source={require('../../assets/images/index/index_flowers.png')}
style={styles.image}
resizeMode="contain"
/>
{/* 中间的背景装饰 SVG (现在放在上面,作为上层) */}
<View style={[styles.bgDecorationContainer, { top: bgDecorationTop }]}>
<FlowersBg width={width + 10} height={bgDecorationHeight} />
</View>
<View style={styles.contentContainer}>
<Text style={styles.title}>{t('consent.title')}</Text>
<Text style={styles.subtitle}>{t('consent.subtitle')}</Text>
{/* 顶部的花图片 (现在放在下面,作为下层) */}
<View style={styles.topImageContainer}>
<Image
source={require('../../assets/images/index/index_flowers.png')}
style={styles.topImage}
resizeMode="contain"
/>
</View>
{/* 文案内容 */}
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}>
<Text style={styles.titleText}>
You Are Perfect.{"\n"}
Everything{"\n"}
Will Be Better.
</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 onPress={handleAgree} activeOpacity={0.8} style={styles.buttonWrapper}>
<WelcomeBtn width={87} height={57} />
</TouchableOpacity>
<View style={styles.linksContainer}>
@@ -90,74 +99,57 @@ export default function SplashScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFF9F0', // 假设的背景色,根据图片调整
backgroundColor: '#F5D3B5', // 匹配 Figma 背景色
alignItems: 'center',
},
image: {
width: width * 0.8,
height: height * 0.5,
marginTop: height * 0.1,
topImageContainer: {
marginTop: 60,
zIndex: 1, // 降低层级
},
topImage: {
width: 308,
height: 354,
},
bgDecorationContainer: {
position: 'absolute',
left: -3,
zIndex: 2, // 提高层级,使其覆盖在图片之上
},
contentContainer: {
alignItems: 'center',
marginTop: 20,
paddingHorizontal: 30,
zIndex: 3,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#4A3427', // 深褐
marginBottom: 10,
titleText: {
fontSize: 30,
lineHeight: 42,
color: '#772F00', // 匹配 Figma 文字颜
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',
fontWeight: '600',
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
},
bottomContainer: {
position: 'absolute',
bottom: 0,
bottom: 60,
width: '100%',
alignItems: 'center',
paddingBottom: 20,
zIndex: 4,
},
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',
buttonWrapper: {
marginBottom: 40,
},
linksContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
linkText: {
fontSize: 12,
color: '#999',
color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色
textDecorationLine: 'underline',
},
divider: {
width: 1,
height: 12,
backgroundColor: '#CCC',
backgroundColor: 'rgba(119, 47, 0, 0.2)',
marginHorizontal: 15,
},
});

View File

@@ -3,12 +3,22 @@ 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 * as Notifications from 'expo-notifications';
import { useEffect, useState } from 'react';
import 'react-native-reanimated';
import { useColorScheme } from '@/components/useColorScheme';
import { initI18n } from '@/src/i18n';
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,

View File

@@ -1,8 +1,9 @@
import { useEffect } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useRouter } from 'expo-router';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getOnboardingCompleted, getConsentAccepted } from '@/src/storage/appStorage';
import { getOnboardingCompleted, getConsentAccepted, setOnboardingCompleted, setConsentAccepted } from '@/src/storage/appStorage';
/**
* 启动分发:根据 consent 和 onboarding 状态跳转
@@ -13,8 +14,9 @@ export default function Index() {
useEffect(() => {
let cancelled = false;
(async () => {
// 临时重置引导状态(开发调试用
await setOnboardingCompleted(false);
// 【完全重置】:清除本地存储的所有数据(收藏、设置、引导状态
await AsyncStorage.clear();
console.log('AsyncStorage has been cleared.');
// 1. 检查是否同意协议
const consentAccepted = await getConsentAccepted();