feat(badges): 添加勋章系统和展示功能
实现完整的勋章系统,包括勋章列表展示、自动弹窗展示和分享功能。 - 新增勋章列表页面,支持已获得和待解锁勋章的分类展示 - 在个人中心添加勋章预览模块,显示前3个勋章和总数统计 - 实现勋章展示弹窗,支持动画效果和玻璃态UI - 添加勋章分享功能,可生成分享卡片 - 新增 badgesSlice 管理勋章状态,包括获取、排序和计数逻辑 - 添加勋章服务 API 封装,支持获取勋章列表和标记已展示 - 完善中英文国际化文案
This commit is contained in:
@@ -1,10 +1,14 @@
|
|||||||
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
||||||
|
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
|
||||||
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
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 { selectActiveMembershipPlanName } from '@/store/membershipSlice';
|
||||||
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
||||||
import { getItem, setItem } from '@/utils/kvStore';
|
import { getItem, setItem } from '@/utils/kvStore';
|
||||||
@@ -105,6 +109,10 @@ export default function PersonalScreen() {
|
|||||||
openMembershipModal();
|
openMembershipModal();
|
||||||
}, [ensureLoggedIn, openMembershipModal]);
|
}, [ensureLoggedIn, openMembershipModal]);
|
||||||
|
|
||||||
|
const handleBadgesPress = useCallback(() => {
|
||||||
|
router.push(ROUTES.BADGES);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
// 计算底部间距
|
// 计算底部间距
|
||||||
const bottomPadding = useMemo(() => {
|
const bottomPadding = useMemo(() => {
|
||||||
return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
|
return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
|
||||||
@@ -113,6 +121,48 @@ export default function PersonalScreen() {
|
|||||||
// 直接使用 Redux 中的用户信息,避免重复状态管理
|
// 直接使用 Redux 中的用户信息,避免重复状态管理
|
||||||
const userProfile = useAppSelector((state) => state.user.profile);
|
const userProfile = useAppSelector((state) => state.user.profile);
|
||||||
const activeMembershipPlanName = useAppSelector(selectActiveMembershipPlanName);
|
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(() => {
|
React.useCallback(() => {
|
||||||
dispatch(fetchMyProfile());
|
dispatch(fetchMyProfile());
|
||||||
dispatch(fetchActivityHistory());
|
dispatch(fetchActivityHistory());
|
||||||
|
dispatch(fetchAvailableBadges());
|
||||||
// 不再需要在这里加载推送偏好设置,因为已移到通知设置页面
|
// 不再需要在这里加载推送偏好设置,因为已移到通知设置页面
|
||||||
// 加载开发者模式状态
|
// 加载开发者模式状态
|
||||||
loadDeveloperModeState();
|
loadDeveloperModeState();
|
||||||
@@ -367,6 +418,64 @@ export default function PersonalScreen() {
|
|||||||
</View>
|
</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[] }) => (
|
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
|
||||||
<View style={styles.sectionContainer}>
|
<View style={styles.sectionContainer}>
|
||||||
@@ -590,6 +699,7 @@ export default function PersonalScreen() {
|
|||||||
<UserHeader />
|
<UserHeader />
|
||||||
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
|
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
|
||||||
<StatsSection />
|
<StatsSection />
|
||||||
|
<BadgesPreviewSection />
|
||||||
<View style={styles.fishRecordContainer}>
|
<View style={styles.fishRecordContainer}>
|
||||||
{/* <Image
|
{/* <Image
|
||||||
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-profile-fish.png' }}
|
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} />
|
<MenuSection key={index} title={section.title} items={section.items} />
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
<BadgeShowcaseModal
|
||||||
|
badge={showcaseBadge}
|
||||||
|
onClose={handleBadgeShowcaseClose}
|
||||||
|
username={displayName}
|
||||||
|
appName="Out Live"
|
||||||
|
/>
|
||||||
<LanguageSelectorModal />
|
<LanguageSelectorModal />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -871,6 +987,87 @@ const styles = StyleSheet.create({
|
|||||||
color: '#6C757D',
|
color: '#6C757D',
|
||||||
fontWeight: '500',
|
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: {
|
menuItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -440,6 +440,7 @@ export default function RootLayout() {
|
|||||||
name="health-data-permissions"
|
name="health-data-permissions"
|
||||||
options={{ headerShown: false }}
|
options={{ headerShown: false }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen name="badges/index" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="dark" />
|
<StatusBar style="dark" />
|
||||||
|
|||||||
242
app/badges/index.tsx
Normal file
242
app/badges/index.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1084,3 +1084,4 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -65,6 +65,9 @@ export const ROUTES = {
|
|||||||
// 健康权限披露
|
// 健康权限披露
|
||||||
HEALTH_DATA_PERMISSIONS: '/health-data-permissions',
|
HEALTH_DATA_PERMISSIONS: '/health-data-permissions',
|
||||||
|
|
||||||
|
// 勋章
|
||||||
|
BADGES: '/badges',
|
||||||
|
|
||||||
|
|
||||||
// 目标管理路由 (已移至tab中)
|
// 目标管理路由 (已移至tab中)
|
||||||
// GOAL_MANAGEMENT: '/goal-management',
|
// GOAL_MANAGEMENT: '/goal-management',
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ const personalScreenResources = {
|
|||||||
aiUsage: '免费AI次数: {{value}}',
|
aiUsage: '免费AI次数: {{value}}',
|
||||||
aiUsageUnlimited: '无限',
|
aiUsageUnlimited: '无限',
|
||||||
fishRecord: '能量记录',
|
fishRecord: '能量记录',
|
||||||
|
badgesPreview: {
|
||||||
|
title: '我的勋章',
|
||||||
|
subtitle: '记录你的荣耀时刻',
|
||||||
|
cta: '查看全部',
|
||||||
|
loading: '正在同步勋章...',
|
||||||
|
empty: '完成睡眠或挑战任务即可解锁首枚勋章',
|
||||||
|
lockedHint: '坚持训练即可点亮更多勋章',
|
||||||
|
},
|
||||||
stats: {
|
stats: {
|
||||||
height: '身高',
|
height: '身高',
|
||||||
weight: '体重',
|
weight: '体重',
|
||||||
@@ -71,6 +79,84 @@ const personalScreenResources = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const badgesScreenResources = {
|
||||||
|
title: '勋章馆',
|
||||||
|
subtitle: '点亮每一次坚持',
|
||||||
|
hero: {
|
||||||
|
highlight: '保持连续打卡即可解锁更多稀有勋章',
|
||||||
|
earnedLabel: '已获得',
|
||||||
|
totalLabel: '总数',
|
||||||
|
progressLabel: '解锁进度',
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
all: '全部',
|
||||||
|
sleep: '睡眠',
|
||||||
|
exercise: '运动',
|
||||||
|
diet: '饮食',
|
||||||
|
challenge: '挑战',
|
||||||
|
social: '社交',
|
||||||
|
special: '特别',
|
||||||
|
},
|
||||||
|
rarities: {
|
||||||
|
common: '普通',
|
||||||
|
uncommon: '少见',
|
||||||
|
rare: '稀有',
|
||||||
|
epic: '史诗',
|
||||||
|
legendary: '传说',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
earned: '已获得',
|
||||||
|
locked: '待解锁',
|
||||||
|
earnedAt: '{{date}} 获得',
|
||||||
|
},
|
||||||
|
legend: '稀有度说明',
|
||||||
|
filterLabel: '勋章分类',
|
||||||
|
empty: {
|
||||||
|
title: '还没有勋章',
|
||||||
|
description: '完成睡眠、运动、挑战等任务即可点亮你的第一枚勋章。',
|
||||||
|
action: '去探索计划',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const badgesScreenResourcesEn = {
|
||||||
|
title: 'Badge Gallery',
|
||||||
|
subtitle: 'Celebrate every effort',
|
||||||
|
hero: {
|
||||||
|
highlight: 'Keep checking in to unlock rarer badges.',
|
||||||
|
earnedLabel: 'Earned',
|
||||||
|
totalLabel: 'Total',
|
||||||
|
progressLabel: 'Progress',
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
all: 'All',
|
||||||
|
sleep: 'Sleep',
|
||||||
|
exercise: 'Exercise',
|
||||||
|
diet: 'Nutrition',
|
||||||
|
challenge: 'Challenge',
|
||||||
|
social: 'Social',
|
||||||
|
special: 'Special',
|
||||||
|
},
|
||||||
|
rarities: {
|
||||||
|
common: 'Common',
|
||||||
|
uncommon: 'Uncommon',
|
||||||
|
rare: 'Rare',
|
||||||
|
epic: 'Epic',
|
||||||
|
legendary: 'Legendary',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
earned: 'Unlocked',
|
||||||
|
locked: 'Locked',
|
||||||
|
earnedAt: 'Unlocked on {{date}}',
|
||||||
|
},
|
||||||
|
legend: 'Rarity legend',
|
||||||
|
filterLabel: 'Badge categories',
|
||||||
|
empty: {
|
||||||
|
title: 'No badges yet',
|
||||||
|
description: 'Complete sleep, workout, or challenge tasks to earn your first badge.',
|
||||||
|
action: 'Explore plans',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const editProfileResources = {
|
const editProfileResources = {
|
||||||
title: '编辑资料',
|
title: '编辑资料',
|
||||||
fields: {
|
fields: {
|
||||||
@@ -748,6 +834,7 @@ const resources = {
|
|||||||
zh: {
|
zh: {
|
||||||
translation: {
|
translation: {
|
||||||
personal: personalScreenResources,
|
personal: personalScreenResources,
|
||||||
|
badges: badgesScreenResources,
|
||||||
editProfile: editProfileResources,
|
editProfile: editProfileResources,
|
||||||
healthPermissions: healthPermissionsResources,
|
healthPermissions: healthPermissionsResources,
|
||||||
statistics: statisticsResources,
|
statistics: statisticsResources,
|
||||||
@@ -764,6 +851,14 @@ const resources = {
|
|||||||
aiUsage: 'Free AI credits: {{value}}',
|
aiUsage: 'Free AI credits: {{value}}',
|
||||||
aiUsageUnlimited: 'Unlimited',
|
aiUsageUnlimited: 'Unlimited',
|
||||||
fishRecord: 'Energy log',
|
fishRecord: 'Energy log',
|
||||||
|
badgesPreview: {
|
||||||
|
title: 'My badges',
|
||||||
|
subtitle: 'Celebrate every milestone',
|
||||||
|
cta: 'View all',
|
||||||
|
loading: 'Syncing your badges…',
|
||||||
|
empty: 'Complete sleep or challenge tasks to unlock your first badge.',
|
||||||
|
lockedHint: 'Keep building the habit to unlock more.',
|
||||||
|
},
|
||||||
stats: {
|
stats: {
|
||||||
height: 'Height',
|
height: 'Height',
|
||||||
weight: 'Weight',
|
weight: 'Weight',
|
||||||
@@ -817,6 +912,7 @@ const resources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
badges: badgesScreenResourcesEn,
|
||||||
editProfile: {
|
editProfile: {
|
||||||
title: 'Edit Profile',
|
title: 'Edit Profile',
|
||||||
fields: {
|
fields: {
|
||||||
|
|||||||
29
services/badges.ts
Normal file
29
services/badges.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
export type BadgeCategory = 'sleep' | 'exercise' | 'diet' | 'challenge' | 'social' | 'special';
|
||||||
|
|
||||||
|
export type BadgeRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||||
|
|
||||||
|
export interface BadgeDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: BadgeCategory;
|
||||||
|
rarity?: BadgeRarity;
|
||||||
|
imageUrl: string;
|
||||||
|
icon?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
earnedAt?: string | null;
|
||||||
|
awardedAt?: string | null;
|
||||||
|
isAwarded: boolean;
|
||||||
|
isShow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAvailableBadges(): Promise<BadgeDto[]> {
|
||||||
|
return api.get<BadgeDto[]>('/api/users/badges/available');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportBadgeShowcaseDisplayed(badgeCode: string): Promise<boolean> {
|
||||||
|
const response = await api.post<{ success: boolean }>('/users/badges/mark-shown', { badgeCode });
|
||||||
|
return Boolean(response?.success);
|
||||||
|
}
|
||||||
120
store/badgesSlice.ts
Normal file
120
store/badgesSlice.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { BadgeDto, BadgeRarity } from '@/services/badges';
|
||||||
|
import { getAvailableBadges } from '@/services/badges';
|
||||||
|
import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import type { RootState } from './index';
|
||||||
|
|
||||||
|
export interface BadgesState {
|
||||||
|
items: BadgeDto[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastFetched: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: BadgesState = {
|
||||||
|
items: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastFetched: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchAvailableBadges = createAsyncThunk<BadgeDto[], void, { rejectValue: string }>(
|
||||||
|
'badges/fetchAvailable',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
return await getAvailableBadges();
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error?.message ?? '获取勋章列表失败';
|
||||||
|
return rejectWithValue(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: (_, { getState }) => {
|
||||||
|
const state = getState() as RootState;
|
||||||
|
const { loading } = state.badges ?? {};
|
||||||
|
return !loading;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const badgesSlice = createSlice({
|
||||||
|
name: 'badges',
|
||||||
|
initialState,
|
||||||
|
reducers: {},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchAvailableBadges.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchAvailableBadges.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.items = action.payload;
|
||||||
|
|
||||||
|
state.lastFetched = new Date().toISOString();
|
||||||
|
})
|
||||||
|
.addCase(fetchAvailableBadges.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = typeof action.payload === 'string' ? action.payload : action.error.message ?? '加载勋章失败';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default badgesSlice.reducer;
|
||||||
|
|
||||||
|
const selectBadgesSlice = (state: RootState) => state.badges;
|
||||||
|
export const selectBadgesState = selectBadgesSlice;
|
||||||
|
export const selectBadgesLoading = (state: RootState) => state.badges.loading;
|
||||||
|
export const selectBadgesError = (state: RootState) => state.badges.error;
|
||||||
|
export const selectBadges = (state: RootState) => state.badges.items;
|
||||||
|
|
||||||
|
const rarityPriority: Record<BadgeRarity, number> = {
|
||||||
|
common: 0,
|
||||||
|
uncommon: 1,
|
||||||
|
rare: 2,
|
||||||
|
epic: 3,
|
||||||
|
legendary: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const compareBadges = (a: BadgeDto, b: BadgeDto) => {
|
||||||
|
if (a.isAwarded !== b.isAwarded) {
|
||||||
|
return a.isAwarded ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOrderA = a.sortOrder ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const sortOrderB = b.sortOrder ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
if (sortOrderA !== sortOrderB) {
|
||||||
|
return sortOrderA - sortOrderB;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.isAwarded && b.isAwarded) {
|
||||||
|
const earnedDiff = (b.earnedAt ? dayjs(b.earnedAt).valueOf() : 0) - (a.earnedAt ? dayjs(a.earnedAt).valueOf() : 0);
|
||||||
|
if (earnedDiff !== 0) {
|
||||||
|
return earnedDiff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rarityKeyA = a.rarity ?? 'common';
|
||||||
|
const rarityKeyB = b.rarity ?? 'common';
|
||||||
|
const rarityDiff = rarityPriority[rarityKeyB] - rarityPriority[rarityKeyA];
|
||||||
|
if (rarityDiff !== 0) {
|
||||||
|
return rarityDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectSortedBadges = createSelector([selectBadges], (items) => {
|
||||||
|
return [...items].sort(compareBadges);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const selectBadgeCounts = createSelector([selectBadges], (items) => {
|
||||||
|
const earned = items.filter((badge) => badge.isAwarded).length;
|
||||||
|
return {
|
||||||
|
earned,
|
||||||
|
total: items.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const selectBadgePreview = createSelector([selectSortedBadges], (items) => items.slice(0, 3));
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { persistActiveFastingSchedule } from '@/utils/fasting';
|
import { persistActiveFastingSchedule } from '@/utils/fasting';
|
||||||
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||||
|
import badgesReducer from './badgesSlice';
|
||||||
import challengesReducer from './challengesSlice';
|
import challengesReducer from './challengesSlice';
|
||||||
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
||||||
import circumferenceReducer from './circumferenceSlice';
|
import circumferenceReducer from './circumferenceSlice';
|
||||||
@@ -111,6 +112,7 @@ export const store = configureStore({
|
|||||||
water: waterReducer,
|
water: waterReducer,
|
||||||
fasting: fastingReducer,
|
fasting: fastingReducer,
|
||||||
medications: medicationsReducer,
|
medications: medicationsReducer,
|
||||||
|
badges: badgesReducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
||||||
|
|||||||
Reference in New Issue
Block a user