feat(challenges): 移除旧版挑战页面并优化详情页交互
删除废弃的 app/challenge 目录及其所有文件,统一使用新的 challenges 模块。在详情页新增退出挑战确认弹窗,优化浮动 CTA 文案与交互,调整进度卡片样式与布局。
This commit is contained in:
@@ -1,13 +0,0 @@
|
|||||||
import { Stack } from 'expo-router';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function ChallengeLayout() {
|
|
||||||
return (
|
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
|
||||||
<Stack.Screen name="index" />
|
|
||||||
<Stack.Screen name="day" />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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<Record<string, number>>({});
|
|
||||||
const [custom, setCustomLocal] = useState<ExerciseCustomConfig[]>(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<ExerciseCustomConfig>) => {
|
|
||||||
setCustomLocal((prev) => {
|
|
||||||
const next = prev.map((c) => (c.key === key ? { ...c, ...partial } : c));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!plan) {
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<View style={styles.container}><Text>加载中...</Text></View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<View style={styles.container}>
|
|
||||||
<HeaderBar title={`第${plan.dayNumber}天`} onBack={() => router.back()} withSafeTop={false} transparent />
|
|
||||||
<Text style={styles.title}>{plan.title}</Text>
|
|
||||||
<Text style={styles.subtitle}>{plan.focus}</Text>
|
|
||||||
|
|
||||||
<FlatList
|
|
||||||
data={plan.exercises}
|
|
||||||
keyExtractor={(item) => 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 (
|
|
||||||
<View style={styles.exerciseCard}>
|
|
||||||
<View style={styles.exerciseHeader}>
|
|
||||||
<Text style={styles.exerciseName}>{item.name}</Text>
|
|
||||||
<Text style={styles.exerciseDesc}>{item.description}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.controlsRow}>
|
|
||||||
<TouchableOpacity style={[styles.toggleBtn, conf?.enabled === false && styles.toggleBtnOff]} onPress={() => updateCustom(item.key, { enabled: !(conf?.enabled ?? true) })}>
|
|
||||||
<Text style={styles.toggleBtnText}>{conf?.enabled === false ? '已关闭' : '已启用'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={styles.counterBox}>
|
|
||||||
<Text style={styles.counterLabel}>组数</Text>
|
|
||||||
<View style={styles.counterRow}>
|
|
||||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { sets: Math.max(1, (conf?.sets ?? targetSets) - 1) })}><Text style={styles.counterBtnText}>-</Text></TouchableOpacity>
|
|
||||||
<Text style={styles.counterValue}>{conf?.sets ?? targetSets}</Text>
|
|
||||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { sets: Math.min(10, (conf?.sets ?? targetSets) + 1) })}><Text style={styles.counterBtnText}>+</Text></TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.counterBox}>
|
|
||||||
<Text style={styles.counterLabel}>时长/组</Text>
|
|
||||||
<View style={styles.counterRow}>
|
|
||||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { durationSec: Math.max(10, (conf?.durationSec ?? perSetDuration) - 5) })}><Text style={styles.counterBtnText}>-</Text></TouchableOpacity>
|
|
||||||
<Text style={styles.counterValue}>{conf?.durationSec ?? perSetDuration}s</Text>
|
|
||||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { durationSec: Math.min(180, (conf?.durationSec ?? perSetDuration) + 5) })}><Text style={styles.counterBtnText}>+</Text></TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.setsRow}>
|
|
||||||
{Array.from({ length: targetSets }).map((_, idx) => (
|
|
||||||
<View key={idx} style={[styles.setPill, idx < doneSets ? styles.setPillDone : styles.setPillTodo]}>
|
|
||||||
<Text style={[styles.setPillText, idx < doneSets ? styles.setPillTextDone : styles.setPillTextTodo]}>
|
|
||||||
{perSetDuration}s
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity style={styles.nextSetBtn} onPress={() => handleNextSet(item)} disabled={doneSets >= targetSets || conf?.enabled === false}>
|
|
||||||
<Text style={styles.nextSetText}>{doneSets >= item.sets.length ? '本动作完成' : '完成一组'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{item.tips && (
|
|
||||||
<View style={styles.tipsBox}>
|
|
||||||
{item.tips.map((t: string, i: number) => (
|
|
||||||
<Text key={i} style={styles.tipText}>• {t}</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View style={styles.bottomBar}>
|
|
||||||
<TouchableOpacity style={[styles.finishBtn, !canFinish && { opacity: 0.5 }]} disabled={!canFinish || isLocked || isCompleted} onPress={handleComplete}>
|
|
||||||
<Text style={styles.finishBtnText}>{isCompleted ? '已完成' : '完成今日训练'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 (
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<View style={styles.container}>
|
|
||||||
<HeaderBar title="30天普拉提打卡" onBack={() => router.back()} withSafeTop={false} transparent />
|
|
||||||
<Text style={styles.subtitle}>专注核心、体态与柔韧 · 连续完成解锁徽章</Text>
|
|
||||||
|
|
||||||
{/* 进度环与统计 */}
|
|
||||||
<View style={styles.summaryCard}>
|
|
||||||
<View style={styles.summaryLeft}>
|
|
||||||
<View style={styles.progressPill}>
|
|
||||||
<View style={[styles.progressFill, { width: `${Math.round((progress || 0) * 100)}%` }]} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.progressText}>{Math.round((progress || 0) * 100)}%</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.summaryRight}>
|
|
||||||
<Text style={styles.summaryItem}><Text style={styles.summaryItemValue}>{challenge?.streak ?? 0}</Text> 天连续</Text>
|
|
||||||
<Text style={styles.summaryItem}><Text style={styles.summaryItemValue}>{(challenge?.days?.filter((d: any) => d.status === 'completed').length) ?? 0}</Text> / 30 完成</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 日历格子(简单 6x5 网格) */}
|
|
||||||
<FlatList
|
|
||||||
data={challenge?.days || []}
|
|
||||||
keyExtractor={(item) => 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 (
|
|
||||||
<TouchableOpacity
|
|
||||||
disabled={isLocked}
|
|
||||||
onPress={async () => {
|
|
||||||
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}
|
|
||||||
>
|
|
||||||
<Text style={[styles.dayNumber, isLocked && styles.dayNumberLocked]}>{plan.dayNumber}</Text>
|
|
||||||
<Text style={styles.dayMinutes}>{minutes}′</Text>
|
|
||||||
{isCompleted && <Ionicons name="checkmark-circle" size={18} color="#10B981" style={{ position: 'absolute', top: 6, right: 6 }} />}
|
|
||||||
{isLocked && <Ionicons name="lock-closed" size={16} color="#9CA3AF" style={{ position: 'absolute', top: 6, right: 6 }} />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 底部 CTA */}
|
|
||||||
<View style={styles.bottomBar}>
|
|
||||||
<TouchableOpacity style={styles.startButton} onPress={async () => {
|
|
||||||
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) } });
|
|
||||||
}}>
|
|
||||||
<Text style={styles.startButtonText}>开始今日训练</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ import LottieView from 'lottie-react-native';
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Image,
|
Image,
|
||||||
Platform,
|
Platform,
|
||||||
@@ -41,7 +42,7 @@ import {
|
|||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
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 CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF'];
|
||||||
|
|
||||||
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value);
|
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') {
|
if (!id || leaveStatus === 'loading') {
|
||||||
return;
|
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 = () => {
|
const handleProgressReport = () => {
|
||||||
@@ -256,7 +278,16 @@ export default function ChallengeDetailScreen() {
|
|||||||
|
|
||||||
const highlightTitle = challenge.highlightTitle ?? '立即加入挑战';
|
const highlightTitle = challenge.highlightTitle ?? '立即加入挑战';
|
||||||
const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果';
|
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 participantsLabel = formatParticipantsLabel(challenge.participantsCount);
|
||||||
|
|
||||||
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
|
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
|
||||||
@@ -321,15 +352,10 @@ export default function ChallengeDetailScreen() {
|
|||||||
style={styles.progressCard}
|
style={styles.progressCard}
|
||||||
>
|
>
|
||||||
<View style={styles.progressHeaderRow}>
|
<View style={styles.progressHeaderRow}>
|
||||||
<View style={styles.progressBadgeRing}>
|
|
||||||
<View style={styles.progressBadgeFallback}>
|
|
||||||
<Text style={styles.progressBadgeText}>打卡中</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.progressHeadline}>
|
<View style={styles.progressHeadline}>
|
||||||
<Text style={styles.progressTitle}>{challenge.title}</Text>
|
<Text style={styles.progressTitle}>{challenge.title}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.progressRemaining}>剩余 {dayjs(challenge.endAt).diff(dayjs(), 'd') || 0} 天</Text>
|
<Text style={styles.progressRemaining}>挑战剩余 {dayjs(challenge.endAt).diff(dayjs(), 'd') || 0} 天</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.progressMetaRow}>
|
<View style={styles.progressMetaRow}>
|
||||||
@@ -485,34 +511,32 @@ export default function ChallengeDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{!isJoined && (
|
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}>
|
||||||
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}>
|
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
|
||||||
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
|
<View style={styles.floatingCTAContent}>
|
||||||
<View style={styles.floatingCTAContent}>
|
<View style={styles.highlightCopy}>
|
||||||
<View style={styles.highlightCopy}>
|
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
|
||||||
<Text style={styles.highlightTitle}>{highlightTitle}</Text>
|
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
|
||||||
<Text style={styles.highlightSubtitle}>{highlightSubtitle}</Text>
|
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
|
||||||
{joinError ? <Text style={styles.ctaErrorText}>{joinError}</Text> : null}
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.highlightButton}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
onPress={handleJoin}
|
|
||||||
disabled={joinStatus === 'loading'}
|
|
||||||
>
|
|
||||||
<LinearGradient
|
|
||||||
colors={CTA_GRADIENT}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
style={styles.highlightButtonBackground}
|
|
||||||
>
|
|
||||||
<Text style={styles.highlightButtonLabel}>{ctaLabel}</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</BlurView>
|
<TouchableOpacity
|
||||||
</View>
|
style={styles.highlightButton}
|
||||||
)}
|
activeOpacity={0.9}
|
||||||
|
onPress={floatingOnPress}
|
||||||
|
disabled={floatingDisabled}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={CTA_GRADIENT}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.highlightButtonBackground}
|
||||||
|
>
|
||||||
|
<Text style={styles.highlightButtonLabel}>{floatingCtaLabel}</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{showCelebration && (
|
{showCelebration && (
|
||||||
<View pointerEvents="none" style={styles.celebrationOverlay}>
|
<View pointerEvents="none" style={styles.celebrationOverlay}>
|
||||||
@@ -547,8 +571,8 @@ const styles = StyleSheet.create({
|
|||||||
height: HERO_HEIGHT,
|
height: HERO_HEIGHT,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
borderBottomLeftRadius: 36,
|
position: 'absolute',
|
||||||
borderBottomRightRadius: 36,
|
top: 0
|
||||||
},
|
},
|
||||||
heroImage: {
|
heroImage: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -627,17 +651,17 @@ const styles = StyleSheet.create({
|
|||||||
color: '#5f6a97',
|
color: '#5f6a97',
|
||||||
},
|
},
|
||||||
progressRemaining: {
|
progressRemaining: {
|
||||||
fontSize: 13,
|
fontSize: 11,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#707baf',
|
color: '#707baf',
|
||||||
marginLeft: 16,
|
marginLeft: 16,
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
},
|
},
|
||||||
progressMetaRow: {
|
progressMetaRow: {
|
||||||
marginTop: 18,
|
marginTop: 12,
|
||||||
},
|
},
|
||||||
progressMetaValue: {
|
progressMetaValue: {
|
||||||
fontSize: 16,
|
fontSize: 14,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#4F5BD5',
|
color: '#4F5BD5',
|
||||||
},
|
},
|
||||||
@@ -647,7 +671,7 @@ const styles = StyleSheet.create({
|
|||||||
color: '#7a86bb',
|
color: '#7a86bb',
|
||||||
},
|
},
|
||||||
progressBarTrack: {
|
progressBarTrack: {
|
||||||
marginTop: 16,
|
marginTop: 12,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: '#eceffa',
|
backgroundColor: '#eceffa',
|
||||||
@@ -657,7 +681,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
progressBarSegment: {
|
progressBarSegment: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 8,
|
height: 4,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
backgroundColor: '#dfe4f6',
|
backgroundColor: '#dfe4f6',
|
||||||
marginHorizontal: 3,
|
marginHorizontal: 3,
|
||||||
@@ -738,7 +762,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
headerTextBlock: {
|
headerTextBlock: {
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
marginTop: 24,
|
marginTop: HERO_HEIGHT - 60,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
periodLabel: {
|
periodLabel: {
|
||||||
@@ -795,8 +819,6 @@ const styles = StyleSheet.create({
|
|||||||
detailIconWrapper: {
|
detailIconWrapper: {
|
||||||
width: 42,
|
width: 42,
|
||||||
height: 42,
|
height: 42,
|
||||||
borderRadius: 21,
|
|
||||||
backgroundColor: '#EFF1FF',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user