From 705d921c144d738dc133e1adeef028c066730846 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 14 Nov 2025 17:17:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(badges):=20=E6=B7=BB=E5=8A=A0=E5=8B=8B?= =?UTF-8?q?=E7=AB=A0=E7=B3=BB=E7=BB=9F=E5=92=8C=E5=B1=95=E7=A4=BA=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现完整的勋章系统,包括勋章列表展示、自动弹窗展示和分享功能。 - 新增勋章列表页面,支持已获得和待解锁勋章的分类展示 - 在个人中心添加勋章预览模块,显示前3个勋章和总数统计 - 实现勋章展示弹窗,支持动画效果和玻璃态UI - 添加勋章分享功能,可生成分享卡片 - 新增 badgesSlice 管理勋章状态,包括获取、排序和计数逻辑 - 添加勋章服务 API 封装,支持获取勋章列表和标记已展示 - 完善中英文国际化文案 --- app/(tabs)/personal.tsx | 197 ++++++++++ app/_layout.tsx | 1 + app/badges/index.tsx | 242 +++++++++++++ app/challenges/[id]/index.tsx | 1 + components/badges/BadgeShowcaseModal.tsx | 434 +++++++++++++++++++++++ constants/Routes.ts | 3 + i18n/index.ts | 96 +++++ services/badges.ts | 29 ++ store/badgesSlice.ts | 120 +++++++ store/index.ts | 2 + 10 files changed, 1125 insertions(+) create mode 100644 app/badges/index.tsx create mode 100644 components/badges/BadgeShowcaseModal.tsx create mode 100644 services/badges.ts create mode 100644 store/badgesSlice.ts diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index dc8ce77..cb9abcb 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -1,10 +1,14 @@ import ActivityHeatMap from '@/components/ActivityHeatMap'; +import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal'; import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree'; import { ROUTES } from '@/constants/Routes'; import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useMembershipModal } from '@/contexts/MembershipModalContext'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; 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 { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice'; import { getItem, setItem } from '@/utils/kvStore'; @@ -105,6 +109,10 @@ export default function PersonalScreen() { openMembershipModal(); }, [ensureLoggedIn, openMembershipModal]); + const handleBadgesPress = useCallback(() => { + router.push(ROUTES.BADGES); + }, [router]); + // 计算底部间距 const bottomPadding = useMemo(() => { return getTabBarBottomPadding(60) + (insets?.bottom ?? 0); @@ -113,6 +121,48 @@ export default function PersonalScreen() { // 直接使用 Redux 中的用户信息,避免重复状态管理 const userProfile = useAppSelector((state) => state.user.profile); const activeMembershipPlanName = useAppSelector(selectActiveMembershipPlanName); + const badgePreview = useAppSelector(selectBadgePreview); + const badgeCounts = useAppSelector(selectBadgeCounts); + const sortedBadges = useAppSelector(selectSortedBadges); + + const [showcaseBadge, setShowcaseBadge] = useState(null); + const autoShownBadgeCodes = useRef>(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(() => { dispatch(fetchMyProfile()); dispatch(fetchActivityHistory()); + dispatch(fetchAvailableBadges()); // 不再需要在这里加载推送偏好设置,因为已移到通知设置页面 // 加载开发者模式状态 loadDeveloperModeState(); @@ -367,6 +418,64 @@ export default function PersonalScreen() { ); + const BadgesPreviewSection = () => { + const previewBadges = badgePreview.slice(0, 3); + const hasBadges = previewBadges.length > 0; + const extraCount = Math.max(0, badgeCounts.total - previewBadges.length); + + return ( + + + {t('personal.badgesPreview.title')} + {hasBadges ? ( + + + {previewBadges.map((badge, index) => ( + + {badge.imageUrl ? ( + + ) : ( + + {badge.icon ?? '🏅'} + + )} + {!badge.isAwarded && ( + + + + )} + + ))} + + {extraCount > 0 && ( + + +{extraCount} + + )} + + ) : ( + {t('personal.badgesPreview.empty')} + )} + + + ); + }; + // 菜单项组件 const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => ( @@ -590,6 +699,7 @@ export default function PersonalScreen() { {userProfile.isVip ? : } + {/* ))} + ); @@ -871,6 +987,87 @@ const styles = StyleSheet.create({ color: '#6C757D', 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: { flexDirection: 'row', diff --git a/app/_layout.tsx b/app/_layout.tsx index 9a360af..ac25267 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -440,6 +440,7 @@ export default function RootLayout() { name="health-data-permissions" options={{ headerShown: false }} /> + diff --git a/app/badges/index.tsx b/app/badges/index.tsx new file mode 100644 index 0000000..d9ac244 --- /dev/null +++ b/app/badges/index.tsx @@ -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(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 ( + [styles.badgeTile, pressed && styles.badgeTilePressed]} + onPress={() => handleBadgePress(item)} + accessibilityRole="button" + > + + {item.imageUrl ? ( + + ) : ( + + {item.icon ?? '🏅'} + + )} + {!isAwarded && ( + + + + )} + + {item.name} + {item.description} + + {isAwarded + ? t('badges.status.earned') + : t('badges.status.locked')} + + + ); + }; + + const headerOffset = insets.top + 64; + + return ( + + + item.code} + numColumns={3} + contentContainerStyle={[ + styles.listContent, + { paddingTop: headerOffset, paddingBottom: insets.bottom + 24 }, + ]} + columnWrapperStyle={styles.columnWrapper} + renderItem={renderBadgeTile} + ListHeaderComponent={null} + ListEmptyComponent={ + + {t('badges.empty.title')} + {t('badges.empty.description')} + + } + refreshControl={ + + } + showsVerticalScrollIndicator={false} + /> + setShowcaseBadge(null)} + username={userProfile?.name && userProfile.name.trim() ? userProfile.name : DEFAULT_MEMBER_NAME} + appName="Out Live" + /> + + ); +} + +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, + }, +}); diff --git a/app/challenges/[id]/index.tsx b/app/challenges/[id]/index.tsx index acb8097..bb2edd5 100644 --- a/app/challenges/[id]/index.tsx +++ b/app/challenges/[id]/index.tsx @@ -1084,3 +1084,4 @@ const styles = StyleSheet.create({ fontWeight: '600', }, }); + diff --git a/components/badges/BadgeShowcaseModal.tsx b/components/badges/BadgeShowcaseModal.tsx new file mode 100644 index 0000000..ab73d30 --- /dev/null +++ b/components/badges/BadgeShowcaseModal.tsx @@ -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(null); + const shareCardRef = useRef(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 ( + + + + + + + + {badge.isAwarded ? ( + isLiquidGlassAvailable() ? ( + [styles.shareCta, pressed && styles.shareCtaPressed]} + onPress={handleShareBadge} + accessibilityRole="button" + > + + + + + {sharing ? t('badges.share.processing', '生成中…') : t('badges.share.cta', '分享')} + + + + + ) : ( + [styles.shareCta, pressed && styles.shareCtaPressed]} + onPress={handleShareBadge} + accessibilityRole="button" + > + + + + {sharing ? t('badges.share.processing', '生成中…') : t('badges.share.cta', '分享')} + + + + ) + ) : null} + + + + + + + + + + + + ); +}; + +type BadgeShowcaseContentProps = { + badge: BadgeDto; + glowOpacity: Animated.AnimatedInterpolation; + glowScale: Animated.AnimatedInterpolation; + 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 ( + + + + + + {badge.imageUrl ? ( + + ) : ( + + {badge.icon ?? '🏅'} + + )} + + + + + + @{username} + + + {appName} + + + {badge.name} + {badge.description} + + + + + {statusText} + + + {formattedAwardDate ? ( + {formattedAwardDate} + ) : null} + + + + ); +}; + +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, + }, +}); diff --git a/constants/Routes.ts b/constants/Routes.ts index 741e22b..eba78bb 100644 --- a/constants/Routes.ts +++ b/constants/Routes.ts @@ -65,6 +65,9 @@ export const ROUTES = { // 健康权限披露 HEALTH_DATA_PERMISSIONS: '/health-data-permissions', + // 勋章 + BADGES: '/badges', + // 目标管理路由 (已移至tab中) // GOAL_MANAGEMENT: '/goal-management', diff --git a/i18n/index.ts b/i18n/index.ts index e4013c5..aa3b782 100644 --- a/i18n/index.ts +++ b/i18n/index.ts @@ -17,6 +17,14 @@ const personalScreenResources = { aiUsage: '免费AI次数: {{value}}', aiUsageUnlimited: '无限', fishRecord: '能量记录', + badgesPreview: { + title: '我的勋章', + subtitle: '记录你的荣耀时刻', + cta: '查看全部', + loading: '正在同步勋章...', + empty: '完成睡眠或挑战任务即可解锁首枚勋章', + lockedHint: '坚持训练即可点亮更多勋章', + }, stats: { height: '身高', 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 = { title: '编辑资料', fields: { @@ -748,6 +834,7 @@ const resources = { zh: { translation: { personal: personalScreenResources, + badges: badgesScreenResources, editProfile: editProfileResources, healthPermissions: healthPermissionsResources, statistics: statisticsResources, @@ -764,6 +851,14 @@ const resources = { aiUsage: 'Free AI credits: {{value}}', aiUsageUnlimited: 'Unlimited', 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: { height: 'Height', weight: 'Weight', @@ -817,6 +912,7 @@ const resources = { }, }, }, + badges: badgesScreenResourcesEn, editProfile: { title: 'Edit Profile', fields: { diff --git a/services/badges.ts b/services/badges.ts new file mode 100644 index 0000000..3d9648a --- /dev/null +++ b/services/badges.ts @@ -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 { + return api.get('/api/users/badges/available'); +} + +export async function reportBadgeShowcaseDisplayed(badgeCode: string): Promise { + const response = await api.post<{ success: boolean }>('/users/badges/mark-shown', { badgeCode }); + return Boolean(response?.success); +} diff --git a/store/badgesSlice.ts b/store/badgesSlice.ts new file mode 100644 index 0000000..1da6156 --- /dev/null +++ b/store/badgesSlice.ts @@ -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( + '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 = { + 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)); diff --git a/store/index.ts b/store/index.ts index 70ba207..fa1fc56 100644 --- a/store/index.ts +++ b/store/index.ts @@ -1,5 +1,6 @@ import { persistActiveFastingSchedule } from '@/utils/fasting'; import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; +import badgesReducer from './badgesSlice'; import challengesReducer from './challengesSlice'; import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice'; import circumferenceReducer from './circumferenceSlice'; @@ -111,6 +112,7 @@ export const store = configureStore({ water: waterReducer, fasting: fastingReducer, medications: medicationsReducer, + badges: badgesReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(listenerMiddleware.middleware),