feat: 添加训练计划和打卡功能
- 新增训练计划页面,允许用户制定个性化的训练计划 - 集成打卡功能,用户可以记录每日的训练情况 - 更新 Redux 状态管理,添加训练计划相关的 reducer - 在首页中添加训练计划卡片,支持用户点击跳转 - 更新样式和布局,以适应新功能的展示和交互 - 添加日期选择器和相关依赖,支持用户选择训练日期
This commit is contained in:
@@ -3,6 +3,7 @@ import { CircularRing } from '@/components/CircularRing';
|
|||||||
import { ProgressBar } from '@/components/ProgressBar';
|
import { ProgressBar } from '@/components/ProgressBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { ensureHealthPermissions, fetchHealthDataForDate, fetchTodayHealthData } from '@/utils/health';
|
import { ensureHealthPermissions, fetchHealthDataForDate, fetchTodayHealthData } from '@/utils/health';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -20,6 +21,8 @@ import {
|
|||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
export default function ExploreScreen() {
|
export default function ExploreScreen() {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
// 使用 dayjs:当月日期与默认选中“今天”
|
// 使用 dayjs:当月日期与默认选中“今天”
|
||||||
const days = getMonthDaysZh();
|
const days = getMonthDaysZh();
|
||||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
@@ -104,8 +107,8 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
||||||
|
|||||||
@@ -2,28 +2,25 @@ import { PlanCard } from '@/components/PlanCard';
|
|||||||
import { SearchBox } from '@/components/SearchBox';
|
import { SearchBox } from '@/components/SearchBox';
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
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
|
// Removed WorkoutCard import since we no longer use the horizontal carousel
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { getChineseGreeting } from '@/utils/date';
|
import { getChineseGreeting } from '@/utils/date';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React from 'react';
|
||||||
import { Pressable, SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
|
import { Pressable, SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
|
||||||
|
|
||||||
// 移除旧的“热门活动”滑动数据,改为固定的“热点功能”卡片
|
// 移除旧的“热门活动”滑动数据,改为固定的“热点功能”卡片
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const hasOpenedLoginRef = useRef(false);
|
const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
useEffect(() => {
|
const colorTokens = Colors[theme];
|
||||||
// 仅在本次会话首次进入首页时打开登录页,可返回关闭
|
|
||||||
if (!hasOpenedLoginRef.current) {
|
|
||||||
hasOpenedLoginRef.current = true;
|
|
||||||
router.push('/auth/login');
|
|
||||||
}
|
|
||||||
}, [router]);
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||||
<ScrollView showsVerticalScrollIndicator={false}>
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@@ -52,7 +49,7 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.featureCard, styles.featureCardSecondary]}
|
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.featureTitle}>在线教练</ThemedText>
|
||||||
<ThemedText style={styles.featureSubtitle}>认证教练 · 1对1即时解答</ThemedText>
|
<ThemedText style={styles.featureSubtitle}>认证教练 · 1对1即时解答</ThemedText>
|
||||||
@@ -75,15 +72,33 @@ export default function HomeScreen() {
|
|||||||
level="初学者"
|
level="初学者"
|
||||||
progress={0}
|
progress={0}
|
||||||
/>
|
/>
|
||||||
<Pressable onPress={() => router.push('/challenge')}>
|
<Pressable onPress={() => pushIfAuthedElseLogin('/challenge')}>
|
||||||
<PlanCard
|
<PlanCard
|
||||||
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
|
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
|
||||||
title="30日训练打卡"
|
title="每周打卡"
|
||||||
subtitle="坚持30天,养成训练习惯"
|
subtitle="养成训练习惯,练出好身材"
|
||||||
level="初学者"
|
level="初学者"
|
||||||
progress={0.75}
|
progress={0.75}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</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>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export default function PersonalScreen() {
|
|||||||
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const colors = Colors[colorScheme ?? 'light'];
|
const colors = Colors[colorScheme ?? 'light'];
|
||||||
|
const theme = (colorScheme ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
|
||||||
type UserProfile = {
|
type UserProfile = {
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
@@ -287,11 +289,11 @@ export default function PersonalScreen() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||||
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={[styles.scrollView, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}
|
||||||
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -38,8 +38,10 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="onboarding" />
|
<Stack.Screen name="onboarding" />
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="profile/edit" />
|
<Stack.Screen name="profile/edit" />
|
||||||
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
<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="ai-posture-assessment" />
|
||||||
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
<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';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
|
|
||||||
type PoseView = 'front' | 'side' | 'back';
|
type PoseView = 'front' | 'side' | 'back';
|
||||||
@@ -172,18 +173,7 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
||||||
{/* Header */}
|
<HeaderBar title="AI体态测评" onBack={() => router.back()} tone="dark" transparent />
|
||||||
<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>
|
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
|
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import * as AppleAuthentication from 'expo-apple-authentication';
|
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 React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { Alert, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
@@ -14,6 +14,7 @@ import { login } from '@/store/userSlice';
|
|||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useLocalSearchParams<{ redirectTo?: string; redirectParams?: string }>();
|
||||||
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const color = Colors[scheme];
|
const color = Colors[scheme];
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -46,7 +47,18 @@ export default function LoginScreen() {
|
|||||||
});
|
});
|
||||||
const identityToken = (credential as any)?.identityToken;
|
const identityToken = (credential as any)?.identityToken;
|
||||||
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
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) {
|
} catch (err: any) {
|
||||||
if (err?.code === 'ERR_CANCELED') return;
|
if (err?.code === 'ERR_CANCELED') return;
|
||||||
const message = err?.message || '登录失败,请稍后再试';
|
const message = err?.message || '登录失败,请稍后再试';
|
||||||
@@ -54,12 +66,22 @@ export default function LoginScreen() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [appleAvailable, router]);
|
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo]);
|
||||||
|
|
||||||
const onGuestLogin = useCallback(() => {
|
const onGuestLogin = useCallback(() => {
|
||||||
// TODO: 标记为游客身份,可在此写入本地状态/上报统计
|
// 游客继续:若有 redirect 则前往,无则返回
|
||||||
router.back();
|
const to = searchParams?.redirectTo as string | undefined;
|
||||||
}, [router]);
|
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]);
|
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 { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { completeDay, setCustom } from '@/store/challengeSlice';
|
import { completeDay, setCustom } from '@/store/challengeSlice';
|
||||||
import type { Exercise, ExerciseCustomConfig } from '@/utils/pilatesPlan';
|
import type { Exercise, ExerciseCustomConfig } from '@/utils/pilatesPlan';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
@@ -55,17 +55,9 @@ export default function ChallengeDayScreen() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.header}>
|
<HeaderBar title={`第${plan.dayNumber}天`} onBack={() => router.back()} withSafeTop={false} transparent />
|
||||||
<View style={styles.headerRow}>
|
<Text style={styles.title}>{plan.title}</Text>
|
||||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton} accessibilityRole="button">
|
<Text style={styles.subtitle}>{plan.focus}</Text>
|
||||||
<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>
|
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={plan.exercises}
|
data={plan.exercises}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { initChallenge } from '@/store/challengeSlice';
|
import { initChallenge } from '@/store/challengeSlice';
|
||||||
import { estimateSessionMinutesWithCustom } from '@/utils/pilatesPlan';
|
import { estimateSessionMinutesWithCustom } from '@/utils/pilatesPlan';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -9,6 +11,7 @@ import { Dimensions, FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity,
|
|||||||
export default function ChallengeHomeScreen() {
|
export default function ChallengeHomeScreen() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { ensureLoggedIn } = useAuthGuard();
|
||||||
const challenge = useAppSelector((s) => (s as any).challenge);
|
const challenge = useAppSelector((s) => (s as any).challenge);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -24,16 +27,8 @@ export default function ChallengeHomeScreen() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.header}>
|
<HeaderBar title="30天普拉提打卡" onBack={() => router.back()} withSafeTop={false} transparent />
|
||||||
<View style={styles.headerRow}>
|
<Text style={styles.subtitle}>专注核心、体态与柔韧 · 连续完成解锁徽章</Text>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 进度环与统计 */}
|
{/* 进度环与统计 */}
|
||||||
<View style={styles.summaryCard}>
|
<View style={styles.summaryCard}>
|
||||||
@@ -45,7 +40,7 @@ export default function ChallengeHomeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.summaryRight}>
|
<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?.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>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -64,7 +59,10 @@ export default function ChallengeHomeScreen() {
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={isLocked}
|
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]}
|
style={[styles.dayCell, isLocked && styles.dayCellLocked, isCompleted && styles.dayCellCompleted]}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
@@ -79,7 +77,10 @@ export default function ChallengeHomeScreen() {
|
|||||||
|
|
||||||
{/* 底部 CTA */}
|
{/* 底部 CTA */}
|
||||||
<View style={styles.bottomBar}>
|
<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>
|
<Text style={styles.startButtonText}>开始今日训练</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</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 { Colors } from '@/constants/Colors';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -157,14 +158,7 @@ export default function EditProfileScreen() {
|
|||||||
<StatusBar barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'} />
|
<StatusBar barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'} />
|
||||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}>
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}>
|
||||||
<ScrollView contentContainerStyle={{ paddingBottom: 40 }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
|
<ScrollView contentContainerStyle={{ paddingBottom: 40 }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
|
||||||
{/* 统一头部 */}
|
<HeaderBar title="编辑资料" onBack={() => router.back()} withSafeTop={false} transparent />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 头像(带相机蒙层,点击从相册选择) */}
|
{/* 头像(带相机蒙层,点击从相册选择) */}
|
||||||
<View style={{ alignItems: 'center', marginTop: 4, marginBottom: 16 }}>
|
<View style={{ alignItems: 'center', marginTop: 4, marginBottom: 16 }}>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -6,12 +7,12 @@ import * as Haptics from 'expo-haptics';
|
|||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
@@ -56,34 +57,34 @@ export default function GoalsScreen() {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(p);
|
const parsed = JSON.parse(p);
|
||||||
if (Array.isArray(parsed)) setPurposes(parsed.filter((x) => typeof x === 'string'));
|
if (Array.isArray(parsed)) setPurposes(parsed.filter((x) => typeof x === 'string'));
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => {});
|
AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => { });
|
||||||
}, [calories]);
|
}, [calories]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => {});
|
AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => { });
|
||||||
}, [steps]);
|
}, [steps]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => {});
|
AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => { });
|
||||||
}, [purposes]);
|
}, [purposes]);
|
||||||
|
|
||||||
const caloriesPercent = useMemo(() =>
|
const caloriesPercent = useMemo(() =>
|
||||||
(Math.min(CALORIES_RANGE.max, Math.max(CALORIES_RANGE.min, calories)) - CALORIES_RANGE.min) /
|
(Math.min(CALORIES_RANGE.max, Math.max(CALORIES_RANGE.min, calories)) - CALORIES_RANGE.min) /
|
||||||
(CALORIES_RANGE.max - CALORIES_RANGE.min),
|
(CALORIES_RANGE.max - CALORIES_RANGE.min),
|
||||||
[calories]);
|
[calories]);
|
||||||
|
|
||||||
const stepsPercent = useMemo(() =>
|
const stepsPercent = useMemo(() =>
|
||||||
(Math.min(STEPS_RANGE.max, Math.max(STEPS_RANGE.min, steps)) - STEPS_RANGE.min) /
|
(Math.min(STEPS_RANGE.max, Math.max(STEPS_RANGE.min, steps)) - STEPS_RANGE.min) /
|
||||||
(STEPS_RANGE.max - STEPS_RANGE.min),
|
(STEPS_RANGE.max - STEPS_RANGE.min),
|
||||||
[steps]);
|
[steps]);
|
||||||
|
|
||||||
const changeWithHaptics = (next: number, setter: (v: number) => void) => {
|
const changeWithHaptics = (next: number, setter: (v: number) => void) => {
|
||||||
if (process.env.EXPO_OS === 'ios') {
|
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 }>
|
const SectionCard: React.FC<{ title: string; subtitle?: string; children: React.ReactNode }>
|
||||||
= ({ title, subtitle, children }) => (
|
= ({ title, subtitle, children }) => (
|
||||||
<View style={[styles.card, { backgroundColor: colors.surface }]}>
|
<View style={[styles.card, { backgroundColor: colors.surface }]}>
|
||||||
<Text style={[styles.cardTitle, { color: colors.text }]}>{title}</Text>
|
<Text style={[styles.cardTitle, { color: colors.text }]}>{title}</Text>
|
||||||
{subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textSecondary }]}>{subtitle}</Text> : null}
|
{subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textSecondary }]}>{subtitle}</Text> : null}
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
const PresetChip: React.FC<{ label: string; active?: boolean; onPress: () => void }>
|
const PresetChip: React.FC<{ label: string; active?: boolean; onPress: () => void }>
|
||||||
= ({ label, active, onPress }) => (
|
= ({ label, active, onPress }) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[styles.chip, { backgroundColor: active ? colors.primary : colors.card, borderColor: colors.border }]}
|
style={[styles.chip, { backgroundColor: active ? colors.primary : colors.card, borderColor: colors.border }]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.chipText, { color: active ? colors.onPrimary : colors.text }]}>{label}</Text>
|
<Text style={[styles.chipText, { color: active ? colors.onPrimary : colors.text }]}>{label}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Stepper: React.FC<{ onDec: () => void; onInc: () => void }>
|
const Stepper: React.FC<{ onDec: () => void; onInc: () => void }>
|
||||||
= ({ onDec, onInc }) => (
|
= ({ onDec, onInc }) => (
|
||||||
<View style={styles.stepperRow}>
|
<View style={styles.stepperRow}>
|
||||||
<TouchableOpacity onPress={onDec} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
<TouchableOpacity onPress={onDec} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||||
<Text style={[styles.stepperText, { color: colors.text }]}>-</Text>
|
<Text style={[styles.stepperText, { color: colors.text }]}>-</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity onPress={onInc} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
<TouchableOpacity onPress={onInc} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||||
<Text style={[styles.stepperText, { color: colors.text }]}>+</Text>
|
<Text style={[styles.stepperText, { color: colors.text }]}>+</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
const PURPOSE_OPTIONS: { id: string; label: string; icon: any }[] = [
|
const PURPOSE_OPTIONS: { id: string; label: string; icon: any }[] = [
|
||||||
{ id: 'core', label: '增强核心力量', icon: 'barbell-outline' },
|
{ id: 'core', label: '增强核心力量', icon: 'barbell-outline' },
|
||||||
@@ -153,18 +154,7 @@ export default function GoalsScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? '#F5F5F5' : colors.background }]}>
|
<View style={[styles.container, { backgroundColor: theme === 'light' ? '#F5F5F5' : colors.background }]}>
|
||||||
{/* Header(参照 AI 体态测评页面的实现) */}
|
<HeaderBar title="目标管理" onBack={() => router.back()} withSafeTop={false} tone={theme} transparent />
|
||||||
<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>
|
|
||||||
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea}>
|
||||||
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: Math.max(20, insets.bottom + 16) }]}
|
<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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
86
components/ui/HeaderBar.tsx
Normal file
86
components/ui/HeaderBar.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
|
||||||
|
export type HeaderBarProps = {
|
||||||
|
title: string | React.ReactNode;
|
||||||
|
onBack?: () => void;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
tone?: 'light' | 'dark';
|
||||||
|
showBottomBorder?: boolean;
|
||||||
|
withSafeTop?: boolean;
|
||||||
|
transparent?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HeaderBar({
|
||||||
|
title,
|
||||||
|
onBack,
|
||||||
|
right,
|
||||||
|
tone,
|
||||||
|
showBottomBorder = false,
|
||||||
|
withSafeTop = true,
|
||||||
|
transparent = true,
|
||||||
|
}: HeaderBarProps) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const colorScheme = useColorScheme() ?? 'light';
|
||||||
|
const theme = Colors[tone ?? colorScheme];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.header,
|
||||||
|
{
|
||||||
|
paddingTop: (withSafeTop ? insets.top : 0) + 8,
|
||||||
|
backgroundColor: transparent ? 'transparent' : theme.card,
|
||||||
|
borderBottomWidth: showBottomBorder ? 1 : 0,
|
||||||
|
borderBottomColor: theme.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{onBack ? (
|
||||||
|
<TouchableOpacity accessibilityRole="button" onPress={onBack} style={[styles.backButton, { backgroundColor: 'rgba(187,242,70,0.2)' }]}>
|
||||||
|
<Ionicons name="chevron-back" size={20} color={theme.onPrimary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View style={{ width: 32 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
{typeof title === 'string' ? (
|
||||||
|
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
||||||
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{right ?? <View style={{ width: 32 }} />}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 10,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -62,6 +62,12 @@ export const Colors = {
|
|||||||
tabIconSelected: palette.ink, // tab 激活时的文字/图标颜色(深色,在亮色背景上显示)
|
tabIconSelected: palette.ink, // tab 激活时的文字/图标颜色(深色,在亮色背景上显示)
|
||||||
tabBarBackground: palette.ink, // tab 栏背景色
|
tabBarBackground: palette.ink, // tab 栏背景色
|
||||||
tabBarActiveBackground: primaryColor, // tab 激活时的背景色
|
tabBarActiveBackground: primaryColor, // tab 激活时的背景色
|
||||||
|
|
||||||
|
// 页面氛围与装饰(新)
|
||||||
|
pageBackgroundEmphasis: '#F9FBF2',
|
||||||
|
heroSurfaceTint: 'rgba(187,242,70,0.18)',
|
||||||
|
ornamentPrimary: 'rgba(187,242,70,0.22)',
|
||||||
|
ornamentAccent: 'rgba(164,138,237,0.16)',
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
// 基础文本/背景
|
// 基础文本/背景
|
||||||
@@ -99,5 +105,11 @@ export const Colors = {
|
|||||||
tabIconSelected: palette.ink, // 在亮色背景上使用深色文字
|
tabIconSelected: palette.ink, // 在亮色背景上使用深色文字
|
||||||
tabBarBackground: palette.ink,
|
tabBarBackground: palette.ink,
|
||||||
tabBarActiveBackground: primaryColor,
|
tabBarActiveBackground: primaryColor,
|
||||||
|
|
||||||
|
// 页面氛围与装饰(新)
|
||||||
|
pageBackgroundEmphasis: '#151718',
|
||||||
|
heroSurfaceTint: 'rgba(187,242,70,0.12)',
|
||||||
|
ornamentPrimary: 'rgba(187,242,70,0.18)',
|
||||||
|
ornamentAccent: 'rgba(164,138,237,0.14)',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
63
hooks/useAuthGuard.ts
Normal file
63
hooks/useAuthGuard.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { usePathname, useRouter } from 'expo-router';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useAppSelector } from '@/hooks/redux';
|
||||||
|
|
||||||
|
type RedirectParams = Record<string, string | number | boolean | undefined>;
|
||||||
|
|
||||||
|
type EnsureOptions = {
|
||||||
|
redirectTo?: string;
|
||||||
|
redirectParams?: RedirectParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthGuard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const currentPath = usePathname();
|
||||||
|
const token = useAppSelector((s) => (s as any)?.user?.token as string | null);
|
||||||
|
const isLoggedIn = !!token;
|
||||||
|
|
||||||
|
const ensureLoggedIn = useCallback(async (options?: EnsureOptions): Promise<boolean> => {
|
||||||
|
if (isLoggedIn) return true;
|
||||||
|
|
||||||
|
const redirectTo = options?.redirectTo ?? currentPath ?? '/(tabs)';
|
||||||
|
const paramsJson = options?.redirectParams ? JSON.stringify(options.redirectParams) : undefined;
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
pathname: '/auth/login',
|
||||||
|
params: {
|
||||||
|
redirectTo,
|
||||||
|
...(paramsJson ? { redirectParams: paramsJson } : {}),
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
return false;
|
||||||
|
}, [isLoggedIn, router, currentPath]);
|
||||||
|
|
||||||
|
const pushIfAuthedElseLogin = useCallback((pathname: string, params?: RedirectParams) => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
router.push({ pathname, params } as any);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const paramsJson = params ? JSON.stringify(params) : undefined;
|
||||||
|
router.push({ pathname: '/auth/login', params: { redirectTo: pathname, ...(paramsJson ? { redirectParams: paramsJson } : {}) } } as any);
|
||||||
|
}, [isLoggedIn, router]);
|
||||||
|
|
||||||
|
const guardHandler = useCallback(
|
||||||
|
<T extends any[]>(fn: (...args: T) => any | Promise<any>, options?: EnsureOptions) => {
|
||||||
|
return async (...args: T) => {
|
||||||
|
const ok = await ensureLoggedIn(options);
|
||||||
|
if (!ok) return;
|
||||||
|
return fn(...args);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[ensureLoggedIn]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoggedIn,
|
||||||
|
ensureLoggedIn,
|
||||||
|
pushIfAuthedElseLogin,
|
||||||
|
guardHandler,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
40
package-lock.json
generated
40
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^14.1.0",
|
"@expo/vector-icons": "^14.1.0",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
|
"@react-native-community/datetimepicker": "^8.4.4",
|
||||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||||
"@react-navigation/elements": "^2.3.8",
|
"@react-navigation/elements": "^2.3.8",
|
||||||
"@react-navigation/native": "^7.1.6",
|
"@react-navigation/native": "^7.1.6",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"react-native": "0.79.5",
|
"react-native": "0.79.5",
|
||||||
"react-native-gesture-handler": "~2.24.0",
|
"react-native-gesture-handler": "~2.24.0",
|
||||||
"react-native-health": "^1.19.0",
|
"react-native-health": "^1.19.0",
|
||||||
|
"react-native-modal-datetime-picker": "^18.0.0",
|
||||||
"react-native-reanimated": "~3.17.4",
|
"react-native-reanimated": "~3.17.4",
|
||||||
"react-native-safe-area-context": "5.4.0",
|
"react-native-safe-area-context": "5.4.0",
|
||||||
"react-native-screens": "~4.11.1",
|
"react-native-screens": "~4.11.1",
|
||||||
@@ -2764,6 +2766,29 @@
|
|||||||
"react-native": "^0.0.0-0 || >=0.65 <1.0"
|
"react-native": "^0.0.0-0 || >=0.65 <1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-native-community/datetimepicker": {
|
||||||
|
"version": "8.4.4",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/@react-native-community/datetimepicker/-/datetimepicker-8.4.4.tgz",
|
||||||
|
"integrity": "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"invariant": "^2.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": ">=52.0.0",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*",
|
||||||
|
"react-native-windows": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"expo": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native-windows": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-native/assets-registry": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.79.5",
|
"version": "0.79.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.79.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.79.5.tgz",
|
||||||
@@ -10225,7 +10250,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -10237,7 +10261,6 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
@@ -10678,6 +10701,19 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-modal-datetime-picker": {
|
||||||
|
"version": "18.0.0",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/react-native-modal-datetime-picker/-/react-native-modal-datetime-picker-18.0.0.tgz",
|
||||||
|
"integrity": "sha512-0jdvhhraZQlRACwr7pM6vmZ2kxgzJ4CpnmV6J3TVA6MrXMXK6Zo/upRBKkRp0+fTOiKuNblzesA2U59rYo6SGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prop-types": "^15.7.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-native-community/datetimepicker": ">=6.7.0",
|
||||||
|
"react-native": ">=0.65.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-reanimated": {
|
"node_modules/react-native-reanimated": {
|
||||||
"version": "3.17.5",
|
"version": "3.17.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^14.1.0",
|
"@expo/vector-icons": "^14.1.0",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
|
"@react-native-community/datetimepicker": "^8.4.4",
|
||||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||||
"@react-navigation/elements": "^2.3.8",
|
"@react-navigation/elements": "^2.3.8",
|
||||||
"@react-navigation/native": "^7.1.6",
|
"@react-navigation/native": "^7.1.6",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"react-native": "0.79.5",
|
"react-native": "0.79.5",
|
||||||
"react-native-gesture-handler": "~2.24.0",
|
"react-native-gesture-handler": "~2.24.0",
|
||||||
"react-native-health": "^1.19.0",
|
"react-native-health": "^1.19.0",
|
||||||
|
"react-native-modal-datetime-picker": "^18.0.0",
|
||||||
"react-native-reanimated": "~3.17.4",
|
"react-native-reanimated": "~3.17.4",
|
||||||
"react-native-safe-area-context": "5.4.0",
|
"react-native-safe-area-context": "5.4.0",
|
||||||
"react-native-screens": "~4.11.1",
|
"react-native-screens": "~4.11.1",
|
||||||
|
|||||||
78
store/checkinSlice.ts
Normal file
78
store/checkinSlice.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export type CheckinExercise = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
sets: number; // 组数
|
||||||
|
reps?: number; // 每组重复(计次型)
|
||||||
|
durationSec?: number; // 每组时长(计时型)
|
||||||
|
completed?: boolean; // 是否已完成该动作
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckinRecord = {
|
||||||
|
id: string;
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
items: CheckinExercise[];
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckinState = {
|
||||||
|
byDate: Record<string, CheckinRecord>;
|
||||||
|
currentDate: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: CheckinState = {
|
||||||
|
byDate: {},
|
||||||
|
currentDate: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureRecord(state: CheckinState, date: string): CheckinRecord {
|
||||||
|
if (!state.byDate[date]) {
|
||||||
|
state.byDate[date] = {
|
||||||
|
id: `rec_${date}`,
|
||||||
|
date,
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return state.byDate[date];
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkinSlice = createSlice({
|
||||||
|
name: 'checkin',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setCurrentDate(state, action: PayloadAction<string>) {
|
||||||
|
state.currentDate = action.payload; // 期望格式 YYYY-MM-DD
|
||||||
|
ensureRecord(state, action.payload);
|
||||||
|
},
|
||||||
|
addExercise(state, action: PayloadAction<{ date: string; item: CheckinExercise }>) {
|
||||||
|
const rec = ensureRecord(state, action.payload.date);
|
||||||
|
// 若同 key 已存在则覆盖参数(更接近用户“重新选择/编辑”的心智)
|
||||||
|
const idx = rec.items.findIndex((it) => it.key === action.payload.item.key);
|
||||||
|
const normalized: CheckinExercise = { ...action.payload.item, completed: false };
|
||||||
|
if (idx >= 0) rec.items[idx] = normalized; else rec.items.push(normalized);
|
||||||
|
},
|
||||||
|
removeExercise(state, action: PayloadAction<{ date: string; key: string }>) {
|
||||||
|
const rec = ensureRecord(state, action.payload.date);
|
||||||
|
rec.items = rec.items.filter((it) => it.key !== action.payload.key);
|
||||||
|
},
|
||||||
|
toggleExerciseCompleted(state, action: PayloadAction<{ date: string; key: string }>) {
|
||||||
|
const rec = ensureRecord(state, action.payload.date);
|
||||||
|
const idx = rec.items.findIndex((it) => it.key === action.payload.key);
|
||||||
|
if (idx >= 0) rec.items[idx].completed = !rec.items[idx].completed;
|
||||||
|
},
|
||||||
|
setNote(state, action: PayloadAction<{ date: string; note: string }>) {
|
||||||
|
const rec = ensureRecord(state, action.payload.date);
|
||||||
|
rec.note = action.payload.note;
|
||||||
|
},
|
||||||
|
resetDate(state, action: PayloadAction<string>) {
|
||||||
|
delete state.byDate[action.payload];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setCurrentDate, addExercise, removeExercise, toggleExerciseCompleted, setNote, resetDate } = checkinSlice.actions;
|
||||||
|
export default checkinSlice.reducer;
|
||||||
|
|
||||||
|
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import challengeReducer from './challengeSlice';
|
import challengeReducer from './challengeSlice';
|
||||||
|
import checkinReducer from './checkinSlice';
|
||||||
|
import trainingPlanReducer from './trainingPlanSlice';
|
||||||
import userReducer from './userSlice';
|
import userReducer from './userSlice';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
user: userReducer,
|
user: userReducer,
|
||||||
challenge: challengeReducer,
|
challenge: challengeReducer,
|
||||||
|
checkin: checkinReducer,
|
||||||
|
trainingPlan: trainingPlanReducer,
|
||||||
},
|
},
|
||||||
// React Native 环境默认即可
|
// React Native 环境默认即可
|
||||||
});
|
});
|
||||||
|
|||||||
148
store/trainingPlanSlice.ts
Normal file
148
store/trainingPlanSlice.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export type PlanMode = 'daysOfWeek' | 'sessionsPerWeek';
|
||||||
|
|
||||||
|
export type PlanGoal =
|
||||||
|
| 'postpartum_recovery' // 产后恢复
|
||||||
|
| 'fat_loss' // 减脂塑形
|
||||||
|
| 'posture_correction' // 体态矫正
|
||||||
|
| 'core_strength' // 核心力量
|
||||||
|
| 'flexibility' // 柔韧灵活
|
||||||
|
| 'rehab' // 康复保健
|
||||||
|
| 'stress_relief'; // 释压放松
|
||||||
|
|
||||||
|
export type TrainingPlan = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string; // ISO
|
||||||
|
startDate: string; // ISO (当天或下周一)
|
||||||
|
mode: PlanMode;
|
||||||
|
daysOfWeek: number[]; // 0(日) - 6(六)
|
||||||
|
sessionsPerWeek: number; // 1..7
|
||||||
|
goal: PlanGoal | '';
|
||||||
|
startWeightKg?: number;
|
||||||
|
preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrainingPlanState = {
|
||||||
|
current?: TrainingPlan | null;
|
||||||
|
draft: Omit<TrainingPlan, 'id' | 'createdAt'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = '@training_plan';
|
||||||
|
|
||||||
|
function nextMondayISO(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const day = now.getDay();
|
||||||
|
const diff = (8 - day) % 7 || 7; // 距下周一的天数
|
||||||
|
const next = new Date(now);
|
||||||
|
next.setDate(now.getDate() + diff);
|
||||||
|
next.setHours(0, 0, 0, 0);
|
||||||
|
return next.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: TrainingPlanState = {
|
||||||
|
current: null,
|
||||||
|
draft: {
|
||||||
|
startDate: new Date(new Date().setHours(0, 0, 0, 0)).toISOString(),
|
||||||
|
mode: 'daysOfWeek',
|
||||||
|
daysOfWeek: [1, 3, 5],
|
||||||
|
sessionsPerWeek: 3,
|
||||||
|
goal: '',
|
||||||
|
startWeightKg: undefined,
|
||||||
|
preferredTimeOfDay: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadTrainingPlan = createAsyncThunk('trainingPlan/load', async () => {
|
||||||
|
const str = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!str) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(str) as TrainingPlan;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const saveTrainingPlan = createAsyncThunk(
|
||||||
|
'trainingPlan/save',
|
||||||
|
async (_: void, { getState }) => {
|
||||||
|
const s = (getState() as any).trainingPlan as TrainingPlanState;
|
||||||
|
const draft = s.draft;
|
||||||
|
const plan: TrainingPlan = {
|
||||||
|
id: `plan_${Date.now()}`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
...draft,
|
||||||
|
};
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(plan));
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const trainingPlanSlice = createSlice({
|
||||||
|
name: 'trainingPlan',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setMode(state, action: PayloadAction<PlanMode>) {
|
||||||
|
state.draft.mode = action.payload;
|
||||||
|
},
|
||||||
|
toggleDayOfWeek(state, action: PayloadAction<number>) {
|
||||||
|
const d = action.payload;
|
||||||
|
const set = new Set(state.draft.daysOfWeek);
|
||||||
|
if (set.has(d)) set.delete(d); else set.add(d);
|
||||||
|
state.draft.daysOfWeek = Array.from(set).sort();
|
||||||
|
},
|
||||||
|
setSessionsPerWeek(state, action: PayloadAction<number>) {
|
||||||
|
const n = Math.min(7, Math.max(1, action.payload));
|
||||||
|
state.draft.sessionsPerWeek = n;
|
||||||
|
},
|
||||||
|
setGoal(state, action: PayloadAction<TrainingPlan['goal']>) {
|
||||||
|
state.draft.goal = action.payload;
|
||||||
|
},
|
||||||
|
setStartWeight(state, action: PayloadAction<number | undefined>) {
|
||||||
|
state.draft.startWeightKg = action.payload;
|
||||||
|
},
|
||||||
|
setStartDate(state, action: PayloadAction<string>) {
|
||||||
|
state.draft.startDate = action.payload;
|
||||||
|
},
|
||||||
|
setPreferredTime(state, action: PayloadAction<TrainingPlan['preferredTimeOfDay']>) {
|
||||||
|
state.draft.preferredTimeOfDay = action.payload;
|
||||||
|
},
|
||||||
|
setStartDateNextMonday(state) {
|
||||||
|
state.draft.startDate = nextMondayISO();
|
||||||
|
},
|
||||||
|
resetDraft(state) {
|
||||||
|
state.draft = initialState.draft;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(loadTrainingPlan.fulfilled, (state, action) => {
|
||||||
|
state.current = action.payload;
|
||||||
|
// 若存在历史计划,初始化 draft 基于该计划(便于编辑)
|
||||||
|
if (action.payload) {
|
||||||
|
const { id, createdAt, ...rest } = action.payload;
|
||||||
|
state.draft = { ...rest };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(saveTrainingPlan.fulfilled, (state, action) => {
|
||||||
|
state.current = action.payload;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
setMode,
|
||||||
|
toggleDayOfWeek,
|
||||||
|
setSessionsPerWeek,
|
||||||
|
setGoal,
|
||||||
|
setStartWeight,
|
||||||
|
setStartDate,
|
||||||
|
setPreferredTime,
|
||||||
|
setStartDateNextMonday,
|
||||||
|
resetDraft,
|
||||||
|
} = trainingPlanSlice.actions;
|
||||||
|
|
||||||
|
export default trainingPlanSlice.reducer;
|
||||||
|
|
||||||
|
|
||||||
82
utils/exerciseLibrary.ts
Normal file
82
utils/exerciseLibrary.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
export type ExerciseCategory =
|
||||||
|
| '核心与腹部'
|
||||||
|
| '脊柱与后链'
|
||||||
|
| '侧链与髋'
|
||||||
|
| '平衡与支撑'
|
||||||
|
| '进阶控制'
|
||||||
|
| '柔韧与拉伸';
|
||||||
|
|
||||||
|
export type ExerciseLibraryItem = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: ExerciseCategory;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EXERCISE_LIBRARY: ExerciseLibraryItem[] = [
|
||||||
|
// 核心与腹部
|
||||||
|
{ key: 'hundred', name: '百次拍击 (The Hundred)', description: '仰卧桌面位,小幅摆臂协同吸呼,核心激活。', category: '核心与腹部' },
|
||||||
|
{ key: 'single_leg_stretch', name: '单腿伸展 (Single Leg Stretch)', description: '交替伸直一条腿,另一腿屈膝抱向胸口,稳定骨盆。', category: '核心与腹部' },
|
||||||
|
{ key: 'double_leg_stretch', name: '双腿伸展 (Double Leg Stretch)', description: '双臂双腿同时伸直/收回,呼吸控制,核心稳定。', category: '核心与腹部' },
|
||||||
|
{ key: 'criss_cross', name: '扭转卷腹 (Criss Cross)', description: '肘碰对侧膝,腹斜肌发力与呼吸配合。', category: '核心与腹部' },
|
||||||
|
{ key: 'single_straight_leg', name: '单腿直剪 (Scissors)', description: '交替拉长上抬直腿,控制下放,避免耸肩。', category: '核心与腹部' },
|
||||||
|
{ key: 'double_straight_leg', name: '双腿抬降 (Double Straight Leg Lower Lift)', description: '双腿并拢抬降,腹直肌抗伸展。', category: '核心与腹部' },
|
||||||
|
{ key: 'spine_stretch_forward', name: '脊柱前伸 (Spine Stretch Forward)', description: '坐姿脊柱分节前伸,强调轴向延展与呼吸。', category: '核心与腹部' },
|
||||||
|
{ key: 'roll_up', name: '卷起 (Roll Up)', description: '仰卧分节卷起到坐,前屈后还原,控制节律。', category: '核心与腹部' },
|
||||||
|
{ key: 'rolling_like_a_ball', name: '小球滚动 (Rolling Like a Ball)', description: '抱膝蜷成球,控制滚动回正,核心稳定。', category: '核心与腹部' },
|
||||||
|
{ key: 'teaser', name: '船式 (Teaser)', description: 'V字平衡,腿躯干对抗重力与摆动。', category: '核心与腹部' },
|
||||||
|
|
||||||
|
// 脊柱与后链
|
||||||
|
{ key: 'swan', name: '天鹅式 (Swan)', description: '俯卧伸展胸椎,后链发力,延展不挤压。', category: '脊柱与后链' },
|
||||||
|
{ key: 'swan_dive', name: '天鹅下潜 (Swan Dive)', description: '在Swan基础上前后摆动,弹性控制。', category: '脊柱与后链' },
|
||||||
|
{ key: 'swimming', name: '游泳式 (Swimming)', description: '俯卧交替抬对侧上肢与下肢,脊柱中立。', category: '脊柱与后链' },
|
||||||
|
{ key: 'shoulder_bridge', name: '肩桥 (Shoulder Bridge)', description: '仰卧卷尾抬盆,臀腿后侧力量与脊柱分节。', category: '脊柱与后链' },
|
||||||
|
{ key: 'spine_twist', name: '脊柱扭转 (Spine Twist)', description: '坐姿轴向延展上的控制旋转。', category: '脊柱与后链' },
|
||||||
|
{ key: 'saw', name: '锯式 (Saw)', description: '坐姿分腿,旋转前屈触对侧脚尖。', category: '脊柱与后链' },
|
||||||
|
|
||||||
|
// 侧链与髋
|
||||||
|
{ key: 'side_kick_front_back', name: '侧踢腿 前后摆 (Side Kick Front/Back)', description: '侧卧,髋稳定,腿前后摆动。', category: '侧链与髋' },
|
||||||
|
{ key: 'side_kick_up_down', name: '侧踢腿 上下 (Side Kick Up/Down)', description: '侧卧,上侧腿上抬下放,控制不耸肩。', category: '侧链与髋' },
|
||||||
|
{ key: 'side_leg_lift', name: '侧抬腿 (Side Leg Lift)', description: '侧卧抬腿,髋稳定,中臀肌激活。', category: '侧链与髋' },
|
||||||
|
{ key: 'clam', name: '蛤蜊式 (Clam)', description: '侧卧屈髋屈膝,抬起上侧膝,臀中刺激。', category: '侧链与髋' },
|
||||||
|
{ key: 'mermaid', name: '美人鱼 (Mermaid)', description: '坐姿侧屈拉伸,改善侧链柔韧。', category: '侧链与髋' },
|
||||||
|
|
||||||
|
// 平衡与支撑
|
||||||
|
{ key: 'plank', name: '平板支撑 (Plank)', description: '掌/前臂支撑,身体成一直线,核心稳定。', category: '平衡与支撑' },
|
||||||
|
{ key: 'side_plank', name: '侧板支撑 (Side Plank)', description: '单侧支撑,侧链与肩带稳定。', category: '平衡与支撑' },
|
||||||
|
{ key: 'push_up', name: '俯卧撑 (Push Up)', description: '胸肩臂与核心协同的推举支撑。', category: '平衡与支撑' },
|
||||||
|
{ key: 'leg_pull_front', name: '前拉腿 (Leg Pull Front)', description: '俯撑抬腿,后链与肩带控制。', category: '平衡与支撑' },
|
||||||
|
{ key: 'leg_pull_back', name: '后拉腿 (Leg Pull Back)', description: '仰撑抬腿,后链与肩带控制。', category: '平衡与支撑' },
|
||||||
|
|
||||||
|
// 进阶控制
|
||||||
|
{ key: 'jackknife', name: '折刀 (Jackknife)', description: '仰卧抬腿过头后折刀上推,核心与控制。', category: '进阶控制' },
|
||||||
|
{ key: 'open_leg_rocker', name: '开腿摇滚 (Open Leg Rocker)', description: '开腿V坐平衡,来回滚动。', category: '进阶控制' },
|
||||||
|
{ key: 'corkscrew', name: '开瓶器 (Corkscrew)', description: '躯干稳定下的双腿画圈,控制髋与核心。', category: '进阶控制' },
|
||||||
|
{ key: 'boomerang', name: '回旋镖 (Boomerang)', description: '融合Roll Over/Teaser的串联流畅控制。', category: '进阶控制' },
|
||||||
|
{ key: 'control_balance', name: '控制平衡 (Control Balance)', description: '过头位下的单腿抬降与控制。', category: '进阶控制' },
|
||||||
|
{ key: 'neck_pull', name: '抱颈卷起 (Neck Pull)', description: '更具挑战的卷起,背侧链参与更高。', category: '进阶控制' },
|
||||||
|
{ key: 'roll_over', name: '翻滚过头 (Roll Over)', description: '仰卧双腿过头落地,脊柱分节控制。', category: '进阶控制' },
|
||||||
|
|
||||||
|
// 柔韧与拉伸
|
||||||
|
{ key: 'cat_cow', name: '猫牛 (Cat-Cow)', description: '四点支撑的屈伸热身/整理放松。', category: '柔韧与拉伸' },
|
||||||
|
{ key: 'hamstring_stretch', name: '腘绳肌拉伸', description: '仰卧或坐姿,拉伸大腿后侧。', category: '柔韧与拉伸' },
|
||||||
|
{ key: 'hip_flexor_stretch', name: '髋屈肌拉伸', description: '弓步位,前髋前侧拉伸。', category: '柔韧与拉伸' },
|
||||||
|
{ key: 'thoracic_extension', name: '胸椎伸展', description: '泡沫轴/垫上胸椎延展放松。', category: '柔韧与拉伸' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getCategories(): ExerciseCategory[] {
|
||||||
|
const set = new Set<ExerciseCategory>();
|
||||||
|
EXERCISE_LIBRARY.forEach((e) => set.add(e.category));
|
||||||
|
return Array.from(set);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchExercises(keyword: string): ExerciseLibraryItem[] {
|
||||||
|
const kw = keyword.trim().toLowerCase();
|
||||||
|
if (!kw) return EXERCISE_LIBRARY;
|
||||||
|
return EXERCISE_LIBRARY.filter((e) =>
|
||||||
|
e.name.toLowerCase().includes(kw) ||
|
||||||
|
e.description.toLowerCase().includes(kw)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user