实现完整的勋章系统,包括勋章列表展示、自动弹窗展示和分享功能。 - 新增勋章列表页面,支持已获得和待解锁勋章的分类展示 - 在个人中心添加勋章预览模块,显示前3个勋章和总数统计 - 实现勋章展示弹窗,支持动画效果和玻璃态UI - 添加勋章分享功能,可生成分享卡片 - 新增 badgesSlice 管理勋章状态,包括获取、排序和计数逻辑 - 添加勋章服务 API 封装,支持获取勋章列表和标记已展示 - 完善中英文国际化文案
243 lines
6.9 KiB
TypeScript
243 lines
6.9 KiB
TypeScript
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,
|
|
},
|
|
});
|