From d74bd214edec1e3e6a7b8b63f6f8967f02475414 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Mon, 29 Sep 2025 15:39:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(challenges):=20=E7=99=BB=E5=BD=95=E6=80=81?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=E4=B8=8E=E8=BF=9B=E5=BA=A6=E6=9D=A1=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 _layout 中仅当已登录时才拉取挑战列表,避免未授权请求 - 挑战详情页加入 ensureLoggedIn 守卫,未登录时跳转登录 - ChallengeProgressCard 新增分段进度动画,提升视觉反馈 - 升级版本号至 1.0.15 --- app.json | 2 +- app/_layout.tsx | 15 +++- app/challenges/[id].tsx | 22 ++++-- .../challenges/ChallengeProgressCard.tsx | 74 +++++++++++++++++-- ios/OutLive/Info.plist | 2 +- 5 files changed, 98 insertions(+), 17 deletions(-) diff --git a/app.json b/app.json index 563a72b..754cf57 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Out Live", "slug": "digital-pilates", - "version": "1.0.14", + "version": "1.0.15", "orientation": "portrait", "scheme": "digitalpilates", "userInterfaceStyle": "light", diff --git a/app/_layout.tsx b/app/_layout.tsx index 73cd97c..93ec2d3 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -14,18 +14,19 @@ import { setupQuickActions } from '@/services/quickActions'; import { initializeWaterRecordBridge } from '@/services/waterRecordBridge'; import { WaterRecordSource } from '@/services/waterRecords'; import { store } from '@/store'; -import { fetchChallenges } from '@/store/challengesSlice'; import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice'; import { createWaterRecordAction } from '@/store/waterSlice'; import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health'; import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers'; import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync'; -import React from 'react'; +import React, { useEffect } from 'react'; import { DialogProvider } from '@/components/ui/DialogProvider'; import { ToastProvider } from '@/contexts/ToastContext'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; import { STORAGE_KEYS } from '@/services/api'; import { BackgroundTaskManager } from '@/services/backgroundTaskManager'; +import { fetchChallenges } from '@/store/challengesSlice'; import AsyncStorage from '@/utils/kvStore'; import { Provider } from 'react-redux'; @@ -34,10 +35,17 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { const dispatch = useAppDispatch(); const { profile } = useAppSelector((state) => state.user); const [showPrivacyModal, setShowPrivacyModal] = React.useState(false); + const { isLoggedIn } = useAuthGuard() // 初始化快捷动作处理 useQuickActions(); + useEffect(() => { + if (isLoggedIn) { + dispatch(fetchChallenges()); + } + }, [isLoggedIn]); + React.useEffect(() => { const loadUserData = async () => { // 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态 @@ -128,7 +136,8 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { loadUserData(); initHealthPermissions(); initializeNotifications(); - dispatch(fetchChallenges()); + + // 冷启动时清空 AI 教练会话缓存 clearAiCoachSessionCache(); diff --git a/app/challenges/[id].tsx b/app/challenges/[id].tsx index 4f83cbf..c679fd7 100644 --- a/app/challenges/[id].tsx +++ b/app/challenges/[id].tsx @@ -2,6 +2,7 @@ import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; import { fetchChallengeDetail, @@ -15,12 +16,12 @@ import { selectJoinStatus, selectLeaveError, selectLeaveStatus, - selectProgressError, - selectProgressStatus, + selectProgressStatus } from '@/store/challengesSlice'; import { Toast } from '@/utils/toast.utils'; import { Ionicons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; +import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { useLocalSearchParams, useRouter } from 'expo-router'; import LottieView from 'lottie-react-native'; @@ -29,7 +30,6 @@ import { ActivityIndicator, Alert, Dimensions, - Image, Platform, ScrollView, Share, @@ -82,6 +82,8 @@ export default function ChallengeDetailScreen() { const colorTokens = Colors[theme]; const insets = useSafeAreaInsets(); + const { ensureLoggedIn } = useAuthGuard(); + const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]); const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined)); @@ -103,8 +105,6 @@ export default function ChallengeDetailScreen() { const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]); const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle')); - const progressErrorSelector = useMemo(() => (id ? selectProgressError(id) : undefined), [id]); - const progressError = useAppSelector((state) => (progressErrorSelector ? progressErrorSelector(state) : undefined)); useEffect(() => { const getData = async (id: string) => { @@ -174,6 +174,13 @@ export default function ChallengeDetailScreen() { if (!id || joinStatus === 'loading') { return; } + + const isLoggedIn = await ensureLoggedIn(); + if (!isLoggedIn) { + // 如果未登录,用户会被重定向到登录页面 + return; + } + try { await dispatch(joinChallenge(id)); setShowCelebration(true) @@ -307,7 +314,7 @@ export default function ChallengeDetailScreen() { ]} > - + 0 && styles.avatarOffset]} + cachePolicy={'memory-disk'} /> ))} {challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? ( @@ -401,7 +409,7 @@ export default function ChallengeDetailScreen() { {index + 1} {item.avatar ? ( - + ) : ( diff --git a/components/challenges/ChallengeProgressCard.tsx b/components/challenges/ChallengeProgressCard.tsx index 5ed2a58..68d69b4 100644 --- a/components/challenges/ChallengeProgressCard.tsx +++ b/components/challenges/ChallengeProgressCard.tsx @@ -1,7 +1,7 @@ import dayjs from 'dayjs'; import { LinearGradient } from 'expo-linear-gradient'; -import React, { useMemo } from 'react'; -import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native'; +import React, { useEffect, useMemo, useRef } from 'react'; +import { Animated, Easing, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native'; import type { ChallengeProgress } from '@/store/challengesSlice'; @@ -60,12 +60,42 @@ export const ChallengeProgressCard: React.FC = ({ inactiveColor = DEFAULT_INACTIVE_COLOR, }) => { const hasValidProgress = Boolean(progress && progress.target && progress.target > 0); + const segmentAnimations = useRef([]); const segments = useMemo(() => { if (!hasValidProgress || !progress) return undefined; return clampSegments(progress.target, progress.completed); }, [hasValidProgress, progress]); + if (segments) { + if (segmentAnimations.current.length < segments.segmentsCount) { + const additional = Array.from( + { length: segments.segmentsCount - segmentAnimations.current.length }, + () => new Animated.Value(0) + ); + segmentAnimations.current = [...segmentAnimations.current, ...additional]; + } else if (segmentAnimations.current.length > segments.segmentsCount) { + segmentAnimations.current = segmentAnimations.current.slice(0, segments.segmentsCount); + } + } else if (segmentAnimations.current.length) { + segmentAnimations.current = []; + } + + useEffect(() => { + if (!segments) return; + + segmentAnimations.current.forEach((animation, index) => { + const isComplete = index < segments.completedSegments; + Animated.timing(animation, { + toValue: isComplete ? 1 : 0, + duration: isComplete ? 460 : 240, + delay: isComplete ? index * 55 : 0, + easing: isComplete ? Easing.out(Easing.cubic) : Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }); + }, [segments?.completedSegments, segments?.segmentsCount]); + const remainingDays = useMemo(() => calculateRemainingDays(endAt), [endAt]); if (!hasValidProgress || !progress || !segments) { @@ -93,19 +123,48 @@ export const ChallengeProgressCard: React.FC = ({ {Array.from({ length: segments.segmentsCount }).map((_, index) => { - const isComplete = index < segments.completedSegments; const isFirst = index === 0; const isLast = index === segments.segmentsCount - 1; + const animation = segmentAnimations.current[index]; + + if (!animation) { + return null; + } + + const scaleY = animation.interpolate({ + inputRange: [0, 1], + outputRange: [0.55, 1], + }); + const scaleX = animation.interpolate({ + inputRange: [0, 1], + outputRange: [0.7, 1], + }); + const opacity = animation.interpolate({ + inputRange: [0, 1], + outputRange: [0.25, 1], + }); + return ( + > + + ); })} @@ -168,6 +227,7 @@ const styles = StyleSheet.create({ height: 4, borderRadius: 4, marginHorizontal: 3, + overflow: 'hidden', }, segmentFirst: { marginLeft: 0, @@ -175,6 +235,10 @@ const styles = StyleSheet.create({ segmentLast: { marginRight: 0, }, + segmentFill: { + flex: 1, + borderRadius: 4, + }, }); export default ChallengeProgressCard; diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index 656bebe..cab4d11 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -25,7 +25,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.14 + 1.0.15 CFBundleSignature ???? CFBundleURLTypes