Files
digital-pilates/app/profile/goals.tsx
richarjiang 5814044cee feat: 更新 AI 教练聊天界面和个人信息页面
- 在 AI 教练聊天界面中添加训练记录分析功能,允许用户基于近期训练记录获取分析建议
- 更新 Redux 状态管理,集成每日步数和卡路里目标
- 在个人信息页面中优化用户头像显示,支持从库中选择头像
- 修改首页布局,添加可拖动的教练徽章,提升用户交互体验
- 更新样式以适应新功能的展示和交互
2025-08-13 10:09:55 +08:00

404 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
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,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ProgressBar } from '@/components/ProgressBar';
import { useAppDispatch } from '@/hooks/redux';
import { setDailyCaloriesGoal, setDailyStepsGoal, setPilatesPurposes } from '@/store/userSlice';
const STORAGE_KEYS = {
calories: '@goal_calories_burn',
steps: '@goal_daily_steps',
purposes: '@goal_pilates_purposes',
} as const;
const CALORIES_RANGE = { min: 100, max: 1500, step: 50 };
const STEPS_RANGE = { min: 2000, max: 20000, step: 500 };
export default function GoalsScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colors = Colors[theme];
const dispatch = useAppDispatch();
const [calories, setCalories] = useState<number>(400);
const [steps, setSteps] = useState<number>(8000);
const [purposes, setPurposes] = useState<string[]>([]);
useEffect(() => {
const load = async () => {
try {
const [c, s, p] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEYS.calories),
AsyncStorage.getItem(STORAGE_KEYS.steps),
AsyncStorage.getItem(STORAGE_KEYS.purposes),
]);
if (c) {
const v = parseInt(c, 10);
if (!Number.isNaN(v)) setCalories(v);
}
if (s) {
const v = parseInt(s, 10);
if (!Number.isNaN(v)) setSteps(v);
}
if (p) {
try {
const parsed = JSON.parse(p);
if (Array.isArray(parsed)) setPurposes(parsed.filter((x) => typeof x === 'string'));
} catch { }
}
} catch { }
};
load();
}, []);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => { });
dispatch(setDailyCaloriesGoal(calories));
}, [calories]);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => { });
dispatch(setDailyStepsGoal(steps));
}, [steps]);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => { });
dispatch(setPilatesPurposes(purposes));
}, [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]);
const stepsPercent = useMemo(() =>
(Math.min(STEPS_RANGE.max, Math.max(STEPS_RANGE.min, steps)) - STEPS_RANGE.min) /
(STEPS_RANGE.max - STEPS_RANGE.min),
[steps]);
const changeWithHaptics = (next: number, setter: (v: number) => void) => {
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
setter(next);
};
const inc = (value: number, range: { step: number; max: number }) => {
return Math.min(range.max, value + range.step);
};
const dec = (value: number, range: { step: number; min: number }) => {
return Math.max(range.min, value - range.step);
};
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>
);
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>
);
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>
);
const PURPOSE_OPTIONS: { id: string; label: string; icon: any }[] = [
{ id: 'core', label: '增强核心力量', icon: 'barbell-outline' },
{ id: 'posture', label: '改善姿势体态', icon: 'body-outline' },
{ id: 'flexibility', label: '提高柔韧灵活', icon: 'walk-outline' },
{ id: 'balance', label: '强化平衡稳定', icon: 'accessibility-outline' },
{ id: 'shape', label: '塑形与线条', icon: 'heart-outline' },
{ id: 'stress', label: '减压与身心放松', icon: 'leaf-outline' },
{ id: 'backpain', label: '预防/改善腰背痛', icon: 'shield-checkmark-outline' },
{ id: 'rehab', label: '术后/伤后康复', icon: 'medkit-outline' },
{ id: 'performance', label: '提升运动表现', icon: 'fitness-outline' },
];
const togglePurpose = (id: string) => {
if (process.env.EXPO_OS === 'ios') {
Haptics.selectionAsync();
}
setPurposes((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
return (
<View style={[styles.container, { backgroundColor: theme === 'light' ? '#F5F5F5' : colors.background }]}>
<SafeAreaView style={styles.safeArea}>
<HeaderBar title="目标管理" onBack={() => router.back()} withSafeTop={false} tone={theme} transparent />
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: Math.max(20, insets.bottom + 16) }]}
showsVerticalScrollIndicator={false}
>
<SectionCard title="每日卡路里消耗目标" subtitle="设置你计划每天通过活动消耗的热量">
<View style={styles.rowBetween}>
<Text style={[styles.valueText, { color: colors.text }]}>{calories} kcal</Text>
<Stepper
onDec={() => changeWithHaptics(dec(calories, CALORIES_RANGE), setCalories)}
onInc={() => changeWithHaptics(inc(calories, CALORIES_RANGE), setCalories)}
/>
</View>
<ProgressBar
progress={caloriesPercent}
height={18}
style={styles.progressMargin}
trackColor={theme === 'light' ? '#EDEDED' : colors.separator}
fillColor={colors.primary}
/>
<View style={styles.chipsRow}>
{[200, 300, 400, 500, 600].map((v) => (
<PresetChip key={v}
label={`${v}`}
active={v === calories}
onPress={() => changeWithHaptics(v, setCalories)}
/>
))}
</View>
<Text style={[styles.rangeHint, { color: colors.textMuted }]}> {CALORIES_RANGE.min}-{CALORIES_RANGE.max} kcal {CALORIES_RANGE.step}</Text>
</SectionCard>
<SectionCard title="每日步数目标" subtitle="快速设置你的目标步数">
<View style={styles.rowBetween}>
<Text style={[styles.valueText, { color: colors.text }]}>{steps.toLocaleString()} </Text>
<Stepper
onDec={() => changeWithHaptics(dec(steps, STEPS_RANGE), setSteps)}
onInc={() => changeWithHaptics(inc(steps, STEPS_RANGE), setSteps)}
/>
</View>
<ProgressBar
progress={stepsPercent}
height={18}
style={styles.progressMargin}
trackColor={theme === 'light' ? '#EDEDED' : colors.separator}
fillColor={colors.primary}
/>
<View style={styles.chipsRow}>
{[6000, 8000, 10000, 12000, 15000].map((v) => (
<PresetChip key={v}
label={`${v / 1000}k`}
active={v === steps}
onPress={() => changeWithHaptics(v, setSteps)}
/>
))}
</View>
<Text style={[styles.rangeHint, { color: colors.textMuted }]}> {STEPS_RANGE.min.toLocaleString()}-{STEPS_RANGE.max.toLocaleString()} {STEPS_RANGE.step}</Text>
</SectionCard>
<SectionCard
title="练习普拉提是为了什么"
subtitle="可多选"
>
<View style={styles.grid}>
{PURPOSE_OPTIONS.map((opt) => {
const active = purposes.includes(opt.id);
return (
<TouchableOpacity
key={opt.id}
style={[
styles.optionCard,
{
backgroundColor: active ? colors.primary : colors.card,
borderColor: active ? colors.primary : colors.border,
},
]}
activeOpacity={0.9}
onPress={() => togglePurpose(opt.id)}
>
<View style={styles.optionIconWrap}>
<Ionicons
name={opt.icon}
size={20}
color={active ? colors.onPrimary : colors.text}
/>
</View>
<Text
numberOfLines={2}
style={[
styles.optionLabel,
{ color: active ? colors.onPrimary : colors.text },
]}
>
{opt.label}
</Text>
</TouchableOpacity>
);
})}
</View>
{purposes.length > 0 && (
<Text style={[styles.selectedHint, { color: colors.textSecondary }]}> {purposes.length} </Text>
)}
</SectionCard>
</ScrollView>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
},
backButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 20,
fontWeight: '700',
},
safeArea: {
flex: 1,
},
content: {
paddingHorizontal: 20,
paddingTop: 8,
},
card: {
borderRadius: 16,
padding: 16,
marginTop: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 2,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
},
cardSubtitle: {
fontSize: 13,
marginTop: 4,
},
rowBetween: {
marginTop: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
valueText: {
fontSize: 24,
fontWeight: '800',
},
chipsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 14,
},
chip: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
},
chipText: {
fontSize: 14,
fontWeight: '600',
},
stepperRow: {
flexDirection: 'row',
gap: 10,
},
stepperBtn: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
stepperText: {
fontSize: 20,
fontWeight: '700',
},
rangeHint: {
fontSize: 12,
marginTop: 10,
},
progressMargin: {
marginTop: 12,
},
grid: {
marginTop: 12,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
rowGap: 12,
},
optionCard: {
width: '48%',
borderRadius: 14,
paddingVertical: 14,
paddingHorizontal: 12,
borderWidth: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
optionIconWrap: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.04)',
},
optionLabel: {
flex: 1,
fontSize: 14,
fontWeight: '700',
},
selectedHint: {
marginTop: 10,
fontSize: 12,
},
});