Files
mindfulness/client/app/(app)/home.tsx

308 lines
8.8 KiB
TypeScript

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, useFocusEffect } from 'expo-router';
import Animated, {
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSequence,
withTiming,
} from 'react-native-reanimated';
import { MOCK_CONTENT } from '@/src/constants/mockContent';
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 LikeFilledIcon from '@/assets/images/home/like_filled.svg';
import LikeIcon from '@/assets/images/icon/like_icon.svg';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
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]);
// 动画相关 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';
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 textAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
opacity: opacity.value,
}));
const likeAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: likeScale.value }],
}));
// 切换到下一条文案的统一动画逻辑
const triggerNextContent = useCallback(() => {
if (busy) return;
setBusy(true);
// 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.8, { duration: 100 }),
withTiming(1.2, { duration: 150 }),
withTiming(1, { duration: 100 }, (finished) => {
if (finished) {
runOnJS(triggerNextContent)();
}
})
);
}
async function onSelectTheme(next: ThemeMode) {
setThemeModeState(next);
await setThemeMode(next);
setThemeOpen(false);
}
return (
<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, likeAnimatedStyle]}>
<Pressable
onPress={onPressLike}
accessibilityRole="button"
accessibilityLabel={t('home.like')}
hitSlop={20}
style={styles.reactionInner}
>
{likeFilled ? (
<LikeFilledIcon width={35} height={36} />
) : (
<LikeIcon width={35} height={36} />
)}
</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,
padding: 20,
justifyContent: 'center',
alignItems: 'center',
},
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: {
paddingHorizontal: 30,
alignItems: 'center',
justifyContent: 'center',
},
text: {
fontSize: 22,
lineHeight: 32,
color: '#5E2A28',
fontWeight: '700',
textAlign: 'center',
},
actions: {
position: 'absolute',
bottom: SCREEN_HEIGHT * 0.16,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
},
reactionButton: {
alignItems: 'center',
justifyContent: 'center',
},
reactionInner: {
alignItems: 'center',
justifyContent: 'center',
},
});