diff --git a/app/challenge/_layout.tsx b/app/challenge/_layout.tsx deleted file mode 100644 index b86fc0d..0000000 --- a/app/challenge/_layout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Stack } from 'expo-router'; -import React from 'react'; - -export default function ChallengeLayout() { - return ( - - - - - ); -} - - diff --git a/app/challenge/day.tsx b/app/challenge/day.tsx deleted file mode 100644 index 954ea4c..0000000 --- a/app/challenge/day.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { Colors } from '@/constants/Colors'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import { completeDay, setCustom } from '@/store/challengeSlice'; -import type { Exercise, ExerciseCustomConfig } from '@/utils/pilatesPlan'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import React, { useState } from 'react'; -import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -export default function ChallengeDayScreen() { - const { day } = useLocalSearchParams<{ day: string }>(); - const router = useRouter(); - const dispatch = useAppDispatch(); - const challenge = useAppSelector((s) => (s as any).challenge); - const dayNumber = Math.max(1, Math.min(30, parseInt(String(day || '1'), 10))); - const dayState = challenge?.days?.[dayNumber - 1]; - const [currentSetIndexByExercise, setCurrentSetIndexByExercise] = useState>({}); - const [custom, setCustomLocal] = useState(dayState?.custom || []); - - const isLocked = dayState?.status === 'locked'; - const isCompleted = dayState?.status === 'completed'; - const plan = dayState?.plan; - - // 不再强制所有动作完成,始终允许完成 - const canFinish = true; - - const handleNextSet = (ex: Exercise) => { - const curr = currentSetIndexByExercise[ex.key] ?? 0; - if (curr < ex.sets.length) { - setCurrentSetIndexByExercise((prev) => ({ ...prev, [ex.key]: curr + 1 })); - } - }; - - const handleComplete = async () => { - // 持久化自定义配置 - await dispatch(setCustom({ dayNumber, custom: custom })); - await dispatch(completeDay(dayNumber)); - router.back(); - }; - - const updateCustom = (key: string, partial: Partial) => { - setCustomLocal((prev) => { - const next = prev.map((c) => (c.key === key ? { ...c, ...partial } : c)); - return next; - }); - }; - - if (!plan) { - return ( - - 加载中... - - ); - } - - return ( - - - router.back()} withSafeTop={false} transparent /> - {plan.title} - {plan.focus} - - item.key} - contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 120 }} - renderItem={({ item }) => { - const doneSets = currentSetIndexByExercise[item.key] ?? 0; - const conf = custom.find((c) => c.key === item.key); - const targetSets = conf?.sets ?? item.sets.length; - const perSetDuration = conf?.durationSec ?? item.sets[0]?.durationSec ?? 40; - return ( - - - {item.name} - {item.description} - - - updateCustom(item.key, { enabled: !(conf?.enabled ?? true) })}> - {conf?.enabled === false ? '已关闭' : '已启用'} - - - 组数 - - updateCustom(item.key, { sets: Math.max(1, (conf?.sets ?? targetSets) - 1) })}>- - {conf?.sets ?? targetSets} - updateCustom(item.key, { sets: Math.min(10, (conf?.sets ?? targetSets) + 1) })}>+ - - - - 时长/组 - - updateCustom(item.key, { durationSec: Math.max(10, (conf?.durationSec ?? perSetDuration) - 5) })}>- - {conf?.durationSec ?? perSetDuration}s - updateCustom(item.key, { durationSec: Math.min(180, (conf?.durationSec ?? perSetDuration) + 5) })}>+ - - - - - {Array.from({ length: targetSets }).map((_, idx) => ( - - - {perSetDuration}s - - - ))} - - handleNextSet(item)} disabled={doneSets >= targetSets || conf?.enabled === false}> - {doneSets >= item.sets.length ? '本动作完成' : '完成一组'} - - {item.tips && ( - - {item.tips.map((t: string, i: number) => ( - • {t} - ))} - - )} - - ); - }} - /> - - - - {isCompleted ? '已完成' : '完成今日训练'} - - - - - ); -} - -const styles = StyleSheet.create({ - safeArea: { flex: 1, backgroundColor: '#F7F8FA' }, - container: { flex: 1, backgroundColor: '#F7F8FA' }, - header: { paddingHorizontal: 20, paddingTop: 10, paddingBottom: 10 }, - headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, - backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' }, - headerTitle: { fontSize: 18, fontWeight: '800', color: '#1A1A1A' }, - title: { marginTop: 6, fontSize: 20, fontWeight: '800', color: '#1A1A1A' }, - subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' }, - exerciseCard: { - backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginTop: 12, - shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3, - }, - exerciseHeader: { marginBottom: 8 }, - exerciseName: { fontSize: 16, fontWeight: '800', color: '#111827' }, - exerciseDesc: { marginTop: 4, fontSize: 12, color: '#6B7280' }, - setsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 }, - controlsRow: { flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginTop: 8 }, - toggleBtn: { backgroundColor: '#111827', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 }, - toggleBtnOff: { backgroundColor: '#9CA3AF' }, - toggleBtnText: { color: '#FFFFFF', fontWeight: '700' }, - counterBox: { backgroundColor: '#F3F4F6', borderRadius: 8, padding: 8 }, - counterLabel: { fontSize: 10, color: '#6B7280' }, - counterRow: { flexDirection: 'row', alignItems: 'center' }, - counterBtn: { backgroundColor: '#E5E7EB', width: 28, height: 28, borderRadius: 6, alignItems: 'center', justifyContent: 'center' }, - counterBtnText: { fontWeight: '800', color: '#111827' }, - counterValue: { minWidth: 40, textAlign: 'center', fontWeight: '700', color: '#111827' }, - setPill: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 999 }, - setPillTodo: { backgroundColor: '#F3F4F6' }, - setPillDone: { backgroundColor: Colors.light.accentGreen }, - setPillText: { fontSize: 12, fontWeight: '700' }, - setPillTextTodo: { color: '#6B7280' }, - setPillTextDone: { color: '#192126' }, - nextSetBtn: { marginTop: 10, alignSelf: 'flex-start', backgroundColor: '#111827', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 }, - nextSetText: { color: '#FFFFFF', fontWeight: '700' }, - tipsBox: { marginTop: 10, backgroundColor: '#F9FAFB', borderRadius: 8, padding: 10 }, - tipText: { fontSize: 12, color: '#6B7280', lineHeight: 18 }, - bottomBar: { position: 'absolute', left: 0, right: 0, bottom: 0, padding: 20, backgroundColor: 'transparent' }, - finishBtn: { backgroundColor: Colors.light.accentGreen, paddingVertical: 14, borderRadius: 999, alignItems: 'center' }, - finishBtnText: { color: '#192126', fontWeight: '800', fontSize: 16 }, -}); - - diff --git a/app/challenge/index.tsx b/app/challenge/index.tsx deleted file mode 100644 index 6dd9820..0000000 --- a/app/challenge/index.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { Colors } from '@/constants/Colors'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import { useAuthGuard } from '@/hooks/useAuthGuard'; -import { initChallenge } from '@/store/challengeSlice'; -import { estimateSessionMinutesWithCustom } from '@/utils/pilatesPlan'; -import { Ionicons } from '@expo/vector-icons'; -import { useRouter } from 'expo-router'; -import React, { useEffect, useMemo } from 'react'; -import { Dimensions, FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -export default function ChallengeHomeScreen() { - const dispatch = useAppDispatch(); - const router = useRouter(); - const { ensureLoggedIn } = useAuthGuard(); - const challenge = useAppSelector((s) => (s as any).challenge); - - useEffect(() => { - dispatch(initChallenge()); - }, [dispatch]); - - const progress = useMemo(() => { - const total = challenge?.days?.length || 30; - const done = challenge?.days?.filter((d: any) => d.status === 'completed').length || 0; - return total ? done / total : 0; - }, [challenge?.days]); - - return ( - - - router.back()} withSafeTop={false} transparent /> - 专注核心、体态与柔韧 · 连续完成解锁徽章 - - {/* 进度环与统计 */} - - - - - - {Math.round((progress || 0) * 100)}% - - - {challenge?.streak ?? 0} 天连续 - {(challenge?.days?.filter((d: any) => d.status === 'completed').length) ?? 0} / 30 完成 - - - - {/* 日历格子(简单 6x5 网格) */} - String(item.plan.dayNumber)} - numColumns={5} - columnWrapperStyle={{ justifyContent: 'space-between', marginBottom: 12 }} - contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 10, paddingBottom: 40 }} - renderItem={({ item }) => { - const { plan, status } = item; - const isLocked = status === 'locked'; - const isCompleted = status === 'completed'; - const minutes = estimateSessionMinutesWithCustom(plan, item.custom); - return ( - { - if (!(await ensureLoggedIn({ redirectTo: '/challenge', redirectParams: {} }))) return; - router.push({ pathname: '/challenge/day', params: { day: String(plan.dayNumber) } }); - }} - style={[styles.dayCell, isLocked && styles.dayCellLocked, isCompleted && styles.dayCellCompleted]} - activeOpacity={0.8} - > - {plan.dayNumber} - {minutes}′ - {isCompleted && } - {isLocked && } - - ); - }} - /> - - {/* 底部 CTA */} - - { - if (!(await ensureLoggedIn({ redirectTo: '/challenge' }))) return; - router.push({ pathname: '/challenge/day', params: { day: String((challenge?.days?.find((d: any) => d.status === 'available')?.plan.dayNumber) || 1) } }); - }}> - 开始今日训练 - - - - - ); -} - -const { width } = Dimensions.get('window'); -const cellSize = (width - 40 - 4 * 12) / 5; // 20 padding *2, 12 spacing *4 - -const styles = StyleSheet.create({ - safeArea: { flex: 1, backgroundColor: '#F7F8FA' }, - container: { flex: 1, backgroundColor: '#F7F8FA' }, - header: { paddingHorizontal: 20, paddingTop: 10 }, - headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, - backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' }, - headerTitle: { fontSize: 22, fontWeight: '800', color: '#1A1A1A' }, - subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' }, - summaryCard: { - marginTop: 16, - marginHorizontal: 20, - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 16, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3, - }, - summaryLeft: { flexDirection: 'row', alignItems: 'center' }, - progressPill: { width: 120, height: 10, borderRadius: 999, backgroundColor: '#E5E7EB', overflow: 'hidden' }, - progressFill: { height: '100%', backgroundColor: Colors.light.accentGreen }, - progressText: { marginLeft: 12, fontWeight: '700', color: '#111827' }, - summaryRight: {}, - summaryItem: { fontSize: 12, color: '#6B7280' }, - summaryItemValue: { fontWeight: '800', color: '#111827' }, - dayCell: { - width: cellSize, - height: cellSize, - borderRadius: 16, - backgroundColor: '#FFFFFF', - alignItems: 'center', - justifyContent: 'center', - shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3, - }, - dayCellLocked: { backgroundColor: '#F3F4F6' }, - dayCellCompleted: { backgroundColor: '#ECFDF5', borderWidth: 1, borderColor: '#A7F3D0' }, - dayNumber: { fontWeight: '800', color: '#111827', fontSize: 16 }, - dayNumberLocked: { color: '#9CA3AF' }, - dayMinutes: { marginTop: 4, fontSize: 12, color: '#6B7280' }, - bottomBar: { padding: 20 }, - startButton: { backgroundColor: Colors.light.accentGreen, paddingVertical: 14, borderRadius: 999, alignItems: 'center' }, - startButtonText: { color: '#192126', fontWeight: '800', fontSize: 16 }, -}); - - diff --git a/app/challenges/[id].tsx b/app/challenges/[id].tsx index defe784..185087a 100644 --- a/app/challenges/[id].tsx +++ b/app/challenges/[id].tsx @@ -27,6 +27,7 @@ import LottieView from 'lottie-react-native'; import React, { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, + Alert, Dimensions, Image, Platform, @@ -41,7 +42,7 @@ import { import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; const { width } = Dimensions.get('window'); -const HERO_HEIGHT = width * 0.86; +const HERO_HEIGHT = width * 0.76; const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF']; const isHttpUrl = (value: string) => /^https?:\/\//i.test(value); @@ -194,11 +195,32 @@ export default function ChallengeDetailScreen() { } }; - const handleLeave = () => { + const handleLeave = async () => { if (!id || leaveStatus === 'loading') { return; } - dispatch(leaveChallenge(id)); + try { + await dispatch(leaveChallenge(id)).unwrap(); + await dispatch(fetchChallengeDetail(id)).unwrap(); + } catch (error) { + Toast.error('退出挑战失败'); + } + }; + + const handleLeaveConfirm = () => { + if (!id || leaveStatus === 'loading') { + return; + } + Alert.alert('确认退出挑战?', '退出后需要重新加入才能继续坚持。', [ + { text: '取消', style: 'cancel' }, + { + text: '退出挑战', + style: 'destructive', + onPress: () => { + void handleLeave(); + }, + }, + ]); }; const handleProgressReport = () => { @@ -256,7 +278,16 @@ export default function ChallengeDetailScreen() { const highlightTitle = challenge.highlightTitle ?? '立即加入挑战'; const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果'; - const ctaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战'; + const joinCtaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战'; + const leaveHighlightTitle = '先别急着离开'; + const leaveHighlightSubtitle = '再坚持一下,下一个里程碑就要出现了'; + const leaveCtaLabel = leaveStatus === 'loading' ? '退出中…' : '退出挑战'; + const floatingHighlightTitle = isJoined ? leaveHighlightTitle : highlightTitle; + const floatingHighlightSubtitle = isJoined ? leaveHighlightSubtitle : highlightSubtitle; + const floatingCtaLabel = isJoined ? leaveCtaLabel : joinCtaLabel; + const floatingOnPress = isJoined ? handleLeaveConfirm : handleJoin; + const floatingDisabled = isJoined ? leaveStatus === 'loading' : joinStatus === 'loading'; + const floatingError = isJoined ? leaveError : joinError; const participantsLabel = formatParticipantsLabel(challenge.participantsCount); const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined; @@ -321,15 +352,10 @@ export default function ChallengeDetailScreen() { style={styles.progressCard} > - - - 打卡中 - - {challenge.title} - 剩余 {dayjs(challenge.endAt).diff(dayjs(), 'd') || 0} 天 + 挑战剩余 {dayjs(challenge.endAt).diff(dayjs(), 'd') || 0} 天 @@ -485,34 +511,32 @@ export default function ChallengeDetailScreen() { - {!isJoined && ( - - - - - {highlightTitle} - {highlightSubtitle} - {joinError ? {joinError} : null} - - - - {ctaLabel} - - + + + + + {floatingHighlightTitle} + {floatingHighlightSubtitle} + {floatingError ? {floatingError} : null} - - - )} + + + {floatingCtaLabel} + + + + + {showCelebration && ( @@ -547,8 +571,8 @@ const styles = StyleSheet.create({ height: HERO_HEIGHT, width: '100%', overflow: 'hidden', - borderBottomLeftRadius: 36, - borderBottomRightRadius: 36, + position: 'absolute', + top: 0 }, heroImage: { width: '100%', @@ -627,17 +651,17 @@ const styles = StyleSheet.create({ color: '#5f6a97', }, progressRemaining: { - fontSize: 13, + fontSize: 11, fontWeight: '600', color: '#707baf', marginLeft: 16, alignSelf: 'flex-start', }, progressMetaRow: { - marginTop: 18, + marginTop: 12, }, progressMetaValue: { - fontSize: 16, + fontSize: 14, fontWeight: '700', color: '#4F5BD5', }, @@ -647,7 +671,7 @@ const styles = StyleSheet.create({ color: '#7a86bb', }, progressBarTrack: { - marginTop: 16, + marginTop: 12, flexDirection: 'row', alignItems: 'center', backgroundColor: '#eceffa', @@ -657,7 +681,7 @@ const styles = StyleSheet.create({ }, progressBarSegment: { flex: 1, - height: 8, + height: 4, borderRadius: 4, backgroundColor: '#dfe4f6', marginHorizontal: 3, @@ -738,7 +762,7 @@ const styles = StyleSheet.create({ }, headerTextBlock: { paddingHorizontal: 24, - marginTop: 24, + marginTop: HERO_HEIGHT - 60, alignItems: 'center', }, periodLabel: { @@ -795,8 +819,6 @@ const styles = StyleSheet.create({ detailIconWrapper: { width: 42, height: 42, - borderRadius: 21, - backgroundColor: '#EFF1FF', alignItems: 'center', justifyContent: 'center', },