import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react'; import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground, Platform, } from 'react-native'; import { useTranslation } from 'react-i18next'; import { useFocusEffect } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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, getRecoFeedCache, setRecoFeedCache, getUserProfileScoring, getRecoFeedHistory, recordRecoFeedServed, type ThemeMode, getSuixinThemeState, setSuixinThemeState, type SuixinThemeStateV1, } from '@/src/storage/appStorage'; import { fetchRecoFeed } from '@/src/services/recoApi'; import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale'; 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'; import { getBootId } from '@/src/utils/bootSession'; import { advanceSuixinState, buildInitialSuixinState, NEUTRAL_THEME_COLORS } from '@/src/features/suixinTheme'; import { wrapText } from '@/src/features/textWrap'; import { defaultMeasureWidthImpl } from '@/src/features/textWrap/measure'; 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', ]; type FeedItem = { content_id: string; text: string }; export default function HomeScreen() { const { t, i18n } = useTranslation(); const isEnglish = i18n.language?.startsWith('en'); const recoLang: 'en' | 'tc' = toBackendLocaleFromLanguageTag(i18n.language); const insets = useSafeAreaInsets(); const [index, setIndex] = useState(0); const [themeMode, setThemeModeState] = useState('scenery'); const [suixinBgColor, setSuixinBgColor] = useState(NEUTRAL_THEME_COLORS[1]); 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 [isFetching, setIsFetching] = useState(false); const [cardWidth, setCardWidth] = useState(null); const [wrappedText, setWrappedText] = useState(''); const wrapLogRef = useRef<{ key: string } | null>(null); // 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题: // 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行 const feedItemsRef = useRef([]); const isFetchingRef = useRef(false); const themeModeRef = useRef('scenery'); const suixinStateRef = useRef(null); useEffect(() => { feedItemsRef.current = feedItems; }, [feedItems]); useEffect(() => { isFetchingRef.current = isFetching; }, [isFetching]); useEffect(() => { themeModeRef.current = themeMode; }, [themeMode]); const ensureSuixinReady = useCallback(async () => { const bootId = getBootId(); const stored = await getSuixinThemeState(); if (stored && stored.boot_id === bootId) { suixinStateRef.current = stored; setSuixinBgColor(stored.last_color || NEUTRAL_THEME_COLORS[1]); return stored; } const profile = await getUserProfileScoring(); const next = buildInitialSuixinState({ bootId, profile }); suixinStateRef.current = next; setSuixinBgColor(next.last_color || NEUTRAL_THEME_COLORS[1]); await setSuixinThemeState(next); return next; }, []); const advanceSuixinOnNextContent = useCallback(async () => { if (themeModeRef.current !== 'suixin') return; const bootId = getBootId(); let current = suixinStateRef.current; if (!current) { current = await getSuixinThemeState(); } // 冷启动后首次触发/或状态丢失:先初始化 if (!current || current.boot_id !== bootId) { await ensureSuixinReady(); return; } const next = advanceSuixinState(current); suixinStateRef.current = next; setSuixinBgColor(next.last_color || NEUTRAL_THEME_COLORS[1]); await setSuixinThemeState(next); }, [ensureSuixinReady]); // 动画相关 Shared Values const translateY = useSharedValue(0); const opacity = useSharedValue(1); const likeScale = useSharedValue(1); // 统一文案对象结构 const currentFeed = useMemo(() => { if (feedItems.length > 0) { return feedItems; } return MOCK_CONTENT.map(item => ({ content_id: item.id, text: t(item.textKey) })); }, [feedItems, t]); const item = useMemo(() => { const data = currentFeed[index % currentFeed.length]; return { id: String(data.content_id), text: data.text }; }, [currentFeed, index]); // Home 文案:使用自主换行算法(Text Wrap 模块) // - 通过 onLayout 获取容器宽度 // - 注入真实测量实现,确保“宽度派”评分与实际渲染一致 useEffect(() => { let cancelled = false; // 未拿到宽度前先用原文(避免闪烁) if (!cardWidth || cardWidth <= 0) { if (__DEV__) { const key = `noWidth|${item.id}|${String(cardWidth)}`; if (wrapLogRef.current?.key !== key) { wrapLogRef.current = { key }; console.log('[TextWrap][Home] cardWidth 未就绪,先回退原文', { itemId: item.id, lang: recoLang, cardWidth, themeMode, }); } } setWrappedText(item.text); return () => { cancelled = true; }; } const paddingHorizontal = themeMode === 'scenery' ? 50 : 30; const availableWidth = Math.max(0, Math.floor(cardWidth - paddingHorizontal * 2)); const lang = recoLang === 'en' ? 'EN' : 'TC'; const fontFamily = lang === 'EN' ? 'STIXTwoText' : Platform.select({ ios: 'System', android: 'sans-serif', default: 'System', }); const fontSpec = { fontSize: 22, fontWeight: lang === 'EN' ? '600' : '700', fontFamily: String(fontFamily ?? 'System'), }; (async () => { try { if (__DEV__) { const key = `start|${item.id}|${lang}|${availableWidth}|${themeMode}`; if (wrapLogRef.current?.key !== key) { wrapLogRef.current = { key }; console.log('[TextWrap][Home] wrapText 开始', { itemId: item.id, lang, themeMode, cardWidth, paddingHorizontal, availableWidth, fontSpec, textPreview: String(item.text ?? '').slice(0, 80), textLength: String(item.text ?? '').length, }); } } const res = await wrapText({ text: item.text, lang, context: 'APP', availableWidth, maxLines: 3, overflowMode: 'CLIP', lineMode: 'AUTO', configVersion: 'v1', debug: __DEV__, fontSpec, contextProfile: `APP|${Platform.OS}|home|${lang}`, measureWidthImpl: defaultMeasureWidthImpl, }); if (cancelled) return; if (__DEV__) { console.log('[TextWrap][Home] wrapText 成功', { itemId: item.id, wrappedText: res.wrappedText, linesCount: res.lines.length, meta: res.meta, }); } setWrappedText(res.wrappedText); } catch (error) { // 任何异常都回退到原文,避免影响 Home 主流程 if (__DEV__) { console.log('[TextWrap][Home] wrapText 异常,回退原文', { itemId: item.id, lang, availableWidth, fontSpec, errorName: (error as any)?.name, errorMessage: String((error as any)?.message ?? error), errorStack: (error as any)?.stack, }); } if (cancelled) return; setWrappedText(item.text); } })(); return () => { cancelled = true; }; }, [item.text, cardWidth, recoLang, themeMode]); // 异步拉取新文案 const fetchNewFeed = useCallback(async () => { if (isFetchingRef.current) return; isFetchingRef.current = true; setIsFetching(true); try { const scoringProfile = await getUserProfileScoring(); if (!scoringProfile) return; const history = await getRecoFeedHistory(); const { items, meta } = await fetchRecoFeed({ k: 30, user_profile: scoringProfile, already_recommended_ids: history.already_recommended_ids, touched_or_viewed_ids: history.touched_or_viewed_ids, }); if (items.length > 0) { const wasEmpty = feedItemsRef.current.length === 0; const newCache = { saved_at: new Date().toISOString(), lang: recoLang, items: items.map((x) => ({ content_id: x.content_id, text: x.text })), meta: meta as Record, }; await setRecoFeedCache(newCache); await recordRecoFeedServed(items.map((x) => x.content_id)); setFeedItems(newCache.items.map((x) => ({ content_id: String(x.content_id), text: x.text }))); // 如果当前是 mock 数据,切换到新数据的第一条 if (wasEmpty) { setIndex(0); } } } catch (error) { console.error('Failed to fetch new feed:', error); } finally { isFetchingRef.current = false; setIsFetching(false); } }, [recoLang]); // 每次进入页面或页面获得焦点时刷新个人信息和缓存文案 useFocusEffect( useCallback(() => { let cancelled = false; (async () => { const mode = await getThemeMode(); const profile = await getUserProfile(); const cache = await getRecoFeedCache(); if (cancelled) return; setThemeModeState(mode); setProfileName(profile.name); // 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算) if (mode === 'suixin') { ensureSuixinReady().catch(() => { // ignore:失败时回退默认中性底色 setSuixinBgColor(NEUTRAL_THEME_COLORS[1]); }); } // 语言切换时:旧语言缓存不复用,触发重新拉取 if (cache && cache.items.length > 0 && (cache.lang ?? 'en') === recoLang) { setFeedItems(cache.items.map((x) => ({ content_id: String(x.content_id), text: x.text }))); } else { // 语言不匹配或没有缓存:先清空回落到本地 mock(会立即随语言切换),再拉取对应语言的推荐文案 setFeedItems([]); setIndex(0); fetchNewFeed(); } })(); return () => { cancelled = true; }; }, [fetchNewFeed, recoLang, ensureSuixinReady]) ); const backgroundColor = useMemo(() => { if (themeMode === 'suixin') { return suixinBgColor; } if (themeMode === 'color') { const colorIndex = Math.floor(index / 10) % THEME_COLORS.length; return THEME_COLORS[colorIndex]; } return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示) }, [themeMode, suixinBgColor, index]); // 计算当前应该显示的风景图索引(滑动 10 次切换一张) const natureImageIndex = useMemo(() => { return Math.floor(index / 10) % NATURE_IMAGES.length; }, [index]); const currentNatureImage = NATURE_IMAGES[natureImageIndex]; 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); runOnJS(advanceSuixinOnNextContent)(); // 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条) if (index + 5 >= currentFeed.length && !isFetching) { runOnJS(fetchNewFeed)(); } // 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, currentFeed.length, isFetching, fetchNewFeed, 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. 保存到收藏夹,包含当前背景信息 const favItem = { favId: String(Date.now()), // 生成唯一 ID id: item.id, text: item.text, date: dateStr, themeMode: themeMode, background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor, }; console.log('Home: Triggering addFavorite', JSON.stringify(favItem)); await addFavorite(favItem); // 3. 记录到后端 Reaction(喜欢) console.log('Home: Triggering setReaction', item.id); await setReaction(item.id, 'like'); // 4. 爱心缩放动画 likeScale.value = withSequence( withTiming(0.8, { duration: 100 }), withTiming(1.2, { duration: 150 }), withTiming(1, { duration: 100 }, (finished) => { if (finished) { console.log('Home: Like animation finished, triggering next content'); runOnJS(triggerNextContent)(); } }) ); } async function onSelectTheme(next: ThemeMode) { setThemeModeState(next); await setThemeMode(next); setThemeOpen(false); // 切换到随心:不主动重算(除非冷启动会话变化/状态不存在),仅确保可用 if (next === 'suixin') { await ensureSuixinReady().catch(() => { setSuixinBgColor(NEUTRAL_THEME_COLORS[1]); }); } } return ( {themeMode === 'scenery' && ( )} {/* 自绘顶部按钮:不使用系统 Header,彻底避免 iOS 导航栏的毛玻璃/液玻璃材质 */} setThemeOpen(true)} accessibilityLabel={t('home.theme')} > setProfileOpen(true)} accessibilityLabel={t('home.profile')} > { const w = e.nativeEvent.layout.width; if (Number.isFinite(w) && w > 0) setCardWidth(w); }} > {wrappedText || 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', }, topRight: { position: 'absolute', right: 20, flexDirection: 'row', gap: 10, zIndex: 30, }, 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', }, textEnglish: { fontFamily: 'STIXTwoText', // 英文字体观感更细一点,避免过粗 fontWeight: '600', }, 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', }, });