Files
digital-pilates/components/badges/BadgeShowcaseModal.tsx
richarjiang 705d921c14 feat(badges): 添加勋章系统和展示功能
实现完整的勋章系统,包括勋章列表展示、自动弹窗展示和分享功能。

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

435 lines
13 KiB
TypeScript

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,
},
});