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