Files
digital-pilates/app/profile/goals.tsx
richarjiang c7d7255312 feat: 更新标签页和新增探索页面
- 将标签页中的“首页”改为“探索”,并更新相关逻辑
- 新增探索页面,展示推荐文章和训练计划
- 优化教练页面的导航,确保用户体验一致性
- 移除不再使用的代码,简化项目结构
- 更新路由常量,确保路径管理集中化
2025-08-18 19:08:22 +08:00

445 lines
15 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 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, useAppSelector } from '@/hooks/redux';
import { updateUser as updateUserApi } from '@/services/users';
import { fetchMyProfile } from '@/store/userSlice';
import { useFocusEffect } from '@react-navigation/native';
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 };
function arraysEqualUnordered(a?: string[], b?: string[]): boolean {
if (!Array.isArray(a) && !Array.isArray(b)) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
const sa = [...a].sort();
const sb = [...b].sort();
for (let i = 0; i < sa.length; i += 1) {
if (sa[i] !== sb[i]) return false;
}
return true;
}
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 accountProfile = useAppSelector((s) => (s as any)?.user?.profile as any);
const userId: string | undefined = useMemo(() => {
return (
accountProfile?.userId ||
accountProfile?.id ||
accountProfile?._id ||
accountProfile?.uid ||
undefined
) as string | undefined;
}, [accountProfile]);
const [calories, setCalories] = useState<number>(400);
const [steps, setSteps] = useState<number>(8000);
const [purposes, setPurposes] = useState<string[]>([]);
const lastSentRef = React.useRef<{ calories?: number; steps?: number; purposes?: 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();
}, []);
// 页面聚焦时,从后端拉取并用全局 profile 的值覆盖 UI保证是最新
useFocusEffect(
React.useCallback(() => {
(async () => {
try {
await dispatch(fetchMyProfile() as any);
const latest = (accountProfile ?? {}) as any;
if (typeof latest?.dailyCaloriesGoal === 'number') setCalories(latest.dailyCaloriesGoal);
if (typeof latest?.dailyStepsGoal === 'number') setSteps(latest.dailyStepsGoal);
if (Array.isArray(latest?.pilatesPurposes)) setPurposes(latest.pilatesPurposes.filter((x: any) => typeof x === 'string'));
} catch { }
})();
}, [dispatch])
);
// 当全局 profile 有变化时,同步覆盖 UI
useEffect(() => {
const latest = (accountProfile ?? {}) as any;
if (typeof latest?.dailyCaloriesGoal === 'number') setCalories(latest.dailyCaloriesGoal);
if (typeof latest?.dailyStepsGoal === 'number') setSteps(latest.dailyStepsGoal);
if (Array.isArray(latest?.pilatesPurposes)) setPurposes(latest.pilatesPurposes.filter((x: any) => typeof x === 'string'));
}, [accountProfile]);
// 当全局 profile 变化(例如刚拉完或保存后刷新)时,将“已发送基线”对齐为后端值,避免重复上报
useEffect(() => {
const latest = (accountProfile ?? {}) as any;
if (typeof latest?.dailyCaloriesGoal === 'number') {
lastSentRef.current.calories = latest.dailyCaloriesGoal;
}
if (typeof latest?.dailyStepsGoal === 'number') {
lastSentRef.current.steps = latest.dailyStepsGoal;
}
if (Array.isArray(latest?.pilatesPurposes)) {
lastSentRef.current.purposes = [...latest.pilatesPurposes];
}
}, [accountProfile]);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => { });
if (!userId) return;
if (lastSentRef.current.calories === calories) return;
lastSentRef.current.calories = calories;
(async () => {
try {
await updateUserApi({ userId, dailyCaloriesGoal: calories });
await dispatch(fetchMyProfile() as any);
} catch { }
})();
}, [calories, userId]);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => { });
if (!userId) return;
if (lastSentRef.current.steps === steps) return;
lastSentRef.current.steps = steps;
(async () => {
try {
await updateUserApi({ userId, dailyStepsGoal: steps });
await dispatch(fetchMyProfile() as any);
} catch { }
})();
}, [steps, userId]);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => { });
if (!userId) return;
if (arraysEqualUnordered(lastSentRef.current.purposes, purposes)) return;
lastSentRef.current.purposes = [...purposes];
(async () => {
try {
await updateUserApi({ userId, pilatesPurposes: purposes });
await dispatch(fetchMyProfile() as any);
} catch { }
})();
}, [purposes, userId]);
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>
</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,
},
});