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