373 lines
11 KiB
TypeScript
373 lines
11 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,
|
||
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');
|
||
|
||
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 [feedItems, setFeedItems] = useState<Array<{ content_id: number; text: string }>>([]);
|
||
|
||
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<string, unknown>,
|
||
});
|
||
await recordRecoFeedServed(out.items.map((x) => x.content_id));
|
||
}
|
||
} catch {
|
||
// 忽略:保持缓存/本地 mock
|
||
}
|
||
})();
|
||
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);
|
||
|
||
// 记录“看过/划过”的内容 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<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: typeof currentContentId === 'number' ? String(currentContentId) : (item as any).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',
|
||
},
|
||
});
|