feat: 添加训练计划和打卡功能

- 新增训练计划页面,允许用户制定个性化的训练计划
- 集成打卡功能,用户可以记录每日的训练情况
- 更新 Redux 状态管理,添加训练计划相关的 reducer
- 在首页中添加训练计划卡片,支持用户点击跳转
- 更新样式和布局,以适应新功能的展示和交互
- 添加日期选择器和相关依赖,支持用户选择训练日期
This commit is contained in:
richarjiang
2025-08-13 09:10:00 +08:00
parent e0e000b64f
commit f3e6250505
24 changed files with 1898 additions and 609 deletions

View File

@@ -1,3 +1,4 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons';
@@ -6,12 +7,12 @@ import * as Haptics from 'expo-haptics';
import { useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import {
SafeAreaView,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
SafeAreaView,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -56,34 +57,34 @@ export default function GoalsScreen() {
try {
const parsed = JSON.parse(p);
if (Array.isArray(parsed)) setPurposes(parsed.filter((x) => typeof x === 'string'));
} catch {}
} catch { }
}
} catch {}
} catch { }
};
load();
}, []);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => {});
AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => { });
}, [calories]);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => {});
AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => { });
}, [steps]);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => {});
AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => { });
}, [purposes]);
const caloriesPercent = useMemo(() =>
(Math.min(CALORIES_RANGE.max, Math.max(CALORIES_RANGE.min, calories)) - CALORIES_RANGE.min) /
(CALORIES_RANGE.max - CALORIES_RANGE.min),
[calories]);
[calories]);
const stepsPercent = useMemo(() =>
(Math.min(STEPS_RANGE.max, Math.max(STEPS_RANGE.min, steps)) - STEPS_RANGE.min) /
(STEPS_RANGE.max - STEPS_RANGE.min),
[steps]);
[steps]);
const changeWithHaptics = (next: number, setter: (v: number) => void) => {
if (process.env.EXPO_OS === 'ios') {
@@ -101,34 +102,34 @@ export default function GoalsScreen() {
const SectionCard: React.FC<{ title: string; subtitle?: string; children: React.ReactNode }>
= ({ title, subtitle, children }) => (
<View style={[styles.card, { backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text }]}>{title}</Text>
{subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textSecondary }]}>{subtitle}</Text> : null}
{children}
</View>
);
<View style={[styles.card, { backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text }]}>{title}</Text>
{subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textSecondary }]}>{subtitle}</Text> : null}
{children}
</View>
);
const PresetChip: React.FC<{ label: string; active?: boolean; onPress: () => void }>
= ({ label, active, onPress }) => (
<TouchableOpacity
onPress={onPress}
style={[styles.chip, { backgroundColor: active ? colors.primary : colors.card, borderColor: colors.border }]}
>
<Text style={[styles.chipText, { color: active ? colors.onPrimary : colors.text }]}>{label}</Text>
</TouchableOpacity>
);
<TouchableOpacity
onPress={onPress}
style={[styles.chip, { backgroundColor: active ? colors.primary : colors.card, borderColor: colors.border }]}
>
<Text style={[styles.chipText, { color: active ? colors.onPrimary : colors.text }]}>{label}</Text>
</TouchableOpacity>
);
const Stepper: React.FC<{ onDec: () => void; onInc: () => void }>
= ({ onDec, onInc }) => (
<View style={styles.stepperRow}>
<TouchableOpacity onPress={onDec} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.stepperText, { color: colors.text }]}>-</Text>
</TouchableOpacity>
<TouchableOpacity onPress={onInc} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.stepperText, { color: colors.text }]}>+</Text>
</TouchableOpacity>
</View>
);
<View style={styles.stepperRow}>
<TouchableOpacity onPress={onDec} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.stepperText, { color: colors.text }]}>-</Text>
</TouchableOpacity>
<TouchableOpacity onPress={onInc} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.stepperText, { color: colors.text }]}>+</Text>
</TouchableOpacity>
</View>
);
const PURPOSE_OPTIONS: { id: string; label: string; icon: any }[] = [
{ id: 'core', label: '增强核心力量', icon: 'barbell-outline' },
@@ -152,19 +153,8 @@ export default function GoalsScreen() {
};
return (
<View style={[styles.container, { backgroundColor: theme === 'light' ? '#F5F5F5' : colors.background }]}>
{/* Header参照 AI 体态测评页面的实现) */}
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
<TouchableOpacity
accessibilityRole="button"
onPress={() => router.back()}
style={[styles.backButton, { backgroundColor: theme === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)' }]}
>
<Ionicons name="chevron-back" size={24} color={theme === 'dark' ? '#ECEDEE' : colors.text} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: theme === 'dark' ? '#ECEDEE' : colors.text }]}></Text>
<View style={{ width: 32 }} />
</View>
<View style={[styles.container, { backgroundColor: theme === 'light' ? '#F5F5F5' : colors.background }]}>
<HeaderBar title="目标管理" onBack={() => router.back()} withSafeTop={false} tone={theme} transparent />
<SafeAreaView style={styles.safeArea}>
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: Math.max(20, insets.bottom + 16) }]}