- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录 - 实现轻量防抖机制,确保会话变动时及时保存缓存 - 在打卡功能中集成按月加载打卡记录,提升用户体验 - 更新 Redux 状态管理,支持打卡记录的按月加载和缓存 - 新增打卡日历页面,允许用户查看每日打卡记录 - 优化样式以适应新功能的展示和交互
490 lines
16 KiB
TypeScript
490 lines
16 KiB
TypeScript
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, 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>
|
||
|
||
<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,
|
||
},
|
||
});
|
||
|
||
|