feat(challenges): 登录态守卫与进度条动画优化
- 在 _layout 中仅当已登录时才拉取挑战列表,避免未授权请求 - 挑战详情页加入 ensureLoggedIn 守卫,未登录时跳转登录 - ChallengeProgressCard 新增分段进度动画,提升视觉反馈 - 升级版本号至 1.0.15
This commit is contained in:
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Out Live",
|
"name": "Out Live",
|
||||||
"slug": "digital-pilates",
|
"slug": "digital-pilates",
|
||||||
"version": "1.0.14",
|
"version": "1.0.15",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"scheme": "digitalpilates",
|
"scheme": "digitalpilates",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
|
|||||||
@@ -14,18 +14,19 @@ import { setupQuickActions } from '@/services/quickActions';
|
|||||||
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
||||||
import { WaterRecordSource } from '@/services/waterRecords';
|
import { WaterRecordSource } from '@/services/waterRecords';
|
||||||
import { store } from '@/store';
|
import { store } from '@/store';
|
||||||
import { fetchChallenges } from '@/store/challengesSlice';
|
|
||||||
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
|
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
|
||||||
import { createWaterRecordAction } from '@/store/waterSlice';
|
import { createWaterRecordAction } from '@/store/waterSlice';
|
||||||
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
|
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
|
||||||
import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
|
import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
|
||||||
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
import { DialogProvider } from '@/components/ui/DialogProvider';
|
import { DialogProvider } from '@/components/ui/DialogProvider';
|
||||||
import { ToastProvider } from '@/contexts/ToastContext';
|
import { ToastProvider } from '@/contexts/ToastContext';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { STORAGE_KEYS } from '@/services/api';
|
import { STORAGE_KEYS } from '@/services/api';
|
||||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||||
|
import { fetchChallenges } from '@/store/challengesSlice';
|
||||||
import AsyncStorage from '@/utils/kvStore';
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
@@ -34,10 +35,17 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { profile } = useAppSelector((state) => state.user);
|
const { profile } = useAppSelector((state) => state.user);
|
||||||
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
|
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
|
||||||
|
const { isLoggedIn } = useAuthGuard()
|
||||||
|
|
||||||
// 初始化快捷动作处理
|
// 初始化快捷动作处理
|
||||||
useQuickActions();
|
useQuickActions();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
dispatch(fetchChallenges());
|
||||||
|
}
|
||||||
|
}, [isLoggedIn]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const loadUserData = async () => {
|
const loadUserData = async () => {
|
||||||
// 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态
|
// 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态
|
||||||
@@ -128,7 +136,8 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
loadUserData();
|
loadUserData();
|
||||||
initHealthPermissions();
|
initHealthPermissions();
|
||||||
initializeNotifications();
|
initializeNotifications();
|
||||||
dispatch(fetchChallenges());
|
|
||||||
|
|
||||||
// 冷启动时清空 AI 教练会话缓存
|
// 冷启动时清空 AI 教练会话缓存
|
||||||
clearAiCoachSessionCache();
|
clearAiCoachSessionCache();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import {
|
import {
|
||||||
fetchChallengeDetail,
|
fetchChallengeDetail,
|
||||||
@@ -15,12 +16,12 @@ import {
|
|||||||
selectJoinStatus,
|
selectJoinStatus,
|
||||||
selectLeaveError,
|
selectLeaveError,
|
||||||
selectLeaveStatus,
|
selectLeaveStatus,
|
||||||
selectProgressError,
|
selectProgressStatus
|
||||||
selectProgressStatus,
|
|
||||||
} from '@/store/challengesSlice';
|
} from '@/store/challengesSlice';
|
||||||
import { Toast } from '@/utils/toast.utils';
|
import { Toast } from '@/utils/toast.utils';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import LottieView from 'lottie-react-native';
|
import LottieView from 'lottie-react-native';
|
||||||
@@ -29,7 +30,6 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Image,
|
|
||||||
Platform,
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Share,
|
Share,
|
||||||
@@ -82,6 +82,8 @@ export default function ChallengeDetailScreen() {
|
|||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { ensureLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
|
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
|
||||||
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
|
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 progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]);
|
||||||
const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle'));
|
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(() => {
|
useEffect(() => {
|
||||||
const getData = async (id: string) => {
|
const getData = async (id: string) => {
|
||||||
@@ -174,6 +174,13 @@ export default function ChallengeDetailScreen() {
|
|||||||
if (!id || joinStatus === 'loading') {
|
if (!id || joinStatus === 'loading') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLoggedIn = await ensureLoggedIn();
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
// 如果未登录,用户会被重定向到登录页面
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await dispatch(joinChallenge(id));
|
await dispatch(joinChallenge(id));
|
||||||
setShowCelebration(true)
|
setShowCelebration(true)
|
||||||
@@ -307,7 +314,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.heroContainer}>
|
<View style={styles.heroContainer}>
|
||||||
<Image source={{ uri: challenge.image }} style={styles.heroImage} resizeMode="cover" />
|
<Image source={{ uri: challenge.image }} style={styles.heroImage} cachePolicy={'memory-disk'} />
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['rgba(0,0,0,0.35)', 'rgba(0,0,0,0.15)', 'rgba(244, 246, 255, 1)']}
|
colors={['rgba(0,0,0,0.35)', 'rgba(0,0,0,0.15)', 'rgba(244, 246, 255, 1)']}
|
||||||
style={StyleSheet.absoluteFillObject}
|
style={StyleSheet.absoluteFillObject}
|
||||||
@@ -369,6 +376,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
key={`${avatar}-${index}`}
|
key={`${avatar}-${index}`}
|
||||||
source={{ uri: avatar }}
|
source={{ uri: avatar }}
|
||||||
style={[styles.avatar, index > 0 && styles.avatarOffset]}
|
style={[styles.avatar, index > 0 && styles.avatarOffset]}
|
||||||
|
cachePolicy={'memory-disk'}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? (
|
{challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? (
|
||||||
@@ -401,7 +409,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
<Text style={styles.rankingOrder}>{index + 1}</Text>
|
<Text style={styles.rankingOrder}>{index + 1}</Text>
|
||||||
</View>
|
</View>
|
||||||
{item.avatar ? (
|
{item.avatar ? (
|
||||||
<Image source={{ uri: item.avatar }} style={styles.rankingAvatar} />
|
<Image source={{ uri: item.avatar }} style={styles.rankingAvatar} cachePolicy={'memory-disk'} />
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.rankingAvatarPlaceholder}>
|
<View style={styles.rankingAvatarPlaceholder}>
|
||||||
<Ionicons name="person-outline" size={20} color="#6f7ba7" />
|
<Ionicons name="person-outline" size={20} color="#6f7ba7" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useEffect, useMemo, useRef } from 'react';
|
||||||
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
import { Animated, Easing, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
||||||
|
|
||||||
import type { ChallengeProgress } from '@/store/challengesSlice';
|
import type { ChallengeProgress } from '@/store/challengesSlice';
|
||||||
|
|
||||||
@@ -60,12 +60,42 @@ export const ChallengeProgressCard: React.FC<ChallengeProgressCardProps> = ({
|
|||||||
inactiveColor = DEFAULT_INACTIVE_COLOR,
|
inactiveColor = DEFAULT_INACTIVE_COLOR,
|
||||||
}) => {
|
}) => {
|
||||||
const hasValidProgress = Boolean(progress && progress.target && progress.target > 0);
|
const hasValidProgress = Boolean(progress && progress.target && progress.target > 0);
|
||||||
|
const segmentAnimations = useRef<Animated.Value[]>([]);
|
||||||
|
|
||||||
const segments = useMemo(() => {
|
const segments = useMemo(() => {
|
||||||
if (!hasValidProgress || !progress) return undefined;
|
if (!hasValidProgress || !progress) return undefined;
|
||||||
return clampSegments(progress.target, progress.completed);
|
return clampSegments(progress.target, progress.completed);
|
||||||
}, [hasValidProgress, progress]);
|
}, [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]);
|
const remainingDays = useMemo(() => calculateRemainingDays(endAt), [endAt]);
|
||||||
|
|
||||||
if (!hasValidProgress || !progress || !segments) {
|
if (!hasValidProgress || !progress || !segments) {
|
||||||
@@ -93,19 +123,48 @@ export const ChallengeProgressCard: React.FC<ChallengeProgressCardProps> = ({
|
|||||||
|
|
||||||
<View style={[styles.track, { backgroundColor: trackColor }]}>
|
<View style={[styles.track, { backgroundColor: trackColor }]}>
|
||||||
{Array.from({ length: segments.segmentsCount }).map((_, index) => {
|
{Array.from({ length: segments.segmentsCount }).map((_, index) => {
|
||||||
const isComplete = index < segments.completedSegments;
|
|
||||||
const isFirst = index === 0;
|
const isFirst = index === 0;
|
||||||
const isLast = index === segments.segmentsCount - 1;
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
key={`progress-segment-${index}`}
|
key={`progress-segment-${index}`}
|
||||||
style={[
|
style={[
|
||||||
styles.segment,
|
styles.segment,
|
||||||
{ backgroundColor: isComplete ? accentColor : inactiveColor },
|
{ backgroundColor: inactiveColor },
|
||||||
isFirst && styles.segmentFirst,
|
isFirst && styles.segmentFirst,
|
||||||
isLast && styles.segmentLast,
|
isLast && styles.segmentLast,
|
||||||
]}
|
]}
|
||||||
/>
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.segmentFill,
|
||||||
|
{
|
||||||
|
backgroundColor: accentColor,
|
||||||
|
opacity,
|
||||||
|
transform: [{ scaleX }, { scaleY }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
@@ -168,6 +227,7 @@ const styles = StyleSheet.create({
|
|||||||
height: 4,
|
height: 4,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
marginHorizontal: 3,
|
marginHorizontal: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
segmentFirst: {
|
segmentFirst: {
|
||||||
marginLeft: 0,
|
marginLeft: 0,
|
||||||
@@ -175,6 +235,10 @@ const styles = StyleSheet.create({
|
|||||||
segmentLast: {
|
segmentLast: {
|
||||||
marginRight: 0,
|
marginRight: 0,
|
||||||
},
|
},
|
||||||
|
segmentFill: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ChallengeProgressCard;
|
export default ChallengeProgressCard;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.14</string>
|
<string>1.0.15</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
Reference in New Issue
Block a user