feat(badges): 添加勋章系统和展示功能

实现完整的勋章系统,包括勋章列表展示、自动弹窗展示和分享功能。

- 新增勋章列表页面,支持已获得和待解锁勋章的分类展示
- 在个人中心添加勋章预览模块,显示前3个勋章和总数统计
- 实现勋章展示弹窗,支持动画效果和玻璃态UI
- 添加勋章分享功能,可生成分享卡片
- 新增 badgesSlice 管理勋章状态,包括获取、排序和计数逻辑
- 添加勋章服务 API 封装,支持获取勋章列表和标记已展示
- 完善中英文国际化文案
This commit is contained in:
richarjiang
2025-11-14 17:17:17 +08:00
parent 8cffbb990a
commit 705d921c14
10 changed files with 1125 additions and 0 deletions

View File

@@ -1,10 +1,14 @@
import ActivityHeatMap from '@/components/ActivityHeatMap';
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { ROUTES } from '@/constants/Routes';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import type { BadgeDto } from '@/services/badges';
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
import { fetchAvailableBadges, selectBadgeCounts, selectBadgePreview, selectSortedBadges } from '@/store/badgesSlice';
import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
import { getItem, setItem } from '@/utils/kvStore';
@@ -105,6 +109,10 @@ export default function PersonalScreen() {
openMembershipModal();
}, [ensureLoggedIn, openMembershipModal]);
const handleBadgesPress = useCallback(() => {
router.push(ROUTES.BADGES);
}, [router]);
// 计算底部间距
const bottomPadding = useMemo(() => {
return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
@@ -113,6 +121,48 @@ export default function PersonalScreen() {
// 直接使用 Redux 中的用户信息,避免重复状态管理
const userProfile = useAppSelector((state) => state.user.profile);
const activeMembershipPlanName = useAppSelector(selectActiveMembershipPlanName);
const badgePreview = useAppSelector(selectBadgePreview);
const badgeCounts = useAppSelector(selectBadgeCounts);
const sortedBadges = useAppSelector(selectSortedBadges);
const [showcaseBadge, setShowcaseBadge] = useState<BadgeDto | null>(null);
const autoShownBadgeCodes = useRef<Set<string>>(new Set());
const openBadgeShowcase = useCallback((badge: BadgeDto) => {
autoShownBadgeCodes.current.add(badge.code);
setShowcaseBadge(badge);
}, []);
useEffect(() => {
if (showcaseBadge) {
return;
}
const nextBadgeToShow = sortedBadges.find(
(badge) => badge.isAwarded && badge.isShow === false && !autoShownBadgeCodes.current.has(badge.code)
);
if (nextBadgeToShow) {
openBadgeShowcase(nextBadgeToShow);
}
}, [openBadgeShowcase, showcaseBadge, sortedBadges]);
const handleBadgeShowcaseClose = useCallback(async () => {
if (!showcaseBadge) {
return;
}
const badgeCode = showcaseBadge.code;
setShowcaseBadge(null);
try {
await reportBadgeShowcaseDisplayed(badgeCode);
} catch (error) {
log.warn('report-badge-showcase-failed', error);
}
}, [showcaseBadge]);
console.log('badgePreview', badgePreview);
// 页面聚焦时获取最新用户信息
@@ -120,6 +170,7 @@ export default function PersonalScreen() {
React.useCallback(() => {
dispatch(fetchMyProfile());
dispatch(fetchActivityHistory());
dispatch(fetchAvailableBadges());
// 不再需要在这里加载推送偏好设置,因为已移到通知设置页面
// 加载开发者模式状态
loadDeveloperModeState();
@@ -367,6 +418,64 @@ export default function PersonalScreen() {
</View>
);
const BadgesPreviewSection = () => {
const previewBadges = badgePreview.slice(0, 3);
const hasBadges = previewBadges.length > 0;
const extraCount = Math.max(0, badgeCounts.total - previewBadges.length);
return (
<View style={styles.sectionContainer}>
<TouchableOpacity style={[styles.cardContainer, styles.badgesRowCard]} onPress={handleBadgesPress} activeOpacity={0.85}>
<Text style={styles.badgesRowTitle}>{t('personal.badgesPreview.title')}</Text>
{hasBadges ? (
<View style={styles.badgesRowContent}>
<View style={styles.badgesStack}>
{previewBadges.map((badge, index) => (
<View
key={badge.code}
style={[
styles.badgeCompactBubble,
badge.isAwarded ? styles.badgeCompactBubbleEarned : styles.badgeCompactBubbleLocked,
{
marginLeft: index === 0 ? 0 : -12,
zIndex: previewBadges.length - index,
},
]}
>
{badge.imageUrl ? (
<Image
source={{ uri: badge.imageUrl }}
style={styles.badgeCompactImage}
contentFit="cover"
transition={200}
/>
) : (
<View style={styles.badgeCompactFallback}>
<Text style={styles.badgeCompactFallbackText}>{badge.icon ?? '🏅'}</Text>
</View>
)}
{!badge.isAwarded && (
<View style={styles.badgeCompactOverlay}>
<Ionicons name="lock-closed" as any size={16} color="#FFFFFF" />
</View>
)}
</View>
))}
</View>
{extraCount > 0 && (
<View style={[styles.badgeCompactBubble, styles.badgeExtraBubble, styles.badgeExtraBubbleInline]}>
<Text style={styles.badgeExtraText}>+{extraCount}</Text>
</View>
)}
</View>
) : (
<Text style={styles.badgesRowEmpty}>{t('personal.badgesPreview.empty')}</Text>
)}
</TouchableOpacity>
</View>
);
};
// 菜单项组件
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
<View style={styles.sectionContainer}>
@@ -590,6 +699,7 @@ export default function PersonalScreen() {
<UserHeader />
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
<StatsSection />
<BadgesPreviewSection />
<View style={styles.fishRecordContainer}>
{/* <Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-profile-fish.png' }}
@@ -605,6 +715,12 @@ export default function PersonalScreen() {
<MenuSection key={index} title={section.title} items={section.items} />
))}
</ScrollView>
<BadgeShowcaseModal
badge={showcaseBadge}
onClose={handleBadgeShowcaseClose}
username={displayName}
appName="Out Live"
/>
<LanguageSelectorModal />
</View>
);
@@ -871,6 +987,87 @@ const styles = StyleSheet.create({
color: '#6C757D',
fontWeight: '500',
},
badgesRowCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 16,
borderRadius: 16,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
},
badgesRowTitle: {
fontSize: 16,
fontWeight: '700',
color: '#111827',
},
badgesRowContent: {
flexDirection: 'row',
alignItems: 'center',
},
badgesStack: {
flexDirection: 'row',
alignItems: 'center',
},
badgeCompactBubble: {
width: 32,
height: 32,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 8,
overflow: 'hidden',
position: 'relative',
},
badgeCompactBubbleEarned: {
backgroundColor: 'rgba(16, 185, 129, 0.18)',
},
badgeCompactBubbleLocked: {
backgroundColor: 'rgba(226, 232, 240, 0.9)',
},
badgeCompactImage: {
width: '100%',
height: '100%',
},
badgeCompactFallback: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
badgeCompactFallbackText: {
fontSize: 18,
fontWeight: '600',
color: '#475467',
},
badgeCompactOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(15, 23, 42, 0.45)',
alignItems: 'center',
justifyContent: 'center',
},
badgeExtraBubble: {
backgroundColor: 'rgba(124, 58, 237, 0.12)',
borderWidth: 1,
borderColor: 'rgba(124, 58, 237, 0.35)',
},
badgeExtraBubbleInline: {
marginLeft: 12,
},
badgeExtraText: {
fontSize: 14,
fontWeight: '700',
color: '#5B21B6',
},
badgesRowEmpty: {
fontSize: 13,
color: '#6B7280',
fontWeight: '500',
},
// 菜单项
menuItem: {
flexDirection: 'row',

View File

@@ -440,6 +440,7 @@ export default function RootLayout() {
name="health-data-permissions"
options={{ headerShown: false }}
/>
<Stack.Screen name="badges/index" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="dark" />

242
app/badges/index.tsx Normal file
View File

@@ -0,0 +1,242 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import type { BadgeDto } from '@/services/badges';
import { fetchAvailableBadges, selectBadgesLoading, selectSortedBadges } from '@/store/badgesSlice';
import { DEFAULT_MEMBER_NAME, selectUserProfile } from '@/store/userSlice';
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function BadgesScreen() {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const badges = useAppSelector(selectSortedBadges);
const loading = useAppSelector(selectBadgesLoading);
const userProfile = useAppSelector(selectUserProfile);
const [refreshing, setRefreshing] = useState(false);
const [showcaseBadge, setShowcaseBadge] = useState<BadgeDto | null>(null);
useFocusEffect(
useCallback(() => {
dispatch(fetchAvailableBadges());
}, [dispatch])
);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
await dispatch(fetchAvailableBadges()).unwrap();
} catch (error: any) {
const message = typeof error === 'string' ? error : error?.message ?? 'Failed to refresh badges';
Toast.error(message);
} finally {
setRefreshing(false);
}
}, [dispatch]);
const gridData = useMemo(() => badges, [badges]);
const handleBadgePress = useCallback(async (badge: BadgeDto) => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
} catch {
// Best-effort haptics; ignore if unavailable
}
setShowcaseBadge(badge);
}, []);
const renderBadgeTile = ({ item }: { item: BadgeDto }) => {
const isAwarded = item.isAwarded;
return (
<Pressable
style={({ pressed }) => [styles.badgeTile, pressed && styles.badgeTilePressed]}
onPress={() => handleBadgePress(item)}
accessibilityRole="button"
>
<View style={[styles.badgeImageContainer, isAwarded ? styles.badgeImageEarned : styles.badgeImageLocked]}>
{item.imageUrl ? (
<Image source={{ uri: item.imageUrl }} style={styles.badgeImage} contentFit="cover" transition={200} />
) : (
<View style={styles.badgeImageFallback}>
<Text style={styles.badgeImageFallbackText}>{item.icon ?? '🏅'}</Text>
</View>
)}
{!isAwarded && (
<View style={styles.badgeOverlay}>
<Ionicons name="lock-closed" size={16} color="#FFFFFF" />
</View>
)}
</View>
<Text style={styles.badgeTitle} numberOfLines={1}>{item.name}</Text>
<Text style={styles.badgeDescription} numberOfLines={2}>{item.description}</Text>
<Text style={[styles.badgeStatus, isAwarded ? styles.badgeStatusEarned : styles.badgeStatusLocked]}>
{isAwarded
? t('badges.status.earned')
: t('badges.status.locked')}
</Text>
</Pressable>
);
};
const headerOffset = insets.top + 64;
return (
<View style={styles.container}>
<HeaderBar
title={t('badges.title')}
/>
<FlatList
data={gridData}
keyExtractor={(item) => item.code}
numColumns={3}
contentContainerStyle={[
styles.listContent,
{ paddingTop: headerOffset, paddingBottom: insets.bottom + 24 },
]}
columnWrapperStyle={styles.columnWrapper}
renderItem={renderBadgeTile}
ListHeaderComponent={null}
ListEmptyComponent={
<View style={styles.emptyState}>
<Text style={styles.emptyStateTitle}>{t('badges.empty.title')}</Text>
<Text style={styles.emptyStateDescription}>{t('badges.empty.description')}</Text>
</View>
}
refreshControl={
<RefreshControl refreshing={refreshing || loading} onRefresh={handleRefresh} tintColor="#7C3AED" />
}
showsVerticalScrollIndicator={false}
/>
<BadgeShowcaseModal
badge={showcaseBadge}
onClose={() => setShowcaseBadge(null)}
username={userProfile?.name && userProfile.name.trim() ? userProfile.name : DEFAULT_MEMBER_NAME}
appName="Out Live"
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#ffffff',
},
listContent: {
paddingHorizontal: 16,
minHeight: '100%',
backgroundColor: '#ffffff'
},
columnWrapper: {
justifyContent: 'space-between',
marginBottom: 16,
},
badgeTile: {
flex: 1,
marginHorizontal: 4,
padding: 12,
alignItems: 'center',
borderRadius: 16,
backgroundColor: '#FFFFFF',
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.06,
shadowRadius: 12,
elevation: 2,
},
badgeTilePressed: {
transform: [{ scale: 0.97 }],
shadowOpacity: 0.02,
},
badgeImageContainer: {
width: 88,
height: 88,
borderRadius: 28,
overflow: 'hidden',
marginBottom: 10,
position: 'relative',
},
badgeImageEarned: {
borderWidth: 1.5,
borderColor: 'rgba(16,185,129,0.6)',
},
badgeImageLocked: {
borderWidth: 1.5,
borderColor: 'rgba(148,163,184,0.5)',
},
badgeImage: {
width: '100%',
height: '100%',
},
badgeOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(15,23,42,0.45)',
alignItems: 'center',
justifyContent: 'center',
},
badgeImageFallback: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F3F4F6',
},
badgeImageFallbackText: {
fontSize: 36,
},
badgeTitle: {
fontSize: 14,
fontWeight: '700',
color: '#111827',
},
badgeDescription: {
fontSize: 12,
color: '#6B7280',
textAlign: 'center',
marginTop: 4,
},
badgeStatus: {
fontSize: 11,
fontWeight: '600',
marginTop: 6,
},
badgeStatusEarned: {
color: '#0F766E',
},
badgeStatusLocked: {
color: '#9CA3AF',
},
emptyState: {
alignItems: 'center',
padding: 32,
borderRadius: 24,
backgroundColor: '#FFFFFF',
marginTop: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 3,
},
emptyStateTitle: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
},
emptyStateDescription: {
fontSize: 14,
color: '#475467',
textAlign: 'center',
marginTop: 8,
lineHeight: 20,
},
});

View File

@@ -1084,3 +1084,4 @@ const styles = StyleSheet.create({
fontWeight: '600',
},
});