feat(badges): 添加勋章系统和展示功能
实现完整的勋章系统,包括勋章列表展示、自动弹窗展示和分享功能。 - 新增勋章列表页面,支持已获得和待解锁勋章的分类展示 - 在个人中心添加勋章预览模块,显示前3个勋章和总数统计 - 实现勋章展示弹窗,支持动画效果和玻璃态UI - 添加勋章分享功能,可生成分享卡片 - 新增 badgesSlice 管理勋章状态,包括获取、排序和计数逻辑 - 添加勋章服务 API 封装,支持获取勋章列表和标记已展示 - 完善中英文国际化文案
This commit is contained in:
434
components/badges/BadgeShowcaseModal.tsx
Normal file
434
components/badges/BadgeShowcaseModal.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Animated, Modal, Platform, Pressable, Share, StyleSheet, Text, View } from 'react-native';
|
||||
import { captureRef } from 'react-native-view-shot';
|
||||
|
||||
import type { BadgeDto } from '@/services/badges';
|
||||
|
||||
export type BadgeShowcaseModalProps = {
|
||||
badge: BadgeDto | null;
|
||||
onClose: () => void;
|
||||
username: string;
|
||||
appName: string;
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
export const BadgeShowcaseModal = ({ badge, onClose, username, appName, visible }: BadgeShowcaseModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isVisible = visible ?? Boolean(badge);
|
||||
const scaleAnim = useRef(new Animated.Value(0.8)).current;
|
||||
const glowAnim = useRef(new Animated.Value(0)).current;
|
||||
const pulseAnim = useRef<Animated.CompositeAnimation | null>(null);
|
||||
const shareCardRef = useRef<View>(null);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
scaleAnim.setValue(0.8);
|
||||
glowAnim.setValue(0);
|
||||
pulseAnim.current?.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
stiffness: 220,
|
||||
damping: 18,
|
||||
mass: 0.8,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
pulseAnim.current = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(glowAnim, {
|
||||
toValue: 1,
|
||||
duration: 1600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(glowAnim, {
|
||||
toValue: 0,
|
||||
duration: 1400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
pulseAnim.current.start();
|
||||
|
||||
return () => {
|
||||
pulseAnim.current?.stop();
|
||||
};
|
||||
}, [glowAnim, isVisible, scaleAnim]);
|
||||
|
||||
const glowScale = glowAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [1, 1.25],
|
||||
});
|
||||
const glowOpacity = glowAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.45, 0.9],
|
||||
});
|
||||
|
||||
const handleShareBadge = useCallback(async () => {
|
||||
if (!badge?.isAwarded || !shareCardRef.current) {
|
||||
return;
|
||||
}
|
||||
setSharing(true);
|
||||
try {
|
||||
Toast.show({ type: 'info', text1: t('badges.share.generating', '正在生成分享卡片…') });
|
||||
const uri = await captureRef(shareCardRef, {
|
||||
format: 'png',
|
||||
quality: 0.95,
|
||||
});
|
||||
const shareMessage = t('badges.share.message', {
|
||||
badgeName: badge.name,
|
||||
defaultValue: `我刚刚在 Digital Pilates 解锁了「${badge.name}」勋章!`,
|
||||
});
|
||||
await Share.share({
|
||||
title: badge.name,
|
||||
message: shareMessage,
|
||||
url: Platform.OS === 'ios' ? uri : `file://${uri}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('badge-share-failed', error);
|
||||
Toast.error(t('badges.share.failed', '分享失败,请稍后再试'));
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
}, [badge, t]);
|
||||
|
||||
if (!badge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="fade" visible={isVisible} onRequestClose={onClose} statusBarTranslucent>
|
||||
<View style={styles.modalBackdrop}>
|
||||
<BlurView intensity={50} tint="dark" style={StyleSheet.absoluteFill}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
|
||||
</BlurView>
|
||||
<Animated.View
|
||||
style={[styles.showcaseCard, { transform: [{ scale: scaleAnim }] }]}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={badge.name}
|
||||
accessibilityHint={badge.description}
|
||||
>
|
||||
<BadgeShowcaseContent
|
||||
badge={badge}
|
||||
glowOpacity={glowOpacity}
|
||||
glowScale={glowScale}
|
||||
username={username}
|
||||
appName={appName}
|
||||
statusText={badge.isAwarded ? t('badges.status.earned') : t('badges.status.locked')}
|
||||
/>
|
||||
{badge.isAwarded ? (
|
||||
isLiquidGlassAvailable() ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.shareCta, pressed && styles.shareCtaPressed]}
|
||||
onPress={handleShareBadge}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<GlassView glassEffectStyle="regular" tintColor="rgba(255, 255, 255, 1)" isInteractive>
|
||||
<View style={[styles.shareCtaContent, styles.shareCtaGlassInner]}>
|
||||
<Ionicons name="share-social" size={18} color="#0F172A" />
|
||||
<Text style={styles.shareCtaLabel}>
|
||||
{sharing ? t('badges.share.processing', '生成中…') : t('badges.share.cta', '分享')}
|
||||
</Text>
|
||||
</View>
|
||||
</GlassView>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.shareCta, pressed && styles.shareCtaPressed]}
|
||||
onPress={handleShareBadge}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#FCD34D', '#F97316']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[styles.shareCtaGradient, styles.shareCtaContent]}
|
||||
>
|
||||
<Ionicons name="share-social" size={18} color="#1C1917" />
|
||||
<Text style={styles.shareCtaLabel}>
|
||||
{sharing ? t('badges.share.processing', '生成中…') : t('badges.share.cta', '分享')}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
)
|
||||
) : null}
|
||||
<Pressable style={styles.closeButton} onPress={onClose} accessibilityRole="button">
|
||||
<Ionicons name="close" size={20} color="#0F172A" />
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
<View style={styles.shareCardOffscreen}>
|
||||
<View ref={shareCardRef} collapsable={false}>
|
||||
<BadgeShowcaseContent
|
||||
badge={badge}
|
||||
glowOpacity={glowOpacity}
|
||||
glowScale={glowScale}
|
||||
username={username}
|
||||
appName={appName}
|
||||
statusText={badge.isAwarded ? t('badges.status.earned') : t('badges.status.locked')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
type BadgeShowcaseContentProps = {
|
||||
badge: BadgeDto;
|
||||
glowOpacity: Animated.AnimatedInterpolation<string | number>;
|
||||
glowScale: Animated.AnimatedInterpolation<string | number>;
|
||||
username: string;
|
||||
appName: string;
|
||||
statusText: string;
|
||||
};
|
||||
|
||||
const BadgeShowcaseContent = ({ badge, glowOpacity, glowScale, username, appName, statusText }: BadgeShowcaseContentProps) => {
|
||||
const awardDate = badge.earnedAt ?? badge.awardedAt;
|
||||
const formattedAwardDate = badge.isAwarded && awardDate ? dayjs(awardDate).format('YYYY-MM-DD') : null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<LinearGradient
|
||||
colors={badge.isAwarded ? ['#34D399', '#059669'] : ['#6366F1', '#312E81']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.showcaseGradient}
|
||||
>
|
||||
<View style={styles.showcaseGlowWrapper}>
|
||||
<Animated.View pointerEvents="none" style={[styles.glowRing, { opacity: glowOpacity, transform: [{ scale: glowScale }] }]} />
|
||||
<View style={styles.showcaseImageShell}>
|
||||
{badge.imageUrl ? (
|
||||
<Image source={{ uri: badge.imageUrl }} style={styles.showcaseImage} contentFit="contain" />
|
||||
) : (
|
||||
<View style={styles.showcaseImageFallback}>
|
||||
<Text style={styles.showcaseImageFallbackText}>{badge.icon ?? '🏅'}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
<View style={styles.showcaseTextBlock}>
|
||||
<View style={styles.showcaseMetaRow}>
|
||||
<Text style={styles.showcaseUsername}>@{username}</Text>
|
||||
<View style={styles.showcaseAppPill}>
|
||||
<Ionicons name="planet" size={12} color="#0F172A" />
|
||||
<Text style={styles.showcaseAppText}>{appName}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.showcaseTitle}>{badge.name}</Text>
|
||||
<Text style={styles.showcaseDescription}>{badge.description}</Text>
|
||||
<View style={styles.statusRow}>
|
||||
<View style={[styles.statusPill, badge.isAwarded ? styles.statusPillEarned : styles.statusPillLocked]}>
|
||||
<Ionicons name={badge.isAwarded ? 'sparkles' : 'lock-closed'} size={14} color={badge.isAwarded ? '#064E3B' : '#1E1B4B'} />
|
||||
<Text style={[styles.statusPillText, badge.isAwarded ? styles.statusPillTextEarned : styles.statusPillTextLocked]}>
|
||||
{statusText}
|
||||
</Text>
|
||||
</View>
|
||||
{formattedAwardDate ? (
|
||||
<Text style={styles.awardDateLabel}>{formattedAwardDate}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalBackdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(3,7,18,0.75)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
showcaseCard: {
|
||||
width: '90%',
|
||||
maxWidth: 360,
|
||||
borderRadius: 28,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#F8FAFC',
|
||||
paddingBottom: 24,
|
||||
},
|
||||
showcaseGradient: {
|
||||
height: 280,
|
||||
borderBottomLeftRadius: 32,
|
||||
borderBottomRightRadius: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
showcaseGlowWrapper: {
|
||||
width: 220,
|
||||
height: 220,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
glowRing: {
|
||||
position: 'absolute',
|
||||
width: 240,
|
||||
height: 240,
|
||||
borderRadius: 120,
|
||||
backgroundColor: 'rgba(255,255,255,0.35)',
|
||||
shadowColor: '#FFFFFF',
|
||||
shadowOpacity: 0.9,
|
||||
shadowRadius: 20,
|
||||
},
|
||||
showcaseImageShell: {
|
||||
width: 180,
|
||||
height: 180,
|
||||
borderRadius: 90,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(255,255,255,0.45)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
showcaseImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
showcaseImageFallback: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||
},
|
||||
showcaseImageFallbackText: {
|
||||
fontSize: 74,
|
||||
},
|
||||
showcaseTextBlock: {
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 24,
|
||||
},
|
||||
showcaseMetaRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
showcaseUsername: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#0F172A',
|
||||
},
|
||||
showcaseAppPill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
columnGap: 4,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: '#FDE68A',
|
||||
},
|
||||
showcaseAppText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#0F172A',
|
||||
},
|
||||
showcaseTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
textAlign: 'center',
|
||||
},
|
||||
showcaseDescription: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: '#475467',
|
||||
textAlign: 'center',
|
||||
},
|
||||
statusPill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
columnGap: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 999,
|
||||
},
|
||||
statusPillEarned: {
|
||||
backgroundColor: 'rgba(16,185,129,0.18)',
|
||||
},
|
||||
statusPillLocked: {
|
||||
backgroundColor: 'rgba(129,140,248,0.18)',
|
||||
},
|
||||
statusPillText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
statusPillTextEarned: {
|
||||
color: '#047857',
|
||||
},
|
||||
statusPillTextLocked: {
|
||||
color: '#312E81',
|
||||
},
|
||||
statusRow: {
|
||||
marginTop: 18,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
columnGap: 12,
|
||||
},
|
||||
awardDateLabel: {
|
||||
fontSize: 13,
|
||||
color: '#475467',
|
||||
fontWeight: '600',
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: 18,
|
||||
right: 18,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
shareCta: {
|
||||
marginTop: 16,
|
||||
marginHorizontal: 24,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
shareCtaGradient: {
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 999,
|
||||
},
|
||||
shareCtaContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
columnGap: 10,
|
||||
},
|
||||
shareCtaLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#1C1917',
|
||||
},
|
||||
shareCtaPressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
shareCtaGlassInner: {
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
shareCardOffscreen: {
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
top: -10000,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user