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',
},