feat: Refactor MoodCalendarScreen to use dayjs for date handling and improve calendar data generation

feat: Update FitnessRingsCard to navigate to fitness rings detail page on press

feat: Modify NutritionRadarCard to enhance UI and add haptic feedback on actions

feat: Add FITNESS_RINGS_DETAIL route for navigation

fix: Adjust minimum fetch interval in BackgroundTaskManager for background tasks

feat: Implement haptic feedback utility functions for better user experience

feat: Extend health permissions to include Apple Exercise Time and Apple Stand Time

feat: Add functions to fetch hourly activity, exercise, and stand data for improved health tracking

feat: Enhance user preferences to manage fitness exercise minutes and active hours info dismissal
This commit is contained in:
richarjiang
2025-09-05 15:32:34 +08:00
parent 460a7e4289
commit 83805a4b07
9 changed files with 1337 additions and 79 deletions

56
utils/haptics.ts Normal file
View File

@@ -0,0 +1,56 @@
import { Platform } from 'react-native';
import * as Haptics from 'expo-haptics';
/**
* 触发轻微震动反馈 (仅在 iOS 上生效)
*/
export const triggerLightHaptic = () => {
if (Platform.OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
};
/**
* 触发中等震动反馈 (仅在 iOS 上生效)
*/
export const triggerMediumHaptic = () => {
if (Platform.OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
};
/**
* 触发强烈震动反馈 (仅在 iOS 上生效)
*/
export const triggerHeavyHaptic = () => {
if (Platform.OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
}
};
/**
* 触发成功反馈震动 (仅在 iOS 上生效)
*/
export const triggerSuccessHaptic = () => {
if (Platform.OS === 'ios') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
};
/**
* 触发警告反馈震动 (仅在 iOS 上生效)
*/
export const triggerWarningHaptic = () => {
if (Platform.OS === 'ios') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
};
/**
* 触发错误反馈震动 (仅在 iOS 上生效)
*/
export const triggerErrorHaptic = () => {
if (Platform.OS === 'ios') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
};

View File

@@ -20,6 +20,9 @@ const PERMISSIONS: HealthKitPermissions = {
AppleHealthKit.Constants.Permissions.OxygenSaturation,
AppleHealthKit.Constants.Permissions.HeartRate,
AppleHealthKit.Constants.Permissions.Water,
// 添加 Apple Exercise Time 和 Apple Stand Time 权限
AppleHealthKit.Constants.Permissions.AppleExerciseTime,
AppleHealthKit.Constants.Permissions.AppleStandTime,
],
write: [
// 支持体重写入
@@ -35,6 +38,21 @@ export type HourlyStepData = {
steps: number;
};
export type HourlyActivityData = {
hour: number; // 0-23
calories: number; // 活动热量
};
export type HourlyExerciseData = {
hour: number; // 0-23
minutes: number; // 锻炼分钟数
};
export type HourlyStandData = {
hour: number; // 0-23
hasStood: number; // 1表示该小时有站立0表示没有
};
export type TodayHealthData = {
steps: number;
activeEnergyBurned: number; // kilocalories
@@ -192,8 +210,9 @@ async function fetchHourlyStepSamples(date: Date): Promise<HourlyStepData[]> {
(err: any, res: any[]) => {
if (err) {
logError('每小时步数样本', err);
// 如果主方法失败,尝试使用备用方法
return null
// 如果主方法失败,返回默认数据
resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 })));
return;
}
logSuccess('每小时步数样本', res);
@@ -225,6 +244,165 @@ async function fetchHourlyStepSamples(date: Date): Promise<HourlyStepData[]> {
});
}
// 获取每小时活动热量数据
// 优化版本:使用更精确的时间间隔来获取每小时数据
async function fetchHourlyActiveCalories(date: Date): Promise<HourlyActivityData[]> {
return new Promise(async (resolve) => {
const startOfDay = dayjs(date).startOf('day');
// 初始化24小时数据
const hourlyData: HourlyActivityData[] = Array.from({ length: 24 }, (_, i) => ({
hour: i,
calories: 0
}));
try {
// 为每个小时单独获取数据,确保精确性
const promises = Array.from({ length: 24 }, (_, hour) => {
const hourStart = startOfDay.add(hour, 'hour');
const hourEnd = hourStart.add(1, 'hour');
const options = {
startDate: hourStart.toDate().toISOString(),
endDate: hourEnd.toDate().toISOString(),
ascending: true,
includeManuallyAdded: false
};
return new Promise<number>((resolveHour) => {
AppleHealthKit.getActiveEnergyBurned(options, (err, res) => {
if (err || !res || !Array.isArray(res)) {
resolveHour(0);
return;
}
const total = res.reduce((acc: number, sample: any) => {
return acc + (sample?.value || 0);
}, 0);
resolveHour(Math.round(total));
});
});
});
const results = await Promise.all(promises);
results.forEach((calories, hour) => {
hourlyData[hour].calories = calories;
});
logSuccess('每小时活动热量', hourlyData);
resolve(hourlyData);
} catch (error) {
logError('每小时活动热量', error);
resolve(hourlyData);
}
});
}
// 获取每小时锻炼分钟数据
// 使用 AppleHealthKit.getAppleExerciseTime 获取锻炼样本数据
async function fetchHourlyExerciseMinutes(date: Date): Promise<HourlyExerciseData[]> {
return new Promise((resolve) => {
const startOfDay = dayjs(date).startOf('day');
const endOfDay = dayjs(date).endOf('day');
const options = {
startDate: startOfDay.toDate().toISOString(),
endDate: endOfDay.toDate().toISOString(),
ascending: true,
includeManuallyAdded: false
};
// 使用 getAppleExerciseTime 获取详细的锻炼样本数据
AppleHealthKit.getAppleExerciseTime(options, (err, res) => {
if (err) {
logError('每小时锻炼分钟', err);
resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, minutes: 0 })));
return;
}
if (!res || !Array.isArray(res)) {
logWarning('每小时锻炼分钟', '数据为空');
resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, minutes: 0 })));
return;
}
logSuccess('每小时锻炼分钟', res);
// 初始化24小时数据
const hourlyData: HourlyExerciseData[] = Array.from({ length: 24 }, (_, i) => ({
hour: i,
minutes: 0
}));
// 将锻炼样本数据按小时分组统计
res.forEach((sample: any) => {
if (sample && sample.startDate && sample.value !== undefined) {
const hour = dayjs(sample.startDate).hour();
if (hour >= 0 && hour < 24) {
hourlyData[hour].minutes += sample.value;
}
}
});
// 四舍五入处理
hourlyData.forEach(data => {
data.minutes = Math.round(data.minutes);
});
resolve(hourlyData);
});
});
}
// 获取每小时站立小时数据
// 使用 AppleHealthKit.getAppleStandTime 获取站立样本数据
async function fetchHourlyStandHours(date: Date): Promise<number[]> {
return new Promise((resolve) => {
const startOfDay = dayjs(date).startOf('day');
const endOfDay = dayjs(date).endOf('day');
const options = {
startDate: startOfDay.toDate().toISOString(),
endDate: endOfDay.toDate().toISOString()
};
// 使用 getAppleStandTime 获取详细的站立样本数据
AppleHealthKit.getAppleStandTime(options, (err, res) => {
if (err) {
logError('每小时站立数据', err);
resolve(Array.from({ length: 24 }, () => 0));
return;
}
if (!res || !Array.isArray(res)) {
logWarning('每小时站立数据', '数据为空');
resolve(Array.from({ length: 24 }, () => 0));
return;
}
logSuccess('每小时站立数据', res);
// 初始化24小时数据
const hourlyData: number[] = Array.from({ length: 24 }, () => 0);
// 将站立样本数据按小时分组统计
res.forEach((sample: any) => {
if (sample && sample.startDate && sample.value !== undefined) {
const hour = dayjs(sample.startDate).hour();
if (hour >= 0 && hour < 24) {
// 站立时间通常以分钟为单位转换为小时1表示该小时有站立0表示没有
hourlyData[hour] = sample.value > 0 ? 1 : 0;
}
}
});
resolve(hourlyData);
});
});
}
async function fetchActiveEnergyBurned(options: HealthDataOptions): Promise<number> {
return new Promise((resolve) => {
AppleHealthKit.getActiveEnergyBurned(options, (err, res) => {
@@ -612,3 +790,63 @@ export async function getCurrentHourStandStatus(): Promise<{ hasStood: boolean;
};
}
}
// === 专门为健身圆环详情页提供的独立函数 ===
// 精简的活动圆环数据类型,只包含必要字段
export type ActivityRingsData = {
// 活动圆环数据(来自 getActivitySummary
activeEnergyBurned: number; // activeEnergyBurned
activeEnergyBurnedGoal: number; // activeEnergyBurnedGoal
appleExerciseTime: number; // appleExerciseTime (分钟)
appleExerciseTimeGoal: number; // appleExerciseTimeGoal
appleStandHours: number; // appleStandHours
appleStandHoursGoal: number; // appleStandHoursGoal
};
// 导出每小时活动热量数据获取函数
export async function fetchHourlyActiveCaloriesForDate(date: Date): Promise<HourlyActivityData[]> {
return fetchHourlyActiveCalories(date);
}
// 导出每小时锻炼分钟数据获取函数
export async function fetchHourlyExerciseMinutesForDate(date: Date): Promise<HourlyExerciseData[]> {
return fetchHourlyExerciseMinutes(date);
}
// 导出每小时站立数据获取函数
export async function fetchHourlyStandHoursForDate(date: Date): Promise<HourlyStandData[]> {
const hourlyStandData = await fetchHourlyStandHours(date);
return hourlyStandData.map((hasStood, hour) => ({
hour,
hasStood
}));
}
// 专门为活动圆环详情页获取精简的数据
export async function fetchActivityRingsForDate(date: Date): Promise<ActivityRingsData | null> {
try {
console.log('获取活动圆环数据...', date);
const options = createDateRange(date);
const activitySummary = await fetchActivitySummary(options);
if (!activitySummary) {
console.warn('ActivitySummary 数据为空');
return null;
}
// 直接使用 getActivitySummary 返回的字段名,与文档保持一致
return {
activeEnergyBurned: Math.round(activitySummary.activeEnergyBurned || 0),
activeEnergyBurnedGoal: Math.round(activitySummary.activeEnergyBurnedGoal || 350),
appleExerciseTime: Math.round(activitySummary.appleExerciseTime || 0),
appleExerciseTimeGoal: Math.round(activitySummary.appleExerciseTimeGoal || 30),
appleStandHours: Math.round(activitySummary.appleStandHours || 0),
appleStandHoursGoal: Math.round(activitySummary.appleStandHoursGoal || 12),
};
} catch (error) {
console.error('获取活动圆环数据失败:', error);
return null;
}
}

View File

@@ -4,18 +4,24 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
const PREFERENCES_KEYS = {
QUICK_WATER_AMOUNT: 'user_preference_quick_water_amount',
NOTIFICATION_ENABLED: 'user_preference_notification_enabled',
FITNESS_EXERCISE_MINUTES_INFO_DISMISSED: 'user_preference_fitness_exercise_minutes_info_dismissed',
FITNESS_ACTIVE_HOURS_INFO_DISMISSED: 'user_preference_fitness_active_hours_info_dismissed',
} as const;
// 用户偏好设置接口
export interface UserPreferences {
quickWaterAmount: number;
notificationEnabled: boolean;
fitnessExerciseMinutesInfoDismissed: boolean;
fitnessActiveHoursInfoDismissed: boolean;
}
// 默认的用户偏好设置
const DEFAULT_PREFERENCES: UserPreferences = {
quickWaterAmount: 150, // 默认快速添加饮水量为 150ml
notificationEnabled: true, // 默认开启消息推送
fitnessExerciseMinutesInfoDismissed: false, // 默认显示锻炼分钟说明
fitnessActiveHoursInfoDismissed: false, // 默认显示活动小时说明
};
/**
@@ -25,10 +31,14 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
try {
const quickWaterAmount = await AsyncStorage.getItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT);
const notificationEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.NOTIFICATION_ENABLED);
const fitnessExerciseMinutesInfoDismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_EXERCISE_MINUTES_INFO_DISMISSED);
const fitnessActiveHoursInfoDismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_ACTIVE_HOURS_INFO_DISMISSED);
return {
quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount,
notificationEnabled: notificationEnabled !== null ? notificationEnabled === 'true' : DEFAULT_PREFERENCES.notificationEnabled,
fitnessExerciseMinutesInfoDismissed: fitnessExerciseMinutesInfoDismissed !== null ? fitnessExerciseMinutesInfoDismissed === 'true' : DEFAULT_PREFERENCES.fitnessExerciseMinutesInfoDismissed,
fitnessActiveHoursInfoDismissed: fitnessActiveHoursInfoDismissed !== null ? fitnessActiveHoursInfoDismissed === 'true' : DEFAULT_PREFERENCES.fitnessActiveHoursInfoDismissed,
};
} catch (error) {
console.error('获取用户偏好设置失败:', error);
@@ -90,6 +100,58 @@ export const getNotificationEnabled = async (): Promise<boolean> => {
}
};
/**
* 设置健身锻炼分钟说明已阅读状态
* @param dismissed 是否已阅读
*/
export const setFitnessExerciseMinutesInfoDismissed = async (dismissed: boolean): Promise<void> => {
try {
await AsyncStorage.setItem(PREFERENCES_KEYS.FITNESS_EXERCISE_MINUTES_INFO_DISMISSED, dismissed.toString());
} catch (error) {
console.error('设置健身锻炼分钟说明已阅读状态失败:', error);
throw error;
}
};
/**
* 获取健身锻炼分钟说明已阅读状态
*/
export const getFitnessExerciseMinutesInfoDismissed = async (): Promise<boolean> => {
try {
const dismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_EXERCISE_MINUTES_INFO_DISMISSED);
return dismissed !== null ? dismissed === 'true' : DEFAULT_PREFERENCES.fitnessExerciseMinutesInfoDismissed;
} catch (error) {
console.error('获取健身锻炼分钟说明已阅读状态失败:', error);
return DEFAULT_PREFERENCES.fitnessExerciseMinutesInfoDismissed;
}
};
/**
* 设置健身活动小时说明已阅读状态
* @param dismissed 是否已阅读
*/
export const setFitnessActiveHoursInfoDismissed = async (dismissed: boolean): Promise<void> => {
try {
await AsyncStorage.setItem(PREFERENCES_KEYS.FITNESS_ACTIVE_HOURS_INFO_DISMISSED, dismissed.toString());
} catch (error) {
console.error('设置健身活动小时说明已阅读状态失败:', error);
throw error;
}
};
/**
* 获取健身活动小时说明已阅读状态
*/
export const getFitnessActiveHoursInfoDismissed = async (): Promise<boolean> => {
try {
const dismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_ACTIVE_HOURS_INFO_DISMISSED);
return dismissed !== null ? dismissed === 'true' : DEFAULT_PREFERENCES.fitnessActiveHoursInfoDismissed;
} catch (error) {
console.error('获取健身活动小时说明已阅读状态失败:', error);
return DEFAULT_PREFERENCES.fitnessActiveHoursInfoDismissed;
}
};
/**
* 重置所有用户偏好设置为默认值
*/
@@ -97,6 +159,8 @@ export const resetUserPreferences = async (): Promise<void> => {
try {
await AsyncStorage.removeItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT);
await AsyncStorage.removeItem(PREFERENCES_KEYS.NOTIFICATION_ENABLED);
await AsyncStorage.removeItem(PREFERENCES_KEYS.FITNESS_EXERCISE_MINUTES_INFO_DISMISSED);
await AsyncStorage.removeItem(PREFERENCES_KEYS.FITNESS_ACTIVE_HOURS_INFO_DISMISSED);
} catch (error) {
console.error('重置用户偏好设置失败:', error);
throw error;