Files
mindfulness/client/app/(app)/home.tsx
2026-03-12 18:02:25 +08:00

904 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
},
});