904 lines
29 KiB
TypeScript
904 lines
29 KiB
TypeScript
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||
import {
|
||
StyleSheet,
|
||
View,
|
||
Text,
|
||
Pressable,
|
||
PanResponder,
|
||
AppState,
|
||
Animated as RNAnimated,
|
||
ImageBackground,
|
||
Platform,
|
||
useWindowDimensions,
|
||
} 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,
|
||
clearPendingHomePushMessage,
|
||
getPendingHomePushMessage,
|
||
getRecoFeedCache,
|
||
setRecoFeedCache,
|
||
getUserProfileScoring,
|
||
getRecoFeedHistory,
|
||
recordRecoFeedServed,
|
||
type ThemeMode,
|
||
getSuixinThemeState,
|
||
setSuixinThemeState,
|
||
type SuixinThemeStateV1,
|
||
} from '@/src/storage/appStorage';
|
||
|
||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||
import { subscribeHomePushMessage } from '@/src/services/pushNotificationRoute';
|
||
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';
|
||
import { ensureDailyWidgetRecoUpToDate } from '@/src/modules/dailyWidgetReco';
|
||
|
||
// 预定义风景图列表
|
||
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 { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||
const isEnglish = i18n.language?.startsWith('en');
|
||
const isTablet = Platform.OS === 'ios' && Math.min(windowWidth, windowHeight) >= 768;
|
||
const recoLang: 'en' | 'tc' = toBackendLocaleFromLanguageTag(i18n.language);
|
||
const insets = useSafeAreaInsets();
|
||
const [index, setIndex] = useState(0);
|
||
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
||
const [suixinBgColor, setSuixinBgColor] = useState<string>(NEUTRAL_THEME_COLORS[1]);
|
||
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 [feedItems, setFeedItems] = useState<FeedItem[]>([]);
|
||
const [pendingPushItem, setPendingPushItem] = useState<FeedItem | null>(null);
|
||
const [isFetching, setIsFetching] = useState(false);
|
||
const [cardWidth, setCardWidth] = useState<number | null>(null);
|
||
const [wrappedText, setWrappedText] = useState<string>('');
|
||
const wrapLogRef = useRef<{ key: string } | null>(null);
|
||
const busyRef = useRef(false);
|
||
const indexRef = useRef(0);
|
||
const currentFeedRef = useRef<FeedItem[]>([]);
|
||
const likedIdsRef = useRef<Set<string>>(new Set());
|
||
const likeInFlightRef = useRef(false);
|
||
|
||
const applyPendingPushItem = useCallback(async (message: { notification_id: string; content_id?: number; text: string }) => {
|
||
const nextItem: FeedItem = {
|
||
content_id: message.content_id != null ? String(message.content_id) : `push:${message.notification_id}`,
|
||
text: message.text,
|
||
};
|
||
setPendingPushItem(nextItem);
|
||
indexRef.current = 0;
|
||
setIndex(0);
|
||
setLikeFilled(likedIdsRef.current.has(String(nextItem.content_id)));
|
||
await clearPendingHomePushMessage();
|
||
}, []);
|
||
|
||
const consumePendingPushItem = useCallback(async () => {
|
||
const pendingMessage = await getPendingHomePushMessage();
|
||
if (!pendingMessage?.text) return;
|
||
await applyPendingPushItem(pendingMessage);
|
||
}, [applyPendingPushItem]);
|
||
|
||
useEffect(() => {
|
||
busyRef.current = busy;
|
||
}, [busy]);
|
||
useEffect(() => {
|
||
indexRef.current = index;
|
||
}, [index]);
|
||
|
||
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
|
||
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
||
const feedItemsRef = useRef<FeedItem[]>([]);
|
||
const isFetchingRef = useRef(false);
|
||
const themeModeRef = useRef<ThemeMode>('scenery');
|
||
const suixinStateRef = useRef<SuixinThemeStateV1 | null>(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(() => {
|
||
const baseFeed =
|
||
feedItems.length > 0
|
||
? feedItems
|
||
: MOCK_CONTENT.map(item => ({
|
||
content_id: item.id,
|
||
text: t(item.textKey)
|
||
}));
|
||
|
||
if (!pendingPushItem) {
|
||
return baseFeed;
|
||
}
|
||
|
||
return [
|
||
pendingPushItem,
|
||
...baseFeed.filter((entry) => (
|
||
String(entry.content_id) !== String(pendingPushItem.content_id) && entry.text !== pendingPushItem.text
|
||
)),
|
||
];
|
||
}, [feedItems, pendingPushItem, t]);
|
||
useEffect(() => {
|
||
currentFeedRef.current = currentFeed;
|
||
}, [currentFeed]);
|
||
|
||
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: 24,
|
||
fontWeight: lang === 'EN' ? '700' : '800',
|
||
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',
|
||
// Home:采用“更偏好语气停顿/更好看”的排版风格微调(不影响算法默认 v1)
|
||
configVersion: 'v1-home',
|
||
debug: __DEV__,
|
||
fontSpec,
|
||
contextProfile: `APP|${Platform.OS}|home|${lang}`,
|
||
measureWidthImpl: defaultMeasureWidthImpl,
|
||
scoringOverrides:
|
||
lang === 'TC'
|
||
? {
|
||
// 更偏好在逗号/句号等处断行(即便宽度允许也不一定要塞满)
|
||
weights: { R_PUNCT_BREAK: 180 },
|
||
// 让“理想行宽”更短,避免宽屏下过度延后断行
|
||
idealWidthRatio: { APP: 0.82 },
|
||
// 更宽容短行(尤其是第一行在标点处停顿)
|
||
minPreferredRatio: 0.45,
|
||
shortLastLineRatio: 0.45,
|
||
}
|
||
: undefined,
|
||
});
|
||
|
||
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<string, unknown>,
|
||
};
|
||
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, profile, cache, pendingMessage] = await Promise.all([
|
||
getThemeMode(),
|
||
getUserProfile(),
|
||
getRecoFeedCache(),
|
||
getPendingHomePushMessage(),
|
||
]);
|
||
|
||
if (cancelled) return;
|
||
setThemeModeState(mode);
|
||
setProfileName(profile.name);
|
||
if (pendingMessage?.text) {
|
||
await applyPendingPushItem(pendingMessage);
|
||
if (cancelled) return;
|
||
}
|
||
|
||
// 随心:若当前主题为随心,进入 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();
|
||
}
|
||
|
||
// Widget:前台辅助刷新(尽力而为)
|
||
// - 写入 App Group 的 dailyReco 缓存
|
||
// - 生成 wrapped_text_by_family,供 Widget 直接渲染
|
||
ensureDailyWidgetRecoUpToDate({ reason: 'home_focus' }).catch(() => {});
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [applyPendingPushItem, fetchNewFeed, recoLang, ensureSuixinReady])
|
||
);
|
||
|
||
useEffect(() => {
|
||
const unsubscribe = subscribeHomePushMessage((message) => {
|
||
void applyPendingPushItem(message);
|
||
});
|
||
return unsubscribe;
|
||
}, [applyPendingPushItem]);
|
||
|
||
useEffect(() => {
|
||
const sub = AppState.addEventListener('change', (state) => {
|
||
if (state !== 'active') return;
|
||
void consumePendingPushItem();
|
||
});
|
||
return () => sub.remove();
|
||
}, [consumePendingPushItem]);
|
||
|
||
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 setBusySafe = useCallback((next: boolean) => {
|
||
busyRef.current = next;
|
||
setBusy(next);
|
||
}, []);
|
||
|
||
const setLikeInFlight = useCallback((next: boolean) => {
|
||
likeInFlightRef.current = next;
|
||
}, []);
|
||
|
||
const syncLikeFilledByIndex = useCallback((nextIndex: number) => {
|
||
const list = currentFeedRef.current;
|
||
const len = list.length;
|
||
if (!len) {
|
||
setLikeFilled(false);
|
||
return;
|
||
}
|
||
const safe = ((nextIndex % len) + len) % len;
|
||
const nextId = String(list[safe]?.content_id);
|
||
setLikeFilled(likedIdsRef.current.has(nextId));
|
||
}, []);
|
||
|
||
const applyIndexChange = useCallback((nextIndex: number) => {
|
||
indexRef.current = nextIndex;
|
||
setIndex(nextIndex);
|
||
syncLikeFilledByIndex(nextIndex);
|
||
}, [syncLikeFilledByIndex]);
|
||
|
||
const maybeFetchNewFeedIfNeeded = useCallback((nextIndex: number) => {
|
||
const len = currentFeedRef.current.length;
|
||
if (!len) return;
|
||
// 当接近当前列表末尾时(例如还剩 5 条)提前拉取
|
||
if (nextIndex + 5 >= len && !isFetchingRef.current) {
|
||
fetchNewFeed();
|
||
}
|
||
}, [fetchNewFeed]);
|
||
|
||
// 切换到下一条文案的统一动画逻辑
|
||
const triggerNextContent = useCallback(() => {
|
||
if (busyRef.current) return;
|
||
setBusySafe(true);
|
||
|
||
// 注意:不要在 Reanimated worklet 回调里读取 React ref(例如 indexRef/currentFeedRef),会导致值不更新或异常
|
||
const nextIndex = indexRef.current + 1;
|
||
|
||
// 1. 当前文案向上移动并消失
|
||
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||
if (finished) {
|
||
// 2. 切换数据索引
|
||
runOnJS(applyIndexChange)(nextIndex);
|
||
runOnJS(advanceSuixinOnNextContent)();
|
||
|
||
// 检查是否需要拉取新文案(注意:不要把匿名函数塞进 runOnJS,可能导致原生崩溃)
|
||
runOnJS(maybeFetchNewFeedIfNeeded)(nextIndex);
|
||
|
||
// 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(setBusySafe)(false);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}, [applyIndexChange, setBusySafe, translateY, opacity, advanceSuixinOnNextContent, maybeFetchNewFeedIfNeeded]);
|
||
|
||
// 切换到上一条文案的统一动画逻辑(下滑触发)
|
||
const triggerPrevContent = useCallback(() => {
|
||
if (busyRef.current) return;
|
||
setBusySafe(true);
|
||
|
||
// 注意:同上,不要在 worklet 里读取 React ref
|
||
const len = currentFeedRef.current.length;
|
||
const raw = indexRef.current - 1;
|
||
const nextIndex = len ? ((raw % len) + len) % len : Math.max(0, raw);
|
||
|
||
// 1. 当前文案向下移动并消失
|
||
translateY.value = withTiming(40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||
if (finished) {
|
||
// 2. 切换数据索引(循环回退)
|
||
runOnJS(applyIndexChange)(nextIndex);
|
||
|
||
// 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(setBusySafe)(false);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}, [applyIndexChange, setBusySafe, translateY, opacity]);
|
||
|
||
const lastTapRef = useRef<number>(0);
|
||
|
||
// 使用 Ref 解决 PanResponder 闭包陷阱,确保手势回调能拿到最新的 state 和 function
|
||
const handlersRef = useRef({ onPressLike, triggerNextContent, triggerPrevContent });
|
||
useEffect(() => {
|
||
handlersRef.current = { onPressLike, triggerNextContent, triggerPrevContent };
|
||
}, [onPressLike, triggerNextContent, triggerPrevContent]);
|
||
|
||
// 使用系统自带的 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)();
|
||
} else if (gestureState.dy > 50) { // 下滑超过 50pt
|
||
runOnJS(handlersRef.current.triggerPrevContent)();
|
||
}
|
||
},
|
||
})
|
||
).current;
|
||
|
||
async function onPressLike() {
|
||
if (busyRef.current) return;
|
||
if (likeInFlightRef.current) return;
|
||
|
||
// 已经喜欢过:不重复写入收藏,直接当作“下一条”
|
||
if (likeFilled || likedIdsRef.current.has(item.id)) {
|
||
triggerNextContent();
|
||
return;
|
||
}
|
||
|
||
likeInFlightRef.current = true;
|
||
|
||
const likedItemId = item.id;
|
||
const likedItemText = item.text;
|
||
// 先记下“已喜欢”,保证回退时能恢复点亮状态(即便异步保存稍后才完成)
|
||
likedIdsRef.current.add(likedItemId);
|
||
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: likedItemId,
|
||
text: likedItemText,
|
||
date: dateStr,
|
||
themeMode: themeMode,
|
||
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
|
||
};
|
||
console.log('Home: Triggering addFavorite', JSON.stringify(favItem));
|
||
try {
|
||
await addFavorite(favItem);
|
||
} catch (error) {
|
||
console.error('Home: addFavorite 失败', error);
|
||
}
|
||
|
||
// 3. 记录到后端 Reaction(喜欢)
|
||
console.log('Home: Triggering setReaction', item.id);
|
||
try {
|
||
await setReaction(item.id, 'like');
|
||
} catch (error) {
|
||
console.error('Home: setReaction 失败', error);
|
||
}
|
||
|
||
// 4. 爱心缩放动画
|
||
likeScale.value = withSequence(
|
||
withTiming(0.8, { duration: 100 }),
|
||
withTiming(1.2, { duration: 150 }),
|
||
withTiming(1, { duration: 100 }, (finished) => {
|
||
runOnJS(setLikeInFlight)(false);
|
||
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]);
|
||
});
|
||
}
|
||
}
|
||
|
||
const actionsBottom = isTablet
|
||
? Math.max(insets.bottom + 36, Math.min(windowHeight * 0.12, 140))
|
||
: windowHeight * 0.16;
|
||
|
||
return (
|
||
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
|
||
{themeMode === 'scenery' && (
|
||
<ImageBackground
|
||
source={currentNatureImage}
|
||
style={StyleSheet.absoluteFill}
|
||
resizeMode="cover"
|
||
/>
|
||
)}
|
||
|
||
{/* 自绘顶部按钮:不使用系统 Header,彻底避免 iOS 导航栏的毛玻璃/液玻璃材质 */}
|
||
<View
|
||
style={[
|
||
styles.topRight,
|
||
{
|
||
top: insets.top + (isTablet ? 16 : 8),
|
||
right: isTablet ? 26 : 20,
|
||
},
|
||
]}
|
||
>
|
||
<CircleIconButton
|
||
onPress={() => setThemeOpen(true)}
|
||
accessibilityLabel={t('home.theme')}
|
||
>
|
||
<ThemeIcon width={20} height={20} />
|
||
</CircleIconButton>
|
||
<CircleIconButton
|
||
onPress={() => setProfileOpen(true)}
|
||
accessibilityLabel={t('home.profile')}
|
||
>
|
||
<MyIcon width={20} height={20} />
|
||
</CircleIconButton>
|
||
</View>
|
||
|
||
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
|
||
<View
|
||
style={[styles.textMeasureBox, isTablet && styles.textMeasureBoxTablet]}
|
||
onLayout={(e) => {
|
||
const w = e.nativeEvent.layout.width;
|
||
if (Number.isFinite(w) && w > 0) setCardWidth(w);
|
||
}}
|
||
>
|
||
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
|
||
{wrappedText || item.text}
|
||
</Text>
|
||
</View>
|
||
</Animated.View>
|
||
|
||
<View style={[styles.actions, { bottom: actionsBottom }]}>
|
||
<Animated.View style={[styles.reactionButton, likeAnimatedStyle]}>
|
||
<Pressable
|
||
onPress={onPressLike}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('home.like')}
|
||
// 稍微增大可点击区域,提升单手操作成功率
|
||
hitSlop={24}
|
||
style={styles.reactionInner}
|
||
>
|
||
{likeFilled ? (
|
||
<LikeFilledIcon width={40} height={41} color="#EA6969" />
|
||
) : (
|
||
<LikeIcon
|
||
width={40}
|
||
height={41}
|
||
color={themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28'}
|
||
/>
|
||
)}
|
||
</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={14}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={accessibilityLabel}
|
||
style={styles.circleBtn}
|
||
>
|
||
{children}
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
padding: 20,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
},
|
||
topRight: {
|
||
position: 'absolute',
|
||
flexDirection: 'row',
|
||
gap: 10,
|
||
zIndex: 30,
|
||
},
|
||
circleBtn: {
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
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: 24,
|
||
lineHeight: 34,
|
||
color: '#5E2A28',
|
||
fontWeight: '800',
|
||
textAlign: 'center',
|
||
},
|
||
textMeasureBox: {
|
||
width: '100%',
|
||
alignItems: 'center',
|
||
},
|
||
textMeasureBoxTablet: {
|
||
maxWidth: 760,
|
||
},
|
||
textEnglish: {
|
||
fontFamily: 'STIXTwoText',
|
||
// 英文字体保持较粗但避免过度发黑
|
||
fontWeight: '700',
|
||
},
|
||
sceneryCard: {
|
||
// 风景模式下稍微收窄文案宽度,增加呼吸感
|
||
paddingHorizontal: 50,
|
||
},
|
||
sceneryText: {
|
||
color: '#FFFFFF',
|
||
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
||
textShadowOffset: { width: 0, height: 1 },
|
||
textShadowRadius: 4,
|
||
},
|
||
actions: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
flexDirection: 'row',
|
||
justifyContent: 'center',
|
||
zIndex: 20, // 提升层级,确保在最顶层可点击
|
||
},
|
||
reactionButton: {
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
reactionInner: {
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
});
|