Files
digital-pilates/hooks/useWaterData.ts
richarjiang 21e57634e0 feat(hrv): 添加心率变异性监控和压力评估功能
- 新增 HRV 监听服务,实时监控心率变异性数据
- 实现 HRV 到压力指数的转换算法和压力等级评估
- 添加智能通知服务,在压力偏高时推送健康建议
- 优化日志系统,修复日志丢失问题并增强刷新机制
- 改进个人页面下拉刷新,支持并行数据加载
- 优化勋章数据缓存策略,减少不必要的网络请求
- 重构应用初始化流程,优化权限服务和健康监听服务的启动顺序
- 移除冗余日志输出,提升应用性能
2025-11-18 14:08:20 +08:00

740 lines
23 KiB
TypeScript
Raw Permalink 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 { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { ChallengeType } from '@/services/challengesApi';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health';
import { Toast } from '@/utils/toast.utils';
import { getQuickWaterAmount, getWaterGoalFromStorage, setWaterGoalToStorage } from '@/utils/userPreferences';
import { refreshWidget, syncWaterDataToWidget } from '@/utils/widgetDataSync';
import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react';
// 水分记录数据结构
export interface WaterRecord {
id: string;
amount: number;
recordedAt: string;
createdAt: string;
note?: string;
}
// 水分统计数据结构
export interface WaterStats {
totalAmount: number;
completionRate: number;
recordCount: number;
}
// 将 HealthKit 数据转换为应用数据格式
const convertHealthKitToWaterRecord = (healthKitRecord: any): WaterRecord => {
return {
id: healthKitRecord.id || `${healthKitRecord.startDate}_${healthKitRecord.value}`,
amount: Math.round(healthKitRecord.value), // HealthKit 已经返回毫升数值
recordedAt: healthKitRecord.startDate,
createdAt: healthKitRecord.endDate,
note: healthKitRecord.metadata?.note || undefined,
};
};
// 创建日期范围选项
function createDateRange(date: string): { startDate: string; endDate: string } {
return {
startDate: dayjs(date).startOf('day').toDate().toISOString(),
endDate: dayjs(date).endOf('day').toDate().toISOString()
};
}
const useWaterChallengeProgressReporter = () => {
const dispatch = useAppDispatch();
const allChallenges = useAppSelector(selectChallengeList);
const joinedWaterChallenges = useMemo(
() => allChallenges.filter((challenge) => challenge.type === ChallengeType.WATER && challenge.isJoined && challenge.status === 'ongoing'),
[allChallenges]
);
return useCallback(
async (value: number) => {
if (!joinedWaterChallenges.length) {
return;
}
for (const challenge of joinedWaterChallenges) {
try {
await dispatch(reportChallengeProgress({ id: challenge.id, value })).unwrap();
} catch (error) {
console.warn('挑战进度上报失败', { error, challengeId: challenge.id });
}
}
},
[dispatch, joinedWaterChallenges]
);
};
export const useWaterData = () => {
// 本地状态管理
const [loading, setLoading] = useState({
records: false,
stats: false,
goal: false
});
const [error, setError] = useState<string | null>(null);
const [dailyWaterGoal, setDailyWaterGoal] = useState<number>(2000);
const [waterRecords, setWaterRecords] = useState<{ [date: string]: WaterRecord[] }>({});
const [selectedDate, setSelectedDate] = useState<string>(dayjs().format('YYYY-MM-DD'));
// 获取指定日期的记录
const getWaterRecordsByDate = useCallback(async (date: string, page = 1, limit = 20) => {
setLoading(prev => ({ ...prev, records: true }));
setError(null);
try {
const options = createDateRange(date);
const healthKitRecords = await getWaterIntakeFromHealthKit(options);
// 转换数据格式并按时间排序
const convertedRecords = healthKitRecords
.map(convertHealthKitToWaterRecord)
.sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime());
// 应用分页逻辑
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedRecords = convertedRecords.slice(startIndex, endIndex);
setWaterRecords(prev => ({
...prev,
[date]: page === 1 ? paginatedRecords : [...(prev[date] || []), ...paginatedRecords]
}));
return paginatedRecords;
} catch (error) {
console.error('获取饮水记录失败:', error);
setError('获取饮水记录失败');
Toast.error('获取饮水记录失败');
return [];
} finally {
setLoading(prev => ({ ...prev, records: false }));
}
}, []);
// 加载更多记录占位符HealthKit一次性返回所有数据
const loadMoreWaterRecords = useCallback(async () => {
// HealthKit通常一次性返回所有数据这里保持接口一致性
return;
}, []);
// 获取日期范围的记录
const getWaterRecordsByDateRange = useCallback(async (
startDate: string,
endDate: string,
page = 1,
limit = 20
) => {
setLoading(prev => ({ ...prev, records: true }));
setError(null);
try {
const options = {
startDate: dayjs(startDate).startOf('day').toDate().toISOString(),
endDate: dayjs(endDate).endOf('day').toDate().toISOString()
};
const healthKitRecords = await getWaterIntakeFromHealthKit(options);
// 转换数据格式并按时间排序
const convertedRecords = healthKitRecords
.map(convertHealthKitToWaterRecord)
.sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime());
// 按日期分组
const recordsByDate: { [date: string]: WaterRecord[] } = {};
convertedRecords.forEach(record => {
const date = dayjs(record.recordedAt).format('YYYY-MM-DD');
if (!recordsByDate[date]) {
recordsByDate[date] = [];
}
recordsByDate[date].push(record);
});
setWaterRecords(prev => ({ ...prev, ...recordsByDate }));
return recordsByDate;
} catch (error) {
console.error('获取日期范围饮水记录失败:', error);
setError('获取日期范围饮水记录失败');
Toast.error('获取日期范围饮水记录失败');
return {};
} finally {
setLoading(prev => ({ ...prev, records: false }));
}
}, []);
// 加载今日数据
const loadTodayData = useCallback(() => {
const today = dayjs().format('YYYY-MM-DD');
getWaterRecordsByDate(today);
}, [getWaterRecordsByDate]);
// 加载指定日期数据
const loadDataByDate = useCallback((date: string) => {
setSelectedDate(date);
getWaterRecordsByDate(date);
}, [getWaterRecordsByDate]);
// 创建喝水记录
const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
const addWaterRecord = useCallback(
async (amount: number, recordedAt?: string) => {
try {
const recordTime = recordedAt || dayjs().toISOString();
const date = dayjs(recordTime).format('YYYY-MM-DD');
const isToday = dayjs(recordTime).isSame(dayjs(), 'day');
// 保存到 HealthKit
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
if (!healthKitSuccess) {
Toast.error('保存到 HealthKit 失败');
return false;
}
// 重新获取当前日期的数据以刷新界面
const updatedRecords = await getWaterRecordsByDate(date);
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
// 如果是今天的数据更新Widget
if (isToday) {
const quickAddAmount = await getQuickWaterAmount();
try {
await syncWaterDataToWidget({
currentIntake: totalAmount,
dailyGoal: dailyWaterGoal,
quickAddAmount,
});
await refreshWidget();
} catch (widgetError) {
console.error('Widget 同步错误:', widgetError);
}
}
await reportWaterChallengeProgress(totalAmount);
return true;
} catch (error: any) {
console.error('添加喝水记录失败:', error);
Toast.error(error?.message || '添加喝水记录失败');
return false;
}
},
[dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress]
);
// 更新喝水记录HealthKit不支持更新只能删除后重新添加
const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => {
try {
// 找到要更新的记录
let recordToUpdate: WaterRecord | null = null;
let recordDate = '';
for (const [date, records] of Object.entries(waterRecords)) {
const record = records.find(r => r.id === id);
if (record) {
recordToUpdate = record;
recordDate = date;
break;
}
}
if (!recordToUpdate) {
Toast.error('找不到要更新的记录');
return false;
}
// 先删除旧记录
await deleteWaterIntakeFromHealthKit(id, recordToUpdate.recordedAt);
// 添加新记录
const newAmount = amount ?? recordToUpdate.amount;
const newRecordedAt = recordedAt ?? recordToUpdate.recordedAt;
const success = await addWaterRecord(newAmount, newRecordedAt);
if (success) {
Toast.success('更新饮水记录成功');
}
return success;
} catch (error: any) {
console.error('更新喝水记录失败:', error);
Toast.error(error?.message || '更新喝水记录失败');
return false;
}
}, [waterRecords, addWaterRecord]);
// 删除喝水记录
const removeWaterRecord = useCallback(async (id: string) => {
try {
// 找到要删除的记录
let recordToDelete: WaterRecord | null = null;
let recordDate = '';
for (const [date, records] of Object.entries(waterRecords)) {
const record = records.find(r => r.id === id);
if (record) {
recordToDelete = record;
recordDate = date;
break;
}
}
if (!recordToDelete) {
Toast.error('找不到要删除的记录');
return false;
}
// 从 HealthKit 删除
const healthKitSuccess = await deleteWaterIntakeFromHealthKit(id, recordToDelete.recordedAt);
if (!healthKitSuccess) {
console.warn('从 HealthKit 删除记录失败,但继续更新本地数据');
}
// 更新本地状态
setWaterRecords(prev => ({
...prev,
[recordDate]: prev[recordDate]?.filter(record => record.id !== id) || []
}));
// 如果是今天的数据更新Widget
if (recordDate === dayjs().format('YYYY-MM-DD')) {
const updatedTodayRecords = waterRecords[recordDate]?.filter(record => record.id !== id) || [];
const totalAmount = updatedTodayRecords.reduce((sum, record) => sum + record.amount, 0);
const quickAddAmount = await getQuickWaterAmount();
try {
await syncWaterDataToWidget({
currentIntake: totalAmount,
dailyGoal: dailyWaterGoal,
quickAddAmount,
});
await refreshWidget();
} catch (widgetError) {
console.error('Widget 删除同步错误:', widgetError);
}
}
Toast.success('删除饮水记录成功');
return true;
} catch (error: any) {
console.error('删除喝水记录失败:', error);
Toast.error(error?.message || '删除喝水记录失败');
return false;
}
}, [waterRecords, dailyWaterGoal]);
// 更新喝水目标
const updateWaterGoal = useCallback(async (goal: number) => {
try {
await setWaterGoalToStorage(goal);
setDailyWaterGoal(goal);
// 更新Widget
const today = dayjs().format('YYYY-MM-DD');
const todayRecords = waterRecords[today] || [];
const totalAmount = todayRecords.reduce((sum, record) => sum + record.amount, 0);
try {
const quickAddAmount = await getQuickWaterAmount();
await syncWaterDataToWidget({
dailyGoal: goal,
currentIntake: totalAmount,
quickAddAmount,
});
await refreshWidget();
} catch (widgetError) {
console.error('Widget 目标同步错误:', widgetError);
}
Toast.success('更新饮水目标成功');
return true;
} catch (error: any) {
console.error('更新喝水目标失败:', error);
Toast.error(error?.message || '更新喝水目标失败');
return false;
}
}, [waterRecords]);
// 计算总喝水量
const getTotalAmount = useCallback((records: WaterRecord[]) => {
return records.reduce((total, record) => total + record.amount, 0);
}, []);
// 按小时分组数据
const getHourlyData = useCallback((records: WaterRecord[]) => {
const hourlyData: { hour: number; amount: number }[] = Array.from({ length: 24 }, (_, i) => ({
hour: i,
amount: 0,
}));
records.forEach(record => {
const hour = dayjs(record.recordedAt).hour();
if (hour >= 0 && hour < 24) {
hourlyData[hour].amount += record.amount;
}
});
return hourlyData;
}, []);
// 计算完成率(返回百分比)
const calculateCompletionRate = useCallback((totalAmount: number, goal: number) => {
if (goal <= 0) return 0;
return Math.min((totalAmount / goal) * 100, 100);
}, []);
// 加载初始数据
useEffect(() => {
const loadInitialData = async () => {
try {
// 加载饮水目标
const goal = await getWaterGoalFromStorage();
setDailyWaterGoal(goal);
// 加载今日数据
loadTodayData();
} catch (error) {
console.error('加载初始数据失败:', error);
}
};
loadInitialData();
}, [loadTodayData]);
// 计算今日统计数据
const todayStats = useMemo(() => {
const today = dayjs().format('YYYY-MM-DD');
const todayRecords = waterRecords[today] || [];
const totalAmount = getTotalAmount(todayRecords);
return {
totalAmount,
completionRate: calculateCompletionRate(totalAmount, dailyWaterGoal),
recordCount: todayRecords.length
};
}, [waterRecords, dailyWaterGoal, getTotalAmount, calculateCompletionRate]);
// 同步初始数据到Widget
useEffect(() => {
const syncInitialDataToWidget = async () => {
if (todayStats && dailyWaterGoal) {
try {
const quickAddAmount = await getQuickWaterAmount();
await syncWaterDataToWidget({
currentIntake: todayStats.totalAmount,
dailyGoal: dailyWaterGoal,
quickAddAmount,
});
} catch (error) {
console.error('初始Widget数据同步失败:', error);
}
}
};
syncInitialDataToWidget();
}, [todayStats, dailyWaterGoal]);
return {
// 数据
todayStats,
dailyWaterGoal,
waterRecords: waterRecords[selectedDate] || [],
waterRecordsMeta: {
total: waterRecords[selectedDate]?.length || 0,
page: 1,
limit: 20,
hasMore: false
},
selectedDate,
loading,
error,
// 方法
loadTodayData,
loadDataByDate,
getWaterRecordsByDate,
loadMoreWaterRecords,
getWaterRecordsByDateRange,
addWaterRecord,
updateWaterRecord,
removeWaterRecord,
updateWaterGoal,
getTotalAmount,
getHourlyData,
calculateCompletionRate,
};
};
// 简化的Hook只返回今日数据
export const useTodayWaterData = () => {
const waterData = useWaterData();
const todayRecords = useMemo(() => {
const today = dayjs().format('YYYY-MM-DD');
return waterData.waterRecords || [];
}, [waterData.waterRecords]);
const todayMeta = useMemo(() => ({
total: todayRecords.length,
page: 1,
limit: 20,
hasMore: false
}), [todayRecords.length]);
const fetchTodayWaterRecords = useCallback(async (page = 1, limit = 20) => {
const today = dayjs().format('YYYY-MM-DD');
await waterData.getWaterRecordsByDate(today, page, limit);
}, [waterData.getWaterRecordsByDate]);
return {
todayStats: waterData.todayStats,
dailyWaterGoal: waterData.dailyWaterGoal,
waterRecords: todayRecords,
waterRecordsMeta: todayMeta,
selectedDate: waterData.selectedDate,
loading: waterData.loading,
error: waterData.error,
fetchTodayWaterRecords,
loadMoreWaterRecords: waterData.loadMoreWaterRecords,
getWaterRecordsByDateRange: waterData.getWaterRecordsByDateRange,
addWaterRecord: waterData.addWaterRecord,
updateWaterRecord: waterData.updateWaterRecord,
removeWaterRecord: waterData.removeWaterRecord,
updateWaterGoal: waterData.updateWaterGoal,
};
};
// 按日期获取饮水数据的 hook
export const useWaterDataByDate = (targetDate?: string) => {
const dateToUse = targetDate || dayjs().format('YYYY-MM-DD');
// 本地状态管理
const [loading, setLoading] = useState({
records: false,
stats: false,
goal: false
});
const [error, setError] = useState<string | null>(null);
const [dailyWaterGoal, setDailyWaterGoal] = useState<number>(2000);
const [waterRecords, setWaterRecords] = useState<WaterRecord[]>([]);
// 获取指定日期的记录
const getWaterRecordsByDate = useCallback(async (date: string, page = 1, limit = 20) => {
setLoading(prev => ({ ...prev, records: true }));
setError(null);
try {
const options = createDateRange(date);
const healthKitRecords = await getWaterIntakeFromHealthKit(options);
// 转换数据格式并按时间排序
const convertedRecords = healthKitRecords
.map(convertHealthKitToWaterRecord)
.sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime());
setWaterRecords(convertedRecords);
return convertedRecords;
} catch (error) {
console.error('🚰 获取饮水记录失败:', error);
setError('获取饮水记录失败');
Toast.error('获取饮水记录失败');
return [];
} finally {
setLoading(prev => ({ ...prev, records: false }));
}
}, []);
// 创建喝水记录
const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
const addWaterRecord = useCallback(
async (amount: number, recordedAt?: string) => {
try {
const recordTime = recordedAt || dayjs().toISOString();
// 保存到 HealthKit
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
if (!healthKitSuccess) {
Toast.error('保存到 HealthKit 失败');
return false;
}
// 重新获取当前日期的数据以刷新界面
const updatedRecords = await getWaterRecordsByDate(dateToUse);
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
// 如果是今天的数据更新Widget
if (dateToUse === dayjs().format('YYYY-MM-DD')) {
const quickAddAmount = await getQuickWaterAmount();
try {
await syncWaterDataToWidget({
currentIntake: totalAmount,
dailyGoal: dailyWaterGoal,
quickAddAmount,
});
await refreshWidget();
} catch (widgetError) {
console.error('Widget 同步错误:', widgetError);
}
}
await reportWaterChallengeProgress(totalAmount);
return true;
} catch (error: any) {
console.error('添加喝水记录失败:', error);
Toast.error(error?.message || '添加喝水记录失败');
return false;
}
},
[dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress]
);
// 更新喝水记录
const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => {
try {
const recordToUpdate = waterRecords.find(r => r.id === id);
if (!recordToUpdate) {
Toast.error('找不到要更新的记录');
return false;
}
// 先删除旧记录
await deleteWaterIntakeFromHealthKit(id, recordToUpdate.recordedAt);
// 添加新记录
const newAmount = amount ?? recordToUpdate.amount;
const newRecordedAt = recordedAt ?? recordToUpdate.recordedAt;
const success = await addWaterRecord(newAmount, newRecordedAt);
return success;
} catch (error: any) {
console.error('更新喝水记录失败:', error);
Toast.error(error?.message || '更新喝水记录失败');
return false;
}
}, [waterRecords, addWaterRecord]);
// 删除喝水记录
const removeWaterRecord = useCallback(async (id: string) => {
try {
const recordToDelete = waterRecords.find(r => r.id === id);
if (!recordToDelete) {
Toast.error('找不到要删除的记录');
return false;
}
// 从 HealthKit 删除
await deleteWaterIntakeFromHealthKit(id, recordToDelete.recordedAt);
// 重新获取数据
await getWaterRecordsByDate(dateToUse);
// 如果是今天的数据更新Widget
if (dateToUse === dayjs().format('YYYY-MM-DD')) {
const updatedRecords = waterRecords.filter(record => record.id !== id);
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
const quickAddAmount = await getQuickWaterAmount();
try {
await syncWaterDataToWidget({
currentIntake: totalAmount,
dailyGoal: dailyWaterGoal,
quickAddAmount,
});
await refreshWidget();
} catch (widgetError) {
console.error('Widget 删除同步错误:', widgetError);
}
}
Toast.success('删除饮水记录成功');
return true;
} catch (error: any) {
console.error('删除喝水记录失败:', error);
Toast.error(error?.message || '删除喝水记录失败');
return false;
}
}, [waterRecords, getWaterRecordsByDate, dateToUse, dailyWaterGoal]);
// 更新喝水目标
const updateWaterGoal = useCallback(async (goal: number) => {
try {
await setWaterGoalToStorage(goal);
setDailyWaterGoal(goal);
Toast.success('更新饮水目标成功');
return true;
} catch (error: any) {
console.error('更新喝水目标失败:', error);
Toast.error(error?.message || '更新喝水目标失败');
return false;
}
}, []);
// 计算指定日期的统计数据
const waterStats = useMemo(() => {
if (!waterRecords || waterRecords.length === 0) {
return {
totalAmount: 0,
completionRate: 0,
recordCount: 0
};
}
const totalAmount = waterRecords.reduce((total, record) => total + record.amount, 0);
const completionRate = dailyWaterGoal > 0 ? Math.min((totalAmount / dailyWaterGoal) * 100, 100) : 0;
return {
totalAmount,
completionRate,
recordCount: waterRecords.length
};
}, [waterRecords, dailyWaterGoal]);
// 初始化加载指定日期的数据
useEffect(() => {
const loadInitialData = async () => {
try {
// 加载饮水目标
const goal = await getWaterGoalFromStorage();
setDailyWaterGoal(goal);
// 加载指定日期数据
await getWaterRecordsByDate(dateToUse);
} catch (error) {
console.error('加载初始数据失败:', error);
}
};
loadInitialData();
}, [dateToUse, getWaterRecordsByDate]);
return {
waterStats,
dailyWaterGoal,
waterRecords,
waterRecordsMeta: {
total: waterRecords.length,
page: 1,
limit: 20,
hasMore: false
},
loading,
error,
addWaterRecord,
updateWaterRecord,
removeWaterRecord,
updateWaterGoal,
getWaterRecordsByDate,
};
};