385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
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,
|
|
getThemeMode,
|
|
getUserProfile,
|
|
setReaction,
|
|
setThemeMode,
|
|
type ThemeMode,
|
|
} from '@/src/storage/appStorage';
|
|
|
|
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<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 item = useMemo(() => MOCK_CONTENT[index % MOCK_CONTENT.length], [index]);
|
|
|
|
// 动画相关 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;
|
|
};
|
|
}, [])
|
|
);
|
|
|
|
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: () => (
|
|
<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, 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);
|
|
|
|
// 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, 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({
|
|
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 (
|
|
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
|
|
{themeMode === 'scenery' && (
|
|
<ImageBackground
|
|
source={currentNatureImage}
|
|
style={StyleSheet.absoluteFill}
|
|
resizeMode="cover"
|
|
/>
|
|
)}
|
|
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
|
|
<Text style={[styles.text, themeMode === 'scenery' && styles.sceneryText]}>{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} style={{ color: '#EA6969' }} />
|
|
) : (
|
|
<LikeIcon
|
|
width={35}
|
|
height={36}
|
|
style={{ 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={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: {
|
|
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',
|
|
},
|
|
});
|