feature:基本功能

This commit is contained in:
吕新雨
2026-01-29 19:09:36 +08:00
parent c1a433a469
commit 0dd84b1873
47 changed files with 2978 additions and 219 deletions

View File

@@ -1,10 +1,7 @@
import { Stack } from 'expo-router';
import { Pressable, Text } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useRouter } from 'expo-router';
export default function AppLayout() {
const router = useRouter();
const { t } = useTranslation();
return (
@@ -16,26 +13,9 @@ export default function AppLayout() {
<Stack.Screen
name="home"
options={{
title: t('home.title'),
headerRight: () => (
<Pressable
onPress={() => router.push('/(app)/settings')}
hitSlop={10}
>
<Text>{t('home.settings')}</Text>
</Pressable>
),
headerLeft: () => (
<Pressable onPress={() => router.push('/(app)/favorites')} hitSlop={10}>
<Text>{t('home.favorites')}</Text>
</Pressable>
),
}}
/>
<Stack.Screen
name="favorites"
options={{
title: t('favorites.title'),
// 卡片页标题按设计留空(右上角为 icon 按钮)
title: '',
headerShadowVisible: false,
}}
/>
<Stack.Screen

View File

@@ -1,61 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { FlatList, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { MOCK_CONTENT } from '@/src/constants/mockContent';
import { getFavorites } from '@/src/storage/appStorage';
export default function FavoritesScreen() {
const { t } = useTranslation();
const [ids, setIds] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
const list = await getFavorites();
if (!cancelled) setIds(list);
})();
return () => {
cancelled = true;
};
}, []);
const items = useMemo(() => {
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c]));
return ids.map((id) => map.get(id)).filter(Boolean) as { id: string; text: string }[];
}, [ids]);
return (
<View style={styles.container}>
{items.length === 0 ? (
<Text style={styles.empty}>{t('favorites.empty')}</Text>
) : (
<FlatList
data={items}
keyExtractor={(it) => it.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<View style={styles.row}>
<Text style={styles.text}>{item.text}</Text>
</View>
)}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
empty: { color: '#6B7280', fontSize: 16, textAlign: 'center', marginTop: 40 },
list: { gap: 12, paddingBottom: 24 },
row: {
borderRadius: 14,
padding: 16,
backgroundColor: '#F9FAFB',
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#E5E7EB',
},
text: { color: '#111827', fontSize: 16, lineHeight: 22 },
});

View File

@@ -1,45 +1,220 @@
import { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { Pressable } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useNavigation } from 'expo-router';
import Animated, {
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSequence,
withTiming,
} from 'react-native-reanimated';
import { MOCK_CONTENT } from '@/src/constants/mockContent';
import { addFavorite, setReaction } from '@/src/storage/appStorage';
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 LikeOutlineIcon from '@/assets/images/home/like.svg';
import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
import HateIcon from '@/assets/images/home/hate.svg';
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]);
async function onLike() {
await setReaction(item.id, 'like');
await addFavorite(item.id);
setIndex((i) => i + 1);
useEffect(() => {
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 = 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 likeScale = useSharedValue(1);
const hateScale = useSharedValue(1);
const likeAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: likeScale.value }],
}));
const hateAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: hateScale.value }],
}));
function playLikeAnimationAndThen(next: () => void) {
setLikeFilled(true);
likeScale.value = withSequence(
withTiming(0.92, { duration: 90, easing: Easing.out(Easing.cubic) }),
withTiming(1.14, { duration: 120, easing: Easing.out(Easing.cubic) }),
withTiming(1, { duration: 140, easing: Easing.out(Easing.cubic) }, (finished) => {
if (finished) runOnJS(next)();
})
);
}
async function onDislike() {
await setReaction(item.id, 'dislike');
setIndex((i) => i + 1);
function playHateAnimationAndThen(next: () => void) {
hateScale.value = withSequence(
withTiming(0.92, { duration: 90, easing: Easing.out(Easing.cubic) }),
withTiming(1.06, { duration: 110, easing: Easing.out(Easing.cubic) }),
withTiming(1, { duration: 120, easing: Easing.out(Easing.cubic) }, (finished) => {
if (finished) runOnJS(next)();
})
);
}
function onPressLike() {
if (busy) return;
setBusy(true);
playLikeAnimationAndThen(async () => {
await setReaction(item.id, 'like');
await addFavorite(item.id);
setIndex((i) => i + 1);
setTimeout(() => setLikeFilled(false), 220);
setBusy(false);
});
}
function onPressHate() {
if (busy) return;
setBusy(true);
playHateAnimationAndThen(async () => {
await setReaction(item.id, 'dislike');
setIndex((i) => i + 1);
setBusy(false);
});
}
async function onSelectTheme(next: ThemeMode) {
setThemeModeState(next);
await setThemeMode(next);
setThemeOpen(false);
}
return (
<View style={styles.container}>
<View style={[styles.container, { backgroundColor }]}>
<View style={styles.card}>
<Text style={styles.text}>{item.text}</Text>
<Animated.Text style={styles.text}>{item.text}</Animated.Text>
</View>
<View style={styles.actions}>
<Pressable style={[styles.button, styles.dislike]} onPress={onDislike}>
<Text style={styles.buttonText}>{t('home.dislike')}</Text>
</Pressable>
<Pressable style={[styles.button, styles.like]} onPress={onLike}>
<Text style={styles.buttonText}>{t('home.like')}</Text>
</Pressable>
<Animated.View style={[styles.reactionButton, hateAnimatedStyle]}>
<Pressable
onPress={onPressHate}
onPressIn={() => (hateScale.value = withTiming(0.92, { duration: 80 }))}
onPressOut={() => (hateScale.value = withTiming(1, { duration: 120 }))}
accessibilityRole="button"
accessibilityLabel={t('home.dislike')}
hitSlop={10}
style={styles.reactionInner}
>
<HateIcon width={30} height={30} />
</Pressable>
</Animated.View>
<Animated.View style={[styles.reactionButton, likeAnimatedStyle]}>
<Pressable
onPress={onPressLike}
onPressIn={() => (likeScale.value = withTiming(0.92, { duration: 80 }))}
onPressOut={() => (likeScale.value = withTiming(1, { duration: 120 }))}
accessibilityRole="button"
accessibilityLabel={t('home.like')}
hitSlop={10}
style={styles.reactionInner}
>
{likeFilled ? (
<LikeFilledIcon width={30} height={30} />
) : (
<LikeOutlineIcon width={30} height={30} />
)}
</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,
@@ -47,30 +222,51 @@ const styles = StyleSheet.create({
justifyContent: 'center',
gap: 16,
},
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: {
borderRadius: 16,
padding: 20,
backgroundColor: '#F6F7FB',
backgroundColor: 'rgba(255,255,255,0.65)',
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#E5E7EB',
},
text: {
fontSize: 20,
lineHeight: 28,
color: '#111827',
color: '#5E2A28',
fontWeight: '700',
},
actions: {
flexDirection: 'row',
gap: 12,
justifyContent: 'center',
},
button: {
flex: 1,
paddingVertical: 14,
borderRadius: 14,
reactionButton: {
width: 58,
height: 58,
borderRadius: 29,
backgroundColor: 'rgba(255,255,255,0.75)',
alignItems: 'center',
justifyContent: 'center',
},
reactionInner: {
width: 58,
height: 58,
borderRadius: 29,
alignItems: 'center',
justifyContent: 'center',
},
like: { backgroundColor: '#16A34A' },
dislike: { backgroundColor: '#EF4444' },
buttonText: { color: 'white', fontSize: 16, fontWeight: '600' },
});