feat: 添加训练计划和打卡功能
- 新增训练计划页面,允许用户制定个性化的训练计划 - 集成打卡功能,用户可以记录每日的训练情况 - 更新 Redux 状态管理,添加训练计划相关的 reducer - 在首页中添加训练计划卡片,支持用户点击跳转 - 更新样式和布局,以适应新功能的展示和交互 - 添加日期选择器和相关依赖,支持用户选择训练日期
This commit is contained in:
@@ -3,6 +3,7 @@ import { CircularRing } from '@/components/CircularRing';
|
||||
import { ProgressBar } from '@/components/ProgressBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate, fetchTodayHealthData } from '@/utils/health';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
// 使用 dayjs:当月日期与默认选中“今天”
|
||||
const days = getMonthDaysZh();
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
@@ -104,8 +107,8 @@ export default function ExploreScreen() {
|
||||
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
||||
|
||||
@@ -2,28 +2,25 @@ import { PlanCard } from '@/components/PlanCard';
|
||||
import { SearchBox } from '@/components/SearchBox';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
// Removed WorkoutCard import since we no longer use the horizontal carousel
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { getChineseGreeting } from '@/utils/date';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React from 'react';
|
||||
import { Pressable, SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
|
||||
|
||||
// 移除旧的“热门活动”滑动数据,改为固定的“热点功能”卡片
|
||||
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter();
|
||||
const hasOpenedLoginRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 仅在本次会话首次进入首页时打开登录页,可返回关闭
|
||||
if (!hasOpenedLoginRef.current) {
|
||||
hasOpenedLoginRef.current = true;
|
||||
router.push('/auth/login');
|
||||
}
|
||||
}, [router]);
|
||||
const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<ThemedView style={styles.container}>
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<ThemedView style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* Header Section */}
|
||||
<View style={styles.header}>
|
||||
@@ -52,7 +49,7 @@ export default function HomeScreen() {
|
||||
|
||||
<Pressable
|
||||
style={[styles.featureCard, styles.featureCardSecondary]}
|
||||
onPress={() => router.push('/health-consultation' as any)}
|
||||
onPress={() => router.push('/ai-coach-chat?name=Sarah' as any)}
|
||||
>
|
||||
<ThemedText style={styles.featureTitle}>在线教练</ThemedText>
|
||||
<ThemedText style={styles.featureSubtitle}>认证教练 · 1对1即时解答</ThemedText>
|
||||
@@ -75,15 +72,33 @@ export default function HomeScreen() {
|
||||
level="初学者"
|
||||
progress={0}
|
||||
/>
|
||||
<Pressable onPress={() => router.push('/challenge')}>
|
||||
<PlanCard
|
||||
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
|
||||
title="30日训练打卡"
|
||||
subtitle="坚持30天,养成训练习惯"
|
||||
level="初学者"
|
||||
progress={0.75}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => pushIfAuthedElseLogin('/challenge')}>
|
||||
<PlanCard
|
||||
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
|
||||
title="每周打卡"
|
||||
subtitle="养成训练习惯,练出好身材"
|
||||
level="初学者"
|
||||
progress={0.75}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => pushIfAuthedElseLogin('/training-plan')}>
|
||||
<PlanCard
|
||||
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
|
||||
title="训练计划制定"
|
||||
subtitle="按周安排/次数·常见目标·个性化选项"
|
||||
level="初学者"
|
||||
progress={0}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => pushIfAuthedElseLogin('/checkin')}>
|
||||
<PlanCard
|
||||
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
|
||||
title="每日打卡(自选动作)"
|
||||
subtitle="选择动作,设置组数/次数,记录完成"
|
||||
level="初学者"
|
||||
progress={0}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export default function PersonalScreen() {
|
||||
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
||||
const colorScheme = useColorScheme();
|
||||
const colors = Colors[colorScheme ?? 'light'];
|
||||
const theme = (colorScheme ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
|
||||
type UserProfile = {
|
||||
fullName?: string;
|
||||
@@ -287,11 +289,11 @@ export default function PersonalScreen() {
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
style={[styles.scrollView, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}
|
||||
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
|
||||
@@ -38,8 +38,10 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="onboarding" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="profile/edit" />
|
||||
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="ai-coach-chat" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="ai-posture-assessment" />
|
||||
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
||||
|
||||
307
app/ai-coach-chat.tsx
Normal file
307
app/ai-coach-chat.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
|
||||
type Role = 'user' | 'assistant';
|
||||
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
role: Role;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export default function AICoachChatScreen() {
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{ name?: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
// 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色
|
||||
const theme = Colors.light;
|
||||
const coachName = (params?.name || 'Sarah').toString();
|
||||
const [input, setInput] = useState('');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([{
|
||||
id: 'm_welcome',
|
||||
role: 'assistant',
|
||||
content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~`,
|
||||
}]);
|
||||
const listRef = useRef<FlatList<ChatMessage>>(null);
|
||||
|
||||
const planDraft = useAppSelector((s) => s.trainingPlan?.draft);
|
||||
|
||||
const chips = useMemo(() => [
|
||||
{ key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') },
|
||||
{ key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() },
|
||||
], [router, planDraft]);
|
||||
|
||||
function scrollToEnd() {
|
||||
requestAnimationFrame(() => {
|
||||
listRef.current?.scrollToEnd({ animated: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function fakeStreamResponse(prompt: string): Promise<string> {
|
||||
// 占位实现:模拟AI逐字输出(可替换为真实后端流式接口)
|
||||
const canned =
|
||||
prompt.includes('训练计划') || prompt.includes('制定')
|
||||
? '好的,我将基于你的目标与时间安排制定一周普拉提计划:\n\n- 周一:核心激活与呼吸(20-25分钟)\n- 周三:下肢稳定与髋部灵活(25-30分钟)\n- 周五:全身整合与平衡(30分钟)\n\n每次训练前后各进行5分钟呼吸与拉伸。若有不适请降低强度或暂停。'
|
||||
: '已收到,我会根据你的问题给出建议:保持规律练习与充分恢复,注意呼吸控制与动作节奏。若感到疼痛请及时调整或咨询专业教练。';
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
return canned;
|
||||
}
|
||||
|
||||
async function send(text: string) {
|
||||
if (!text.trim() || isSending) return;
|
||||
const userMsg: ChatMessage = { id: `u_${Date.now()}`, role: 'user', content: text.trim() };
|
||||
setMessages((m) => [...m, userMsg]);
|
||||
setInput('');
|
||||
setIsSending(true);
|
||||
scrollToEnd();
|
||||
|
||||
try {
|
||||
const replyText = await fakeStreamResponse(text.trim());
|
||||
const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: replyText };
|
||||
setMessages((m) => [...m, aiMsg]);
|
||||
scrollToEnd();
|
||||
} catch (e) {
|
||||
const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: '抱歉,请求失败,请稍后再试。' };
|
||||
setMessages((m) => [...m, aiMsg]);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuickPlan() {
|
||||
const goalMap: Record<string, string> = {
|
||||
postpartum_recovery: '产后恢复',
|
||||
fat_loss: '减脂塑形',
|
||||
posture_correction: '体态矫正',
|
||||
core_strength: '核心力量',
|
||||
flexibility: '柔韧灵活',
|
||||
rehab: '康复保健',
|
||||
stress_relief: '释压放松',
|
||||
};
|
||||
const goalText = planDraft?.goal ? goalMap[planDraft.goal] : '整体提升';
|
||||
const freq = planDraft?.mode === 'sessionsPerWeek'
|
||||
? `${planDraft?.sessionsPerWeek ?? 3}次/周`
|
||||
: (planDraft?.daysOfWeek?.length ? `${planDraft.daysOfWeek.length}次/周` : '3次/周');
|
||||
const prefer = planDraft?.preferredTimeOfDay ? `偏好${planDraft.preferredTimeOfDay}` : '时间灵活';
|
||||
const prompt = `请根据我的目标“${goalText}”、频率“${freq}”、${prefer},制定1周的普拉提训练计划,包含每次训练主题、时长、主要动作与注意事项,并给出恢复建议。`;
|
||||
send(prompt);
|
||||
}
|
||||
|
||||
function renderItem({ item }: { item: ChatMessage }) {
|
||||
const isUser = item.role === 'user';
|
||||
return (
|
||||
<Animated.View
|
||||
entering={isUser ? FadeInUp.springify().damping(18) : FadeInDown.springify().damping(18)}
|
||||
layout={Layout.springify().damping(18)}
|
||||
style={[styles.row, { justifyContent: isUser ? 'flex-end' : 'flex-start' }]}
|
||||
>
|
||||
{!isUser && (
|
||||
<View style={[styles.avatar, { backgroundColor: theme.primary }]}>
|
||||
<Text style={styles.avatarText}>AI</Text>
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
styles.bubble,
|
||||
{
|
||||
backgroundColor: isUser ? theme.primary : 'rgba(187,242,70,0.16)',
|
||||
borderTopLeftRadius: isUser ? 16 : 6,
|
||||
borderTopRightRadius: isUser ? 6 : 16,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.bubbleText, { color: isUser ? theme.onPrimary : '#192126' }]}>{item.content}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
||||
<HeaderBar
|
||||
title={`教练 ${coachName}`}
|
||||
onBack={() => router.back()}
|
||||
tone="light"
|
||||
transparent
|
||||
/>
|
||||
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={messages}
|
||||
keyExtractor={(m) => m.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8, paddingBottom: insets.bottom + 140 }}
|
||||
onContentSizeChange={scrollToEnd}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={80}>
|
||||
<BlurView intensity={18} tint={'light'} style={[styles.composerWrap, { paddingBottom: insets.bottom + 10 }]}>
|
||||
<View style={styles.chipsRow}>
|
||||
{chips.map((c) => (
|
||||
<TouchableOpacity key={c.key} style={[styles.chip, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.12)' }]} onPress={c.action}>
|
||||
<Text style={[styles.chipText, { color: '#192126' }]}>{c.label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={[styles.inputRow, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.08)' }]}>
|
||||
<TextInput
|
||||
placeholder="问我任何与普拉提相关的问题..."
|
||||
placeholderTextColor={theme.textMuted}
|
||||
style={[styles.input, { color: '#192126' }]}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
multiline
|
||||
onSubmitEditing={() => send(input)}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
disabled={!input.trim() || isSending}
|
||||
onPress={() => send(input)}
|
||||
style={[
|
||||
styles.sendBtn,
|
||||
{ backgroundColor: theme.primary, opacity: input.trim() && !isSending ? 1 : 0.5 }
|
||||
]}
|
||||
>
|
||||
{isSending ? (
|
||||
<ActivityIndicator color={theme.onPrimary} />
|
||||
) : (
|
||||
<Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</BlurView>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
backButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.06)'
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: 8,
|
||||
marginVertical: 6,
|
||||
},
|
||||
avatar: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
color: '#192126',
|
||||
fontSize: 12,
|
||||
fontWeight: '800',
|
||||
},
|
||||
bubble: {
|
||||
maxWidth: '82%',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 16,
|
||||
},
|
||||
bubbleText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
composerWrap: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 10,
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
chipsRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
paddingHorizontal: 6,
|
||||
marginBottom: 8,
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: 10,
|
||||
height: 34,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
chipText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
padding: 8,
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(0,0,0,0.04)'
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
maxHeight: 120,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
sendBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
|
||||
type PoseView = 'front' | 'side' | 'back';
|
||||
@@ -172,18 +173,7 @@ export default function AIPostureAssessmentScreen() {
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
||||
{/* Header */}
|
||||
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
onPress={() => router.back()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color="#ECEDEE" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>AI体态测评</Text>
|
||||
<View style={{ width: 32 }} />
|
||||
</View>
|
||||
<HeaderBar title="AI体态测评" onBack={() => router.back()} tone="dark" transparent />
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
@@ -14,6 +14,7 @@ import { login } from '@/store/userSlice';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const router = useRouter();
|
||||
const searchParams = useLocalSearchParams<{ redirectTo?: string; redirectParams?: string }>();
|
||||
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const color = Colors[scheme];
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -46,7 +47,18 @@ export default function LoginScreen() {
|
||||
});
|
||||
const identityToken = (credential as any)?.identityToken;
|
||||
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
||||
router.back();
|
||||
// 登录成功后处理重定向
|
||||
const to = searchParams?.redirectTo as string | undefined;
|
||||
const paramsJson = searchParams?.redirectParams as string | undefined;
|
||||
let parsedParams: Record<string, any> | undefined;
|
||||
if (paramsJson) {
|
||||
try { parsedParams = JSON.parse(paramsJson); } catch { }
|
||||
}
|
||||
if (to) {
|
||||
router.replace({ pathname: to, params: parsedParams } as any);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ERR_CANCELED') return;
|
||||
const message = err?.message || '登录失败,请稍后再试';
|
||||
@@ -54,12 +66,22 @@ export default function LoginScreen() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [appleAvailable, router]);
|
||||
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo]);
|
||||
|
||||
const onGuestLogin = useCallback(() => {
|
||||
// TODO: 标记为游客身份,可在此写入本地状态/上报统计
|
||||
router.back();
|
||||
}, [router]);
|
||||
// 游客继续:若有 redirect 则前往,无则返回
|
||||
const to = searchParams?.redirectTo as string | undefined;
|
||||
const paramsJson = searchParams?.redirectParams as string | undefined;
|
||||
let parsedParams: Record<string, any> | undefined;
|
||||
if (paramsJson) {
|
||||
try { parsedParams = JSON.parse(paramsJson); } catch { }
|
||||
}
|
||||
if (to) {
|
||||
router.replace({ pathname: to, params: parsedParams } as any);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
}, [router, searchParams?.redirectParams, searchParams?.redirectTo]);
|
||||
|
||||
const disabledStyle = useMemo(() => ({ opacity: hasAgreed ? 1 : 0.5 }), [hasAgreed]);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { completeDay, setCustom } from '@/store/challengeSlice';
|
||||
import type { Exercise, ExerciseCustomConfig } from '@/utils/pilatesPlan';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import { FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
@@ -55,17 +55,9 @@ export default function ChallengeDayScreen() {
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerRow}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton} accessibilityRole="button">
|
||||
<Ionicons name="chevron-back" size={24} color="#111827" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>第{plan.dayNumber}天</Text>
|
||||
<View style={{ width: 32 }} />
|
||||
</View>
|
||||
<Text style={styles.title}>{plan.title}</Text>
|
||||
<Text style={styles.subtitle}>{plan.focus}</Text>
|
||||
</View>
|
||||
<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}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
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';
|
||||
@@ -9,6 +11,7 @@ import { Dimensions, FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity,
|
||||
export default function ChallengeHomeScreen() {
|
||||
const dispatch = useAppDispatch();
|
||||
const router = useRouter();
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const challenge = useAppSelector((s) => (s as any).challenge);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,16 +27,8 @@ export default function ChallengeHomeScreen() {
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerRow}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton} accessibilityRole="button">
|
||||
<Ionicons name="chevron-back" size={24} color="#111827" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>30天普拉提打卡</Text>
|
||||
<View style={{ width: 32 }} />
|
||||
</View>
|
||||
<Text style={styles.subtitle}>专注核心、体态与柔韧 · 连续完成解锁徽章</Text>
|
||||
</View>
|
||||
<HeaderBar title="30天普拉提打卡" onBack={() => router.back()} withSafeTop={false} transparent />
|
||||
<Text style={styles.subtitle}>专注核心、体态与柔韧 · 连续完成解锁徽章</Text>
|
||||
|
||||
{/* 进度环与统计 */}
|
||||
<View style={styles.summaryCard}>
|
||||
@@ -45,7 +40,7 @@ export default function ChallengeHomeScreen() {
|
||||
</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>
|
||||
<Text style={styles.summaryItem}><Text style={styles.summaryItemValue}>{(challenge?.days?.filter((d: any) => d.status === 'completed').length) ?? 0}</Text> / 30 完成</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -64,7 +59,10 @@ export default function ChallengeHomeScreen() {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={isLocked}
|
||||
onPress={() => router.push({ pathname: '/challenge/day', params: { day: String(plan.dayNumber) } })}
|
||||
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}
|
||||
>
|
||||
@@ -79,7 +77,10 @@ export default function ChallengeHomeScreen() {
|
||||
|
||||
{/* 底部 CTA */}
|
||||
<View style={styles.bottomBar}>
|
||||
<TouchableOpacity style={styles.startButton} onPress={() => router.push({ pathname: '/challenge/day', params: { day: String((challenge?.days?.find((d:any)=>d.status==='available')?.plan.dayNumber) || 1) } })}>
|
||||
<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>
|
||||
|
||||
110
app/checkin/index.tsx
Normal file
110
app/checkin/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { removeExercise, setCurrentDate, toggleExerciseCompleted } from '@/store/checkinSlice';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
function formatDate(d: Date) {
|
||||
const y = d.getFullYear();
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${d.getDate()}`.padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export default function CheckinHome() {
|
||||
const dispatch = useAppDispatch();
|
||||
const router = useRouter();
|
||||
const today = useMemo(() => formatDate(new Date()), []);
|
||||
const checkin = useAppSelector((s) => (s as any).checkin);
|
||||
const record = checkin?.byDate?.[today];
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setCurrentDate(today));
|
||||
}, [dispatch, today]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<View pointerEvents="none" style={styles.bgOrnaments}>
|
||||
<View style={[styles.blob, { backgroundColor: colorTokens.ornamentPrimary, top: -60, right: -60 }]} />
|
||||
<View style={[styles.blob, { backgroundColor: colorTokens.ornamentAccent, bottom: -70, left: -70 }]} />
|
||||
</View>
|
||||
|
||||
<HeaderBar title="今日打卡" onBack={() => router.back()} withSafeTop={false} transparent />
|
||||
<View style={[styles.hero, { backgroundColor: colorTokens.heroSurfaceTint }]}>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>{today}</Text>
|
||||
<Text style={[styles.subtitle, { color: colorTokens.textMuted }]}>请选择动作并记录完成情况</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionRow}>
|
||||
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]} onPress={() => router.push('/checkin/select')}>
|
||||
<Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}>新增动作</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={record?.items || []}
|
||||
keyExtractor={(item) => item.key}
|
||||
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 20 }}
|
||||
ListEmptyComponent={
|
||||
<View style={[styles.emptyBox, { backgroundColor: colorTokens.card }]}>
|
||||
<Text style={[styles.emptyText, { color: colorTokens.textMuted }]}>还没有选择任何动作,点击“新增动作”开始吧。</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<View style={[styles.card, { backgroundColor: colorTokens.card }]}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.cardTitle, { color: colorTokens.text }]}>{item.name}</Text>
|
||||
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{item.category}</Text>
|
||||
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>组数 {item.sets}{item.reps ? ` · 每组 ${item.reps} 次` : ''}{item.durationSec ? ` · 每组 ${item.durationSec}s` : ''}</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={[styles.doneBtn, { backgroundColor: item.completed ? colorTokens.primary : colorTokens.border }]} onPress={() => dispatch(toggleExerciseCompleted({ date: today, key: item.key }))}>
|
||||
<Text style={[styles.doneBtnText, { color: item.completed ? colorTokens.onPrimary : colorTokens.text, fontWeight: item.completed ? '800' : '700' }]}>{item.completed ? '已完成' : '完成'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.removeBtn, { backgroundColor: colorTokens.border }]} onPress={() => dispatch(removeExercise({ date: today, key: item.key }))}>
|
||||
<Text style={[styles.removeBtnText, { color: colorTokens.text }]}>移除</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
|
||||
container: { flex: 1, backgroundColor: '#F7F8FA' },
|
||||
header: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 8 },
|
||||
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 },
|
||||
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
|
||||
hero: { backgroundColor: 'rgba(187,242,70,0.18)', borderRadius: 16, padding: 14 },
|
||||
title: { fontSize: 24, fontWeight: '800', color: '#111827' },
|
||||
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
||||
bgOrnaments: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
|
||||
blob: { position: 'absolute', width: 260, height: 260, borderRadius: 999 },
|
||||
blobPrimary: { backgroundColor: '#00000000' },
|
||||
blobPurple: { backgroundColor: '#00000000' },
|
||||
actionRow: { paddingHorizontal: 20, marginTop: 8 },
|
||||
primaryBtn: { backgroundColor: '#111827', paddingVertical: 10, borderRadius: 10, alignItems: 'center' },
|
||||
primaryBtnText: { color: '#FFFFFF', fontWeight: '800' },
|
||||
emptyBox: { marginTop: 16, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginHorizontal: 20 },
|
||||
emptyText: { color: '#6B7280' },
|
||||
card: { marginTop: 12, marginHorizontal: 20, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, flexDirection: 'row', alignItems: 'center', gap: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 },
|
||||
cardTitle: { fontSize: 16, fontWeight: '800', color: '#111827' },
|
||||
cardMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
||||
removeBtn: { backgroundColor: '#F3F4F6', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8 },
|
||||
removeBtnText: { color: '#111827', fontWeight: '700' },
|
||||
doneBtn: { backgroundColor: '#E5E7EB', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8, marginRight: 8 },
|
||||
doneBtnActive: { backgroundColor: '#10B981' },
|
||||
doneBtnText: { color: '#111827', fontWeight: '700' },
|
||||
doneBtnTextActive: { color: '#FFFFFF', fontWeight: '800' },
|
||||
|
||||
});
|
||||
|
||||
|
||||
363
app/checkin/select.tsx
Normal file
363
app/checkin/select.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { addExercise } from '@/store/checkinSlice';
|
||||
import { EXERCISE_LIBRARY, getCategories, searchExercises } from '@/utils/exerciseLibrary';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native';
|
||||
|
||||
function formatDate(d: Date) {
|
||||
const y = d.getFullYear();
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${d.getDate()}`.padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export default function SelectExerciseScreen() {
|
||||
const dispatch = useAppDispatch();
|
||||
const router = useRouter();
|
||||
const today = useMemo(() => formatDate(new Date()), []);
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [category, setCategory] = useState<string>('全部');
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [sets, setSets] = useState(3);
|
||||
const [reps, setReps] = useState<number | undefined>(undefined);
|
||||
const [showCustomReps, setShowCustomReps] = useState(false);
|
||||
const [customRepsInput, setCustomRepsInput] = useState('');
|
||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||
|
||||
const controlsOpacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const categories = useMemo(() => ['全部', ...getCategories()], []);
|
||||
const mainCategories = useMemo(() => {
|
||||
const preferred = ['全部', '核心与腹部', '脊柱与后链', '侧链与髋', '平衡与支撑'];
|
||||
const exists = (name: string) => categories.includes(name);
|
||||
const picked = preferred.filter(exists);
|
||||
// 兜底:若某些偏好分类不存在,补足其他分类
|
||||
const rest = categories.filter((c) => !picked.includes(c));
|
||||
while (picked.length < 5 && rest.length) picked.push(rest.shift() as string);
|
||||
return picked;
|
||||
}, [categories]);
|
||||
const filtered = useMemo(() => {
|
||||
const base = searchExercises(keyword);
|
||||
if (category === '全部') return base;
|
||||
return base.filter((e) => e.category === category);
|
||||
}, [keyword, category]);
|
||||
|
||||
const selected = useMemo(() => EXERCISE_LIBRARY.find((e) => e.key === selectedKey) || null, [selectedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(controlsOpacity, {
|
||||
toValue: selected ? 1 : 0,
|
||||
duration: selected ? 220 : 160,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [selected, controlsOpacity]);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!selected) return;
|
||||
dispatch(addExercise({
|
||||
date: today,
|
||||
item: {
|
||||
key: selected.key,
|
||||
name: selected.name,
|
||||
category: selected.category,
|
||||
sets: Math.max(1, sets),
|
||||
reps: reps && reps > 0 ? reps : undefined,
|
||||
},
|
||||
}));
|
||||
router.back();
|
||||
};
|
||||
|
||||
const onSelectItem = (key: string) => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
if (selectedKey === key) {
|
||||
setSelectedKey(null);
|
||||
return;
|
||||
}
|
||||
setSets(3);
|
||||
setReps(undefined);
|
||||
setShowCustomReps(false);
|
||||
setCustomRepsInput('');
|
||||
setSelectedKey(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<View pointerEvents="none" style={styles.bgOrnaments}>
|
||||
<View style={[styles.blob, { backgroundColor: colorTokens.ornamentPrimary, top: -60, right: -60 }]} />
|
||||
<View style={[styles.blob, { backgroundColor: colorTokens.ornamentAccent, bottom: -70, left: -70 }]} />
|
||||
</View>
|
||||
|
||||
<HeaderBar title="选择动作" onBack={() => router.back()} withSafeTop={false} transparent />
|
||||
<View style={[styles.hero, { backgroundColor: colorTokens.heroSurfaceTint }]}>
|
||||
<Text style={[styles.subtitle, { color: colorTokens.textMuted }]}>从动作库里选择一个动作,设置组数与每组次数</Text>
|
||||
</View>
|
||||
|
||||
{/* 大分类宫格(无横向滚动) */}
|
||||
<View style={styles.catGrid}>
|
||||
{[...mainCategories, '更多'].map((item) => {
|
||||
const active = category === item;
|
||||
const meta: Record<string, { bg: string }> = {
|
||||
全部: { bg: 'rgba(187,242,70,0.22)' },
|
||||
核心与腹部: { bg: 'rgba(187,242,70,0.18)' },
|
||||
脊柱与后链: { bg: 'rgba(149,204,227,0.20)' },
|
||||
侧链与髋: { bg: 'rgba(164,138,237,0.20)' },
|
||||
平衡与支撑: { bg: 'rgba(252,196,111,0.22)' },
|
||||
进阶控制: { bg: 'rgba(237,71,71,0.18)' },
|
||||
柔韧与拉伸: { bg: 'rgba(149,204,227,0.18)' },
|
||||
更多: { bg: 'rgba(24,24,27,0.06)' },
|
||||
};
|
||||
const scale = new Animated.Value(1);
|
||||
const onPressIn = () => Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
||||
const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
||||
const handlePress = () => {
|
||||
onPressOut();
|
||||
if (item === '更多') {
|
||||
setShowCategoryPicker(true);
|
||||
Haptics.selectionAsync();
|
||||
} else {
|
||||
setCategory(item);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Animated.View key={item} style={[styles.catTileWrapper, { transform: [{ scale }] }]}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={handlePress}
|
||||
style={[styles.catTile, { backgroundColor: meta[item]?.bg ?? colorTokens.surface }, active && styles.catTileActive]}
|
||||
>
|
||||
<Text style={[styles.catText, { color: active ? colorTokens.onPrimary : colorTokens.text }]}>{item}</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 分类选择弹层(更多) */}
|
||||
<Modal
|
||||
visible={showCategoryPicker}
|
||||
animationType="fade"
|
||||
transparent
|
||||
onRequestClose={() => setShowCategoryPicker(false)}
|
||||
>
|
||||
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setShowCategoryPicker(false)}>
|
||||
<TouchableOpacity activeOpacity={1} style={[styles.modalSheet, { backgroundColor: colorTokens.card }]}
|
||||
onPress={(e) => e.stopPropagation() as any}
|
||||
>
|
||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>选择分类</Text>
|
||||
<View style={styles.catGridModal}>
|
||||
{categories.filter((c) => c !== '全部').map((c) => {
|
||||
const scale = new Animated.Value(1);
|
||||
const onPressIn = () => Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
||||
const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
||||
return (
|
||||
<Animated.View key={c} style={[styles.catTileWrapper, { transform: [{ scale }] }]}>
|
||||
<TouchableOpacity
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={() => {
|
||||
onPressOut();
|
||||
setCategory(c);
|
||||
setShowCategoryPicker(false);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
activeOpacity={0.9}
|
||||
style={[styles.catTile, { backgroundColor: 'rgba(24,24,27,0.06)' }]}
|
||||
>
|
||||
<Text style={styles.catText}>{c}</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
||||
<View style={styles.searchRow}>
|
||||
<TextInput
|
||||
value={keyword}
|
||||
onChangeText={setKeyword}
|
||||
placeholder="搜索动作名称/要点"
|
||||
placeholderTextColor={colorTokens.textMuted}
|
||||
style={[styles.searchInput, { backgroundColor: colorTokens.card, color: colorTokens.text, borderColor: colorTokens.border }]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={filtered}
|
||||
keyExtractor={(item) => item.key}
|
||||
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 40 }}
|
||||
renderItem={({ item }) => {
|
||||
const isSelected = item.key === selectedKey;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.itemCard,
|
||||
{ backgroundColor: colorTokens.card },
|
||||
isSelected && { borderWidth: 2, borderColor: colorTokens.primary },
|
||||
]}
|
||||
onPress={() => onSelectItem(item.key)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.itemTitle, { color: colorTokens.text }]}>{item.name}</Text>
|
||||
<Text style={[styles.itemMeta, { color: colorTokens.textMuted }]}>{item.category}</Text>
|
||||
<Text style={[styles.itemDesc, { color: colorTokens.textMuted }]}>{item.description}</Text>
|
||||
</View>
|
||||
{isSelected && <Ionicons name="chevron-down" size={20} color={colorTokens.text} />}
|
||||
{isSelected && (
|
||||
<Animated.View style={[styles.expandedBox, { opacity: controlsOpacity }]}>
|
||||
<View style={styles.controlsRow}>
|
||||
<View style={[styles.counterBox, { backgroundColor: colorTokens.surface }]}>
|
||||
<Text style={[styles.counterLabel, { color: colorTokens.textMuted }]}>组数</Text>
|
||||
<View style={styles.counterRow}>
|
||||
<TouchableOpacity style={[styles.counterBtn, { backgroundColor: colorTokens.border }]} onPress={() => setSets(Math.max(1, sets - 1))}><Text style={[styles.counterBtnText, { color: colorTokens.text }]}>-</Text></TouchableOpacity>
|
||||
<Text style={[styles.counterValue, { color: colorTokens.text }]}>{sets}</Text>
|
||||
<TouchableOpacity style={[styles.counterBtn, { backgroundColor: colorTokens.border }]} onPress={() => setSets(Math.min(20, sets + 1))}><Text style={[styles.counterBtnText, { color: colorTokens.text }]}>+</Text></TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.counterBox, { backgroundColor: colorTokens.surface }]}>
|
||||
<Text style={[styles.counterLabel, { color: colorTokens.textMuted }]}>每组次数</Text>
|
||||
<View style={styles.repsChipsRow}>
|
||||
{[6, 8, 10, 12, 15, 20, 25, 30].map((v) => {
|
||||
const active = reps === v;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={v}
|
||||
style={[styles.repChip, active && { backgroundColor: colorTokens.primary, borderColor: colorTokens.primary }]}
|
||||
onPress={() => {
|
||||
setReps(v);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.repChipText, active && { color: colorTokens.onPrimary }]}>{v}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
<TouchableOpacity
|
||||
style={[styles.repChipGhost, { borderColor: colorTokens.border }]}
|
||||
onPress={() => {
|
||||
setShowCustomReps((s) => !s);
|
||||
Haptics.selectionAsync();
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.repChipGhostText, { color: colorTokens.text }]}>自定义</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{showCustomReps && (
|
||||
<View style={styles.customRepsRow}>
|
||||
<TextInput
|
||||
keyboardType="number-pad"
|
||||
value={customRepsInput}
|
||||
onChangeText={setCustomRepsInput}
|
||||
placeholder="输入次数 (1-100)"
|
||||
placeholderTextColor={colorTokens.textMuted}
|
||||
style={[styles.customRepsInput, { borderColor: colorTokens.border, color: colorTokens.text }]}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.customRepsBtn, { backgroundColor: colorTokens.primary }]}
|
||||
onPress={() => {
|
||||
const n = Math.max(1, Math.min(100, parseInt(customRepsInput || '0', 10)));
|
||||
if (!Number.isNaN(n)) {
|
||||
setReps(n);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.customRepsBtnText, { color: colorTokens.onPrimary }]}>确定</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }, (!reps || reps <= 0) && { opacity: 0.5 }]}
|
||||
disabled={!reps || reps <= 0}
|
||||
onPress={handleAdd}
|
||||
>
|
||||
<Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}>添加到今日打卡</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
</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', zIndex: 2 },
|
||||
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
|
||||
headerTitle: { fontSize: 18, fontWeight: '800', color: '#1A1A1A' },
|
||||
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
||||
catCard: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 14, flexDirection: 'row', alignItems: 'center' },
|
||||
catCardActive: { borderWidth: 2, borderColor: '#BBF246' },
|
||||
catEmoji: { fontSize: 16, marginRight: 6 },
|
||||
catText: { fontSize: 13, fontWeight: '800' },
|
||||
hero: { backgroundColor: 'rgba(187,242,70,0.18)', borderRadius: 16, padding: 14, marginTop: 8 },
|
||||
bgOrnaments: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
|
||||
blob: { position: 'absolute', width: 260, height: 260, borderRadius: 999 },
|
||||
catGrid: { paddingHorizontal: 16, paddingTop: 10, flexDirection: 'row', flexWrap: 'wrap' },
|
||||
catTileWrapper: { width: '33.33%', padding: 6 },
|
||||
catTile: { borderRadius: 14, paddingVertical: 16, paddingHorizontal: 8, alignItems: 'center', justifyContent: 'center' },
|
||||
catTileActive: { borderWidth: 2, borderColor: '#BBF246' },
|
||||
searchRow: { paddingHorizontal: 20, marginTop: 8 },
|
||||
searchInput: { backgroundColor: '#FFFFFF', borderRadius: 12, paddingHorizontal: 12, paddingVertical: 10, color: '#111827' },
|
||||
itemCard: { backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginTop: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 },
|
||||
itemCardSelected: { borderWidth: 2, borderColor: '#10B981' },
|
||||
itemTitle: { fontSize: 16, fontWeight: '800', color: '#111827' },
|
||||
itemMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
||||
itemDesc: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
||||
expandedBox: { marginTop: 12 },
|
||||
controlsRow: { flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 10 },
|
||||
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' },
|
||||
repsChipsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 6 },
|
||||
repChip: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999, backgroundColor: '#F3F4F6', borderWidth: 1, borderColor: '#E5E7EB' },
|
||||
repChipText: { color: '#111827', fontWeight: '700' },
|
||||
repChipGhost: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999, borderWidth: 1, backgroundColor: 'transparent' },
|
||||
repChipGhostText: { fontWeight: '700' },
|
||||
customRepsRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 8 },
|
||||
customRepsInput: { flex: 1, height: 40, borderWidth: 1, borderRadius: 10, paddingHorizontal: 12 },
|
||||
customRepsBtn: { paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10 },
|
||||
customRepsBtnText: { fontWeight: '800' },
|
||||
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', alignItems: 'center', justifyContent: 'flex-end' },
|
||||
modalSheet: { width: '100%', borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 24 },
|
||||
modalTitle: { fontSize: 16, fontWeight: '800', marginBottom: 8 },
|
||||
catGridModal: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||
primaryBtn: { backgroundColor: '#111827', paddingVertical: 12, borderRadius: 12, alignItems: 'center' },
|
||||
primaryBtnText: { color: '#FFFFFF', fontWeight: '800' },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,481 +0,0 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
Pressable,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
// 健康数据项类型
|
||||
interface HealthItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: 'warning' | 'good' | 'info';
|
||||
icon: string;
|
||||
recommendation: string;
|
||||
value?: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
// 健康数据
|
||||
const healthData: HealthItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: '运动状态',
|
||||
subtitle: '本周运动不足',
|
||||
status: 'warning',
|
||||
icon: '🏃♀️',
|
||||
recommendation: '建议每天进行30分钟普拉提训练',
|
||||
value: '2天/周',
|
||||
color: '#FF6B6B',
|
||||
bgColor: '#FFE5E5',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '体态评估',
|
||||
subtitle: '需要进行评估',
|
||||
status: 'info',
|
||||
icon: '🧘♀️',
|
||||
recommendation: '进行AI体态评估,了解身体状况',
|
||||
color: '#4ECDC4',
|
||||
bgColor: '#E5F9F7',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '核心力量',
|
||||
subtitle: '待加强',
|
||||
status: 'warning',
|
||||
icon: '💪',
|
||||
recommendation: '推荐核心训练课程',
|
||||
value: '初级',
|
||||
color: '#FFB84D',
|
||||
bgColor: '#FFF4E5',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: '柔韧性',
|
||||
subtitle: '良好',
|
||||
status: 'good',
|
||||
icon: '🤸♀️',
|
||||
recommendation: '保持每日拉伸习惯',
|
||||
value: '良好',
|
||||
color: '#95E1D3',
|
||||
bgColor: '#E5F9F5',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: '平衡能力',
|
||||
subtitle: '需要提升',
|
||||
status: 'info',
|
||||
icon: '⚖️',
|
||||
recommendation: '尝试单腿站立训练',
|
||||
color: '#A8E6CF',
|
||||
bgColor: '#E8F8F0',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: '呼吸质量',
|
||||
subtitle: '待改善',
|
||||
status: 'warning',
|
||||
icon: '🌬️',
|
||||
recommendation: '学习普拉提呼吸法',
|
||||
color: '#C7CEEA',
|
||||
bgColor: '#F0F1F8',
|
||||
},
|
||||
];
|
||||
|
||||
export default function HealthConsultationScreen() {
|
||||
const router = useRouter();
|
||||
const primaryColor = useThemeColor({}, 'primary');
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const [greeting, setGreeting] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) {
|
||||
setGreeting('早上好');
|
||||
} else if (hour < 18) {
|
||||
setGreeting('下午好');
|
||||
} else {
|
||||
setGreeting('晚上好');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleHealthItemPress = (item: HealthItem) => {
|
||||
// 根据不同的健康项导航到相应页面
|
||||
if (item.title === '体态评估') {
|
||||
router.push('/ai-posture-assessment');
|
||||
} else {
|
||||
console.log(`点击了 ${item.title}`);
|
||||
// 可以添加更多导航逻辑
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor }]}>
|
||||
<ThemedView style={styles.container}>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* 顶部导航栏 */}
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.back()} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color={textColor} />
|
||||
</Pressable>
|
||||
<ThemedText style={styles.headerTitle}>健康咨询</ThemedText>
|
||||
<Pressable style={styles.notificationButton}>
|
||||
<Ionicons name="notifications-outline" size={24} color={textColor} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* 教练问候卡片 */}
|
||||
<LinearGradient
|
||||
colors={[primaryColor, '#A8E063']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.coachCard}
|
||||
>
|
||||
<View style={styles.coachContent}>
|
||||
<View style={styles.coachInfo}>
|
||||
<View style={styles.coachAvatar}>
|
||||
<Text style={styles.coachAvatarEmoji}>👩⚕️</Text>
|
||||
</View>
|
||||
<View style={styles.coachTextContainer}>
|
||||
<Text style={styles.coachGreeting}>{greeting},</Text>
|
||||
<Text style={styles.coachName}>我是您的普拉提教练 Sarah</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.coachQuestion}>今天感觉怎么样?</Text>
|
||||
<Text style={styles.coachSubtext}>让我们一起了解您的身体状况</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
{/* 快速操作按钮 */}
|
||||
<View style={styles.quickActions}>
|
||||
<Pressable style={[styles.actionButton, { backgroundColor: primaryColor }]}>
|
||||
<Ionicons name="body-outline" size={20} color="#192126" />
|
||||
<Text style={styles.actionButtonText}>体态检测</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.actionButton, styles.actionButtonOutline]}>
|
||||
<Ionicons name="chatbubble-outline" size={20} color={textColor} />
|
||||
<Text style={[styles.actionButtonText, { color: textColor }]}>咨询教练</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.actionButton, styles.actionButtonOutline]}>
|
||||
<Ionicons name="calendar-outline" size={20} color={textColor} />
|
||||
<Text style={[styles.actionButtonText, { color: textColor }]}>预约课程</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* 健康状况标题 */}
|
||||
<View style={styles.sectionHeader}>
|
||||
<ThemedText style={styles.sectionTitle}>您的健康状况</ThemedText>
|
||||
<View style={[styles.healthBadge, { backgroundColor: primaryColor }]}>
|
||||
<Text style={styles.healthBadgeText}>需要关注</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 健康数据网格 */}
|
||||
<View style={styles.healthGrid}>
|
||||
{healthData.map((item) => (
|
||||
<Pressable
|
||||
key={item.id}
|
||||
style={[styles.healthCard, { backgroundColor: item.bgColor }]}
|
||||
onPress={() => handleHealthItemPress(item)}
|
||||
>
|
||||
<View style={styles.healthCardHeader}>
|
||||
<Text style={styles.healthIcon}>{item.icon}</Text>
|
||||
{item.value && (
|
||||
<Text style={[styles.healthValue, { color: item.color }]}>
|
||||
{item.value}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.healthTitle}>{item.title}</Text>
|
||||
<Text style={styles.healthSubtitle}>{item.subtitle}</Text>
|
||||
<View style={styles.healthRecommendation}>
|
||||
<Ionicons name="bulb-outline" size={12} color={item.color} />
|
||||
<Text style={[styles.recommendationText, { color: item.color }]} numberOfLines={2}>
|
||||
{item.recommendation}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name="arrow-forward-circle"
|
||||
size={20}
|
||||
color={item.color}
|
||||
style={styles.cardArrow}
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 今日建议 */}
|
||||
<View style={styles.suggestionSection}>
|
||||
<ThemedText style={styles.suggestionTitle}>今日建议</ThemedText>
|
||||
<View style={[styles.suggestionCard, { backgroundColor: '#F0F8FF' }]}>
|
||||
<View style={styles.suggestionIcon}>
|
||||
<Text style={{ fontSize: 24 }}>💡</Text>
|
||||
</View>
|
||||
<View style={styles.suggestionContent}>
|
||||
<Text style={styles.suggestionMainText}>
|
||||
根据您的身体状况,建议今天进行轻度核心训练
|
||||
</Text>
|
||||
<Text style={styles.suggestionSubText}>
|
||||
配合呼吸练习,效果更佳
|
||||
</Text>
|
||||
<Pressable style={[styles.startButton, { backgroundColor: primaryColor }]}>
|
||||
<Text style={styles.startButtonText}>开始训练</Text>
|
||||
<Ionicons name="arrow-forward" size={16} color="#192126" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 底部间距 */}
|
||||
<View style={styles.bottomSpacing} />
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
notificationButton: {
|
||||
padding: 8,
|
||||
},
|
||||
coachCard: {
|
||||
marginHorizontal: 20,
|
||||
marginTop: 12,
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
elevation: 5,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
coachContent: {
|
||||
gap: 16,
|
||||
},
|
||||
coachInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
coachAvatar: {
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 25,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
coachAvatarEmoji: {
|
||||
fontSize: 30,
|
||||
},
|
||||
coachTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
coachGreeting: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
opacity: 0.8,
|
||||
},
|
||||
coachName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
},
|
||||
coachQuestion: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#192126',
|
||||
marginTop: 8,
|
||||
},
|
||||
coachSubtext: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
opacity: 0.7,
|
||||
},
|
||||
quickActions: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 20,
|
||||
gap: 12,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
gap: 6,
|
||||
},
|
||||
actionButtonOutline: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
},
|
||||
actionButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 32,
|
||||
marginBottom: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
healthBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
healthBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
},
|
||||
healthGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingHorizontal: 16,
|
||||
gap: 12,
|
||||
},
|
||||
healthCard: {
|
||||
width: (screenWidth - 44) / 2,
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
position: 'relative',
|
||||
},
|
||||
healthCardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
healthIcon: {
|
||||
fontSize: 28,
|
||||
},
|
||||
healthValue: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
healthTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
marginBottom: 4,
|
||||
},
|
||||
healthSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
marginBottom: 12,
|
||||
},
|
||||
healthRecommendation: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 6,
|
||||
paddingRight: 20,
|
||||
},
|
||||
recommendationText: {
|
||||
fontSize: 11,
|
||||
flex: 1,
|
||||
},
|
||||
cardArrow: {
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
},
|
||||
suggestionSection: {
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 32,
|
||||
},
|
||||
suggestionTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
},
|
||||
suggestionCard: {
|
||||
flexDirection: 'row',
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
gap: 16,
|
||||
},
|
||||
suggestionIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
suggestionContent: {
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
suggestionMainText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
color: '#192126',
|
||||
},
|
||||
suggestionSubText: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
},
|
||||
startButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
marginTop: 8,
|
||||
gap: 6,
|
||||
},
|
||||
startButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
},
|
||||
bottomSpacing: {
|
||||
height: 100,
|
||||
},
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -157,14 +158,7 @@ export default function EditProfileScreen() {
|
||||
<StatusBar barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'} />
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}>
|
||||
<ScrollView contentContainerStyle={{ paddingBottom: 40 }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
|
||||
{/* 统一头部 */}
|
||||
<View style={[styles.header]}>
|
||||
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color="#192126" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>编辑资料</Text>
|
||||
<View style={{ width: 32 }} />
|
||||
</View>
|
||||
<HeaderBar title="编辑资料" onBack={() => router.back()} withSafeTop={false} transparent />
|
||||
|
||||
{/* 头像(带相机蒙层,点击从相册选择) */}
|
||||
<View style={{ alignItems: 'center', marginTop: 4, marginBottom: 16 }}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -6,12 +7,12 @@ import * as Haptics from 'expo-haptics';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
@@ -56,34 +57,34 @@ export default function GoalsScreen() {
|
||||
try {
|
||||
const parsed = JSON.parse(p);
|
||||
if (Array.isArray(parsed)) setPurposes(parsed.filter((x) => typeof x === 'string'));
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => {});
|
||||
AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => { });
|
||||
}, [calories]);
|
||||
|
||||
useEffect(() => {
|
||||
AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => {});
|
||||
AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => { });
|
||||
}, [steps]);
|
||||
|
||||
useEffect(() => {
|
||||
AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => {});
|
||||
AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => { });
|
||||
}, [purposes]);
|
||||
|
||||
const caloriesPercent = useMemo(() =>
|
||||
(Math.min(CALORIES_RANGE.max, Math.max(CALORIES_RANGE.min, calories)) - CALORIES_RANGE.min) /
|
||||
(CALORIES_RANGE.max - CALORIES_RANGE.min),
|
||||
[calories]);
|
||||
[calories]);
|
||||
|
||||
const stepsPercent = useMemo(() =>
|
||||
(Math.min(STEPS_RANGE.max, Math.max(STEPS_RANGE.min, steps)) - STEPS_RANGE.min) /
|
||||
(STEPS_RANGE.max - STEPS_RANGE.min),
|
||||
[steps]);
|
||||
[steps]);
|
||||
|
||||
const changeWithHaptics = (next: number, setter: (v: number) => void) => {
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
@@ -101,34 +102,34 @@ export default function GoalsScreen() {
|
||||
|
||||
const SectionCard: React.FC<{ title: string; subtitle?: string; children: React.ReactNode }>
|
||||
= ({ title, subtitle, children }) => (
|
||||
<View style={[styles.card, { backgroundColor: colors.surface }]}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>{title}</Text>
|
||||
{subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textSecondary }]}>{subtitle}</Text> : null}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
<View style={[styles.card, { backgroundColor: colors.surface }]}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>{title}</Text>
|
||||
{subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textSecondary }]}>{subtitle}</Text> : null}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
const PresetChip: React.FC<{ label: string; active?: boolean; onPress: () => void }>
|
||||
= ({ label, active, onPress }) => (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={[styles.chip, { backgroundColor: active ? colors.primary : colors.card, borderColor: colors.border }]}
|
||||
>
|
||||
<Text style={[styles.chipText, { color: active ? colors.onPrimary : colors.text }]}>{label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={[styles.chip, { backgroundColor: active ? colors.primary : colors.card, borderColor: colors.border }]}
|
||||
>
|
||||
<Text style={[styles.chipText, { color: active ? colors.onPrimary : colors.text }]}>{label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const Stepper: React.FC<{ onDec: () => void; onInc: () => void }>
|
||||
= ({ onDec, onInc }) => (
|
||||
<View style={styles.stepperRow}>
|
||||
<TouchableOpacity onPress={onDec} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<Text style={[styles.stepperText, { color: colors.text }]}>-</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={onInc} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<Text style={[styles.stepperText, { color: colors.text }]}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
<View style={styles.stepperRow}>
|
||||
<TouchableOpacity onPress={onDec} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<Text style={[styles.stepperText, { color: colors.text }]}>-</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={onInc} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<Text style={[styles.stepperText, { color: colors.text }]}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
const PURPOSE_OPTIONS: { id: string; label: string; icon: any }[] = [
|
||||
{ id: 'core', label: '增强核心力量', icon: 'barbell-outline' },
|
||||
@@ -152,19 +153,8 @@ export default function GoalsScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? '#F5F5F5' : colors.background }]}>
|
||||
{/* Header(参照 AI 体态测评页面的实现) */}
|
||||
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
onPress={() => router.back()}
|
||||
style={[styles.backButton, { backgroundColor: theme === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)' }]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={theme === 'dark' ? '#ECEDEE' : colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.headerTitle, { color: theme === 'dark' ? '#ECEDEE' : colors.text }]}>目标管理</Text>
|
||||
<View style={{ width: 32 }} />
|
||||
</View>
|
||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? '#F5F5F5' : colors.background }]}>
|
||||
<HeaderBar title="目标管理" onBack={() => router.back()} withSafeTop={false} tone={theme} transparent />
|
||||
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: Math.max(20, insets.bottom + 16) }]}
|
||||
|
||||
468
app/training-plan.tsx
Normal file
468
app/training-plan.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Pressable, SafeAreaView, ScrollView, StyleSheet, TextInput, View } from 'react-native';
|
||||
import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { palette } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import {
|
||||
loadTrainingPlan,
|
||||
saveTrainingPlan,
|
||||
setGoal,
|
||||
setMode,
|
||||
setPreferredTime,
|
||||
setSessionsPerWeek,
|
||||
setStartDate,
|
||||
setStartDateNextMonday,
|
||||
setStartWeight,
|
||||
toggleDayOfWeek,
|
||||
type PlanGoal
|
||||
} from '@/store/trainingPlanSlice';
|
||||
|
||||
const WEEK_DAYS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
const GOALS: { key: PlanGoal; title: string; desc: string }[] = [
|
||||
{ key: 'postpartum_recovery', title: '产后恢复', desc: '温和激活,核心重建' },
|
||||
{ key: 'posture_correction', title: '体态矫正', desc: '打开胸肩,改善圆肩驼背' },
|
||||
{ key: 'fat_loss', title: '减脂塑形', desc: '全身燃脂,线条雕刻' },
|
||||
{ key: 'core_strength', title: '核心力量', desc: '核心稳定,提升运动表现' },
|
||||
{ key: 'flexibility', title: '柔韧灵活', desc: '拉伸延展,释放紧张' },
|
||||
{ key: 'rehab', title: '康复保健', desc: '循序渐进,科学修复' },
|
||||
{ key: 'stress_relief', title: '释压放松', desc: '舒缓身心,改善睡眠' },
|
||||
];
|
||||
|
||||
export default function TrainingPlanScreen() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const { draft, current } = useAppSelector((s) => s.trainingPlan);
|
||||
const [weightInput, setWeightInput] = useState<string>('');
|
||||
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadTrainingPlan());
|
||||
return () => {
|
||||
// 离开页面不自动 reset,保留草稿
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (draft.startWeightKg && !weightInput) setWeightInput(String(draft.startWeightKg));
|
||||
}, [draft.startWeightKg]);
|
||||
|
||||
const selectedCount = draft.mode === 'daysOfWeek' ? draft.daysOfWeek.length : draft.sessionsPerWeek;
|
||||
|
||||
const canSave = useMemo(() => {
|
||||
if (!draft.goal) return false;
|
||||
if (draft.mode === 'daysOfWeek' && draft.daysOfWeek.length === 0) return false;
|
||||
if (draft.mode === 'sessionsPerWeek' && draft.sessionsPerWeek <= 0) return false;
|
||||
return true;
|
||||
}, [draft]);
|
||||
|
||||
const handleSave = async () => {
|
||||
await dispatch(saveTrainingPlan()).unwrap().catch(() => { });
|
||||
router.back();
|
||||
};
|
||||
|
||||
const openDatePicker = () => setDatePickerVisible(true);
|
||||
const closeDatePicker = () => setDatePickerVisible(false);
|
||||
const onConfirmDate = (date: Date) => {
|
||||
// 只允许今天之后(含今天)的日期
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const picked = new Date(date);
|
||||
picked.setHours(0, 0, 0, 0);
|
||||
const finalDate = picked < today ? today : picked;
|
||||
dispatch(setStartDate(finalDate.toISOString()));
|
||||
closeDatePicker();
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<ThemedView style={styles.container}>
|
||||
<HeaderBar title="训练计划" onBack={() => router.back()} withSafeTop={false} transparent />
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
|
||||
<ThemedText style={styles.title}>制定你的训练计划</ThemedText>
|
||||
<ThemedText style={styles.subtitle}>选择你的训练节奏与目标,我们将为你生成合适的普拉提安排。</ThemedText>
|
||||
|
||||
<View style={styles.card}>
|
||||
<ThemedText style={styles.cardTitle}>训练频率</ThemedText>
|
||||
<View style={styles.segment}>
|
||||
<Pressable
|
||||
onPress={() => dispatch(setMode('daysOfWeek'))}
|
||||
style={[styles.segmentItem, draft.mode === 'daysOfWeek' && styles.segmentItemActive]}
|
||||
>
|
||||
<ThemedText style={[styles.segmentText, draft.mode === 'daysOfWeek' && styles.segmentTextActive]}>按星期选择</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => dispatch(setMode('sessionsPerWeek'))}
|
||||
style={[styles.segmentItem, draft.mode === 'sessionsPerWeek' && styles.segmentItemActive]}
|
||||
>
|
||||
<ThemedText style={[styles.segmentText, draft.mode === 'sessionsPerWeek' && styles.segmentTextActive]}>每周次数</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{draft.mode === 'daysOfWeek' ? (
|
||||
<View style={styles.weekRow}>
|
||||
{WEEK_DAYS.map((d, i) => {
|
||||
const active = draft.daysOfWeek.includes(i);
|
||||
return (
|
||||
<Pressable key={i} onPress={() => dispatch(toggleDayOfWeek(i))} style={[styles.dayChip, active && styles.dayChipActive]}>
|
||||
<ThemedText style={[styles.dayChipText, active && styles.dayChipTextActive]}>{d}</ThemedText>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.sliderRow}>
|
||||
<ThemedText style={styles.sliderLabel}>每周训练</ThemedText>
|
||||
<View style={styles.counter}>
|
||||
<Pressable onPress={() => dispatch(setSessionsPerWeek(Math.max(1, draft.sessionsPerWeek - 1)))} style={styles.counterBtn}>
|
||||
<ThemedText style={styles.counterBtnText}>-</ThemedText>
|
||||
</Pressable>
|
||||
<ThemedText style={styles.counterValue}>{draft.sessionsPerWeek}</ThemedText>
|
||||
<Pressable onPress={() => dispatch(setSessionsPerWeek(Math.min(7, draft.sessionsPerWeek + 1)))} style={styles.counterBtn}>
|
||||
<ThemedText style={styles.counterBtnText}>+</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
<ThemedText style={styles.sliderSuffix}>次</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ThemedText style={styles.helper}>已选择:{selectedCount} 次/周</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<ThemedText style={styles.cardTitle}>训练目标</ThemedText>
|
||||
<View style={styles.goalGrid}>
|
||||
{GOALS.map((g) => {
|
||||
const active = draft.goal === g.key;
|
||||
return (
|
||||
<Pressable key={g.key} onPress={() => dispatch(setGoal(g.key))} style={[styles.goalItem, active && styles.goalItemActive]}>
|
||||
<ThemedText style={[styles.goalTitle, active && styles.goalTitleActive]}>{g.title}</ThemedText>
|
||||
<ThemedText style={styles.goalDesc}>{g.desc}</ThemedText>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<ThemedText style={styles.cardTitle}>更多选项</ThemedText>
|
||||
<View style={styles.rowBetween}>
|
||||
<ThemedText style={styles.label}>开始日期</ThemedText>
|
||||
<View style={styles.rowRight}>
|
||||
<Pressable onPress={openDatePicker} style={styles.linkBtn}>
|
||||
<ThemedText style={styles.linkText}>选择日期</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => dispatch(setStartDateNextMonday())} style={[styles.linkBtn, { marginLeft: 8 }]}>
|
||||
<ThemedText style={styles.linkText}>下周一</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<ThemedText style={styles.dateHint}>{new Date(draft.startDate).toLocaleDateString()}</ThemedText>
|
||||
|
||||
<View style={styles.rowBetween}>
|
||||
<ThemedText style={styles.label}>开始体重 (kg)</ThemedText>
|
||||
<TextInput
|
||||
keyboardType="numeric"
|
||||
placeholder="可选"
|
||||
value={weightInput}
|
||||
onChangeText={(t) => {
|
||||
setWeightInput(t);
|
||||
const v = Number(t);
|
||||
dispatch(setStartWeight(Number.isFinite(v) ? v : undefined));
|
||||
}}
|
||||
style={styles.input}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.rowBetween, { marginTop: 12 }]}>
|
||||
<ThemedText style={styles.label}>偏好时间段</ThemedText>
|
||||
<View style={styles.segmentSmall}>
|
||||
{(['morning', 'noon', 'evening', ''] as const).map((k) => (
|
||||
<Pressable key={k || 'none'} onPress={() => dispatch(setPreferredTime(k))} style={[styles.segmentItemSmall, draft.preferredTimeOfDay === k && styles.segmentItemActiveSmall]}>
|
||||
<ThemedText style={[styles.segmentTextSmall, draft.preferredTimeOfDay === k && styles.segmentTextActiveSmall]}>{k === 'morning' ? '晨练' : k === 'noon' ? '午间' : k === 'evening' ? '晚间' : '不限'}</ThemedText>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable disabled={!canSave} onPress={handleSave} style={[styles.primaryBtn, !canSave && styles.primaryBtnDisabled]}>
|
||||
<ThemedText style={styles.primaryBtnText}>{canSave ? '生成计划' : '请先选择目标/频率'}</ThemedText>
|
||||
</Pressable>
|
||||
|
||||
<View style={{ height: 32 }} />
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
<DateTimePickerModal
|
||||
isVisible={datePickerVisible}
|
||||
mode="date"
|
||||
minimumDate={new Date()}
|
||||
onConfirm={onConfirmDate}
|
||||
onCancel={closeDatePicker}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F7F8FA',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F7F8FA',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#1A1A1A',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#5E6468',
|
||||
marginTop: 6,
|
||||
marginBottom: 16,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginTop: 14,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 3,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
marginBottom: 12,
|
||||
},
|
||||
segment: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#F1F5F9',
|
||||
padding: 4,
|
||||
borderRadius: 999,
|
||||
},
|
||||
segmentItem: {
|
||||
flex: 1,
|
||||
borderRadius: 999,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
segmentItemActive: {
|
||||
backgroundColor: palette.primary,
|
||||
},
|
||||
segmentText: {
|
||||
fontSize: 14,
|
||||
color: '#475569',
|
||||
fontWeight: '600',
|
||||
},
|
||||
segmentTextActive: {
|
||||
color: palette.ink,
|
||||
},
|
||||
weekRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 14,
|
||||
},
|
||||
dayChip: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F1F5F9',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dayChipActive: {
|
||||
backgroundColor: '#E0F8A2',
|
||||
borderWidth: 2,
|
||||
borderColor: palette.primary,
|
||||
},
|
||||
dayChipText: {
|
||||
fontSize: 16,
|
||||
color: '#334155',
|
||||
fontWeight: '700',
|
||||
},
|
||||
dayChipTextActive: {
|
||||
color: '#0F172A',
|
||||
},
|
||||
sliderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 16,
|
||||
},
|
||||
sliderLabel: {
|
||||
fontSize: 16,
|
||||
color: '#334155',
|
||||
fontWeight: '700',
|
||||
},
|
||||
counter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: 12,
|
||||
},
|
||||
counterBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
backgroundColor: '#F1F5F9',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
counterBtnText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
},
|
||||
counterValue: {
|
||||
width: 44,
|
||||
textAlign: 'center',
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
},
|
||||
sliderSuffix: {
|
||||
marginLeft: 8,
|
||||
color: '#475569',
|
||||
},
|
||||
helper: {
|
||||
marginTop: 10,
|
||||
color: '#5E6468',
|
||||
},
|
||||
goalGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
goalItem: {
|
||||
width: '48%',
|
||||
backgroundColor: '#F8FAFC',
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
goalItemActive: {
|
||||
backgroundColor: '#E0F8A2',
|
||||
borderColor: palette.primary,
|
||||
borderWidth: 2,
|
||||
},
|
||||
goalTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
},
|
||||
goalTitleActive: {
|
||||
color: '#0F172A',
|
||||
},
|
||||
goalDesc: {
|
||||
marginTop: 6,
|
||||
fontSize: 12,
|
||||
color: '#5E6468',
|
||||
lineHeight: 16,
|
||||
},
|
||||
rowBetween: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 6,
|
||||
},
|
||||
rowRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
color: '#0F172A',
|
||||
fontWeight: '700',
|
||||
},
|
||||
linkBtn: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: '#F1F5F9',
|
||||
},
|
||||
linkText: {
|
||||
color: '#334155',
|
||||
fontWeight: '700',
|
||||
},
|
||||
dateHint: {
|
||||
marginTop: 6,
|
||||
color: '#5E6468',
|
||||
},
|
||||
input: {
|
||||
marginLeft: 12,
|
||||
backgroundColor: '#F1F5F9',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
minWidth: 88,
|
||||
textAlign: 'right',
|
||||
color: '#0F172A',
|
||||
},
|
||||
segmentSmall: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#F1F5F9',
|
||||
padding: 3,
|
||||
borderRadius: 999,
|
||||
},
|
||||
segmentItemSmall: {
|
||||
borderRadius: 999,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 10,
|
||||
marginHorizontal: 3,
|
||||
},
|
||||
segmentItemActiveSmall: {
|
||||
backgroundColor: palette.primary,
|
||||
},
|
||||
segmentTextSmall: {
|
||||
fontSize: 12,
|
||||
color: '#475569',
|
||||
fontWeight: '700',
|
||||
},
|
||||
segmentTextActiveSmall: {
|
||||
color: palette.ink,
|
||||
},
|
||||
primaryBtn: {
|
||||
marginTop: 18,
|
||||
backgroundColor: palette.primary,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryBtnDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
primaryBtnText: {
|
||||
color: palette.ink,
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user