feat: 优化 AI 教练聊天和打卡功能
- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录 - 实现轻量防抖机制,确保会话变动时及时保存缓存 - 在打卡功能中集成按月加载打卡记录,提升用户体验 - 更新 Redux 状态管理,支持打卡记录的按月加载和缓存 - 新增打卡日历页面,允许用户查看每日打卡记录 - 优化样式以适应新功能的展示和交互
This commit is contained in:
@@ -17,8 +17,10 @@ import {
|
||||
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';
|
||||
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',
|
||||
@@ -29,16 +31,39 @@ const STORAGE_KEYS = {
|
||||
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 () => {
|
||||
@@ -67,20 +92,81 @@ export default function GoalsScreen() {
|
||||
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(() => { });
|
||||
dispatch(setDailyCaloriesGoal(calories));
|
||||
}, [calories]);
|
||||
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(() => { });
|
||||
dispatch(setDailyStepsGoal(steps));
|
||||
}, [steps]);
|
||||
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(() => { });
|
||||
dispatch(setPilatesPurposes(purposes));
|
||||
}, [purposes]);
|
||||
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) /
|
||||
|
||||
Reference in New Issue
Block a user