Files
digital-pilates/app/profile/goals.tsx
2025-10-14 16:31:19 +08:00

446 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 '@/utils/kvStore';
import * as Haptics from 'expo-haptics';
import { useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import {
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 { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
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 safeAreaTop = useSafeAreaTop()
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({ 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({ 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({ 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 }]}>
<View style={styles.safeArea}>
<HeaderBar title="目标管理" onBack={() => router.back()} withSafeTop={false} tone={theme} transparent />
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: Math.max(20, insets.bottom + 16), paddingTop: safeAreaTop }]}
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>
</View>
</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,
},
});