import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react'; import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } 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, getRecoFeedCache, getRecoFeedHistory, getThemeMode, getUserProfile, getUserProfileScoring, recordRecoFeedServed, recordRecoFeedTouched, setRecoFeedCache, setReaction, setThemeMode, type RecoFeedCacheItem, type ThemeMode, } from '@/src/storage/appStorage'; import { fetchRecoFeed } from '@/src/services/recoApi'; 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'); // 预定义风景图列表 const NATURE_IMAGES = [ require('@/assets/theme/nature/1.png'), require('@/assets/theme/nature/2.png'), require('@/assets/theme/nature/3.png'), require('@/assets/theme/nature/4.png'), require('@/assets/theme/nature/5.png'), require('@/assets/theme/nature/6.png'), require('@/assets/theme/nature/7.png'), require('@/assets/theme/nature/8.png'), require('@/assets/theme/nature/9.png'), require('@/assets/theme/nature/10.png'), require('@/assets/theme/nature/11.png'), require('@/assets/theme/nature/12.png'), require('@/assets/theme/nature/13.png'), require('@/assets/theme/nature/14.png'), require('@/assets/theme/nature/15.png'), require('@/assets/theme/nature/17.png'), require('@/assets/theme/nature/18.png'), require('@/assets/theme/nature/19.png'), require('@/assets/theme/nature/20.png'), require('@/assets/theme/nature/22.png'), ]; // 预定义颜色列表 const THEME_COLORS = [ '#F7D9BF', '#CBF2D8', '#F5CDDE', '#F2ECCB', '#E2CBF2', '#CBD9F2', ]; export default function HomeScreen() { const { t } = useTranslation(); const navigation = useNavigation(); const [index, setIndex] = useState(0); const [themeMode, setThemeModeState] = useState('scenery'); const [themeOpen, setThemeOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false); const [profileName, setProfileName] = useState(undefined); const [busy, setBusy] = useState(false); const [likeFilled, setLikeFilled] = useState(false); const [feedItems, setFeedItems] = useState>([]); const currentList = feedItems.length > 0 ? feedItems : MOCK_CONTENT; const item = useMemo(() => currentList[index % currentList.length], [currentList, index]); const currentContentId = typeof (item as any)?.content_id === 'number' ? Number((item as any).content_id) : null; // 动画相关 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; }; }, []) ); // 首次进入:先读缓存,再拉后端 feed(失败则保持 mock/缓存) useEffect(() => { let cancelled = false; (async () => { const cache = await getRecoFeedCache(); if (!cancelled && cache?.items?.length) { setFeedItems(cache.items.map((x: RecoFeedCacheItem) => ({ content_id: x.content_id, text: x.text }))); } const scoring = await getUserProfileScoring(); if (!scoring) return; try { const hist = await getRecoFeedHistory(); const out = await fetchRecoFeed({ k: 30, user_profile: { profile_version: scoring.profile_version, profile_source: scoring.profile_source, profile_generated_at: scoring.profile_generated_at, profile_confidence: scoring.profile_confidence, profile_answered: scoring.profile_answered, stage: scoring.stage, emotion_score: scoring.emotion_score, context: scoring.context, need: scoring.need, }, already_recommended_ids: hist.already_recommended_ids, touched_or_viewed_ids: hist.touched_or_viewed_ids, }); if (!cancelled && out.items?.length) { setFeedItems(out.items.map((x) => ({ content_id: x.content_id, text: x.text }))); await setRecoFeedCache({ saved_at: new Date().toISOString(), items: out.items.map((x) => ({ content_id: x.content_id, text: x.text })), meta: out.meta as Record, }); await recordRecoFeedServed(out.items.map((x) => x.content_id)); } } catch { // 忽略:保持缓存/本地 mock } })(); return () => { cancelled = true; }; }, []); const backgroundColor = useMemo(() => { if (themeMode === 'color') { const colorIndex = Math.floor(index / 10) % THEME_COLORS.length; return THEME_COLORS[colorIndex]; } return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示) }, [themeMode, index]); // 计算当前应该显示的风景图索引(滑动 10 次切换一张) const natureImageIndex = useMemo(() => { return Math.floor(index / 10) % NATURE_IMAGES.length; }, [index]); const currentNatureImage = NATURE_IMAGES[natureImageIndex]; useLayoutEffect(() => { navigation.setOptions({ headerShadowVisible: false, headerStyle: { backgroundColor: themeMode === 'scenery' ? 'transparent' : backgroundColor }, headerTransparent: themeMode === 'scenery', headerRight: () => ( setThemeOpen(true)} accessibilityLabel={t('home.theme')} > setProfileOpen(true)} accessibilityLabel={t('home.profile')} > ), }); }, [backgroundColor, themeMode, 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); // 记录“看过/划过”的内容 id(用于下一次向后端请求时去重/频控) if (typeof currentContentId === 'number') { void recordRecoFeedTouched(currentContentId); } // 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, currentContentId, index, translateY, opacity]); const lastTapRef = useRef(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({ favId: String(Date.now()), // 生成唯一 ID id: item.id, date: dateStr, themeMode: themeMode, background: themeMode === 'scenery' ? String(natureImageIndex) : 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 ( {themeMode === 'scenery' && ( )} {item.text} {likeFilled ? ( ) : ( )} setThemeOpen(false)} /> setProfileOpen(false)} /> ); } function CircleIconButton({ onPress, accessibilityLabel, children, }: { onPress: () => void; accessibilityLabel: string; children: React.ReactNode; }) { return ( {children} ); } 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: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 30, zIndex: 5, // 降低层级,防止遮挡底部按钮 }, text: { fontSize: 22, lineHeight: 32, color: '#5E2A28', fontWeight: '700', textAlign: 'center', }, sceneryCard: { // 风景模式下稍微收窄文案宽度,增加呼吸感 paddingHorizontal: 50, }, sceneryText: { color: '#FFFFFF', textShadowColor: 'rgba(0, 0, 0, 0.5)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 4, }, actions: { position: 'absolute', bottom: SCREEN_HEIGHT * 0.16, left: 0, right: 0, flexDirection: 'row', justifyContent: 'center', zIndex: 20, // 提升层级,确保在最顶层可点击 }, reactionButton: { alignItems: 'center', justifyContent: 'center', }, reactionInner: { alignItems: 'center', justifyContent: 'center', }, });