From 83805a4b07b1dc08f2bc094d741b98c2ee81a278 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 5 Sep 2025 15:32:34 +0800 Subject: [PATCH] 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 --- app/fitness-rings-detail.tsx | 860 ++++++++++++++++++++++++++++++ app/mood/calendar.tsx | 131 ++--- components/FitnessRingsCard.tsx | 12 +- components/NutritionRadarCard.tsx | 27 +- constants/Routes.ts | 3 + services/backgroundTaskManager.ts | 21 +- utils/haptics.ts | 56 ++ utils/health.ts | 242 ++++++++- utils/userPreferences.ts | 64 +++ 9 files changed, 1337 insertions(+), 79 deletions(-) create mode 100644 app/fitness-rings-detail.tsx create mode 100644 utils/haptics.ts diff --git a/app/fitness-rings-detail.tsx b/app/fitness-rings-detail.tsx new file mode 100644 index 0000000..171a9a0 --- /dev/null +++ b/app/fitness-rings-detail.tsx @@ -0,0 +1,860 @@ +import { CircularRing } from '@/components/CircularRing'; +import { ThemedView } from '@/components/ThemedView'; +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { + fetchActivityRingsForDate, + fetchHourlyActiveCaloriesForDate, + fetchHourlyExerciseMinutesForDate, + fetchHourlyStandHoursForDate, + type ActivityRingsData, + type HourlyActivityData, + type HourlyExerciseData, + type HourlyStandData +} from '@/utils/health'; +import { getFitnessExerciseMinutesInfoDismissed, setFitnessExerciseMinutesInfoDismissed } from '@/utils/userPreferences'; +import { Ionicons } from '@expo/vector-icons'; +import DateTimePicker from '@react-native-community/datetimepicker'; +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; +import weekday from 'dayjs/plugin/weekday'; +import { router } from 'expo-router'; +import React, { useEffect, useRef, useState } from 'react'; +import { + Animated, + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View +} from 'react-native'; + +// 配置 dayjs 插件 +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(weekday); + +// 设置默认时区为中国时区 +dayjs.tz.setDefault('Asia/Shanghai'); + +type WeekData = { + date: Date; + data: ActivityRingsData | null; + isToday: boolean; + dayName: string; +}; + +export default function FitnessRingsDetailScreen() { + const colorScheme = useColorScheme(); + const [weekData, setWeekData] = useState([]); + const [selectedDate, setSelectedDate] = useState(new Date()); + const [selectedDayData, setSelectedDayData] = useState(null); + const [datePickerVisible, setDatePickerVisible] = useState(false); + const [pickerDate, setPickerDate] = useState(new Date()); + // 每小时数据状态 + const [hourlyCaloriesData, setHourlyCaloriesData] = useState([]); + const [hourlyExerciseData, setHourlyExerciseData] = useState([]); + const [hourlyStandData, setHourlyStandData] = useState([]); + const [showExerciseInfo, setShowExerciseInfo] = useState(true); + const exerciseInfoAnim = useRef(new Animated.Value(1)).current; + + useEffect(() => { + // 加载周数据和选中日期的详细数据 + loadWeekData(selectedDate); + loadSelectedDayData(); + loadExerciseInfoPreference(); + }, [selectedDate]); + + const loadExerciseInfoPreference = async () => { + try { + const dismissed = await getFitnessExerciseMinutesInfoDismissed(); + setShowExerciseInfo(!dismissed); + if (!dismissed) { + exerciseInfoAnim.setValue(1); + } else { + exerciseInfoAnim.setValue(0); + } + } catch (error) { + console.error('加载锻炼分钟说明偏好失败:', error); + } + }; + + const loadWeekData = async (targetDate: Date) => { + const target = dayjs(targetDate).tz('Asia/Shanghai'); + const today = dayjs().tz('Asia/Shanghai'); + const weekDays = []; + + // 获取目标日期所在周的数据 (周一到周日) + // 使用 weekday() 确保周一为一周的开始 (0=Monday, 6=Sunday) + const startOfWeek = target.weekday(0); // 周一开始 + + for (let i = 0; i < 7; i++) { + const currentDay = startOfWeek.add(i, 'day'); + const isToday = currentDay.isSame(today, 'day'); + const dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; + + try { + const activityRingsData = await fetchActivityRingsForDate(currentDay.toDate()); + weekDays.push({ + date: currentDay.toDate(), + data: activityRingsData, + isToday, + dayName: dayNames[i] + }); + } catch (error) { + console.error('Failed to fetch activity rings data for', currentDay.format('YYYY-MM-DD'), error); + weekDays.push({ + date: currentDay.toDate(), + data: null, + isToday, + dayName: dayNames[i] + }); + } + } + + setWeekData(weekDays); + }; + + const loadSelectedDayData = async () => { + try { + // 并行获取活动圆环数据和每小时详细数据 + const [activityRingsData, hourlyCalories, hourlyExercise, hourlyStand] = await Promise.all([ + fetchActivityRingsForDate(selectedDate), + fetchHourlyActiveCaloriesForDate(selectedDate), + fetchHourlyExerciseMinutesForDate(selectedDate), + fetchHourlyStandHoursForDate(selectedDate) + ]); + + setSelectedDayData(activityRingsData); + setHourlyCaloriesData(hourlyCalories); + setHourlyExerciseData(hourlyExercise); + setHourlyStandData(hourlyStand); + } catch (error) { + console.error('Failed to fetch selected day activity rings data', error); + setSelectedDayData(null); + setHourlyCaloriesData([]); + setHourlyExerciseData([]); + setHourlyStandData([]); + } + }; + + // 日期选择器相关函数 + const openDatePicker = () => { + setPickerDate(selectedDate); + setDatePickerVisible(true); + }; + + const closeDatePicker = () => setDatePickerVisible(false); + + const onConfirmDate = async (date: Date) => { + const today = dayjs().tz('Asia/Shanghai').startOf('day'); + const picked = dayjs(date).tz('Asia/Shanghai').startOf('day'); + const finalDate = picked.isAfter(today) ? today.toDate() : picked.toDate(); + + setSelectedDate(finalDate); + closeDatePicker(); + }; + + // 格式化头部显示的日期 + const formatHeaderDate = (date: Date) => { + const dayJsDate = dayjs(date).tz('Asia/Shanghai'); + return `${dayJsDate.format('YYYY年MM月DD日')}`; + }; + + const renderWeekRingItem = (item: WeekData, index: number) => { + const isSelected = dayjs(item.date).tz('Asia/Shanghai').isSame(dayjs(selectedDate).tz('Asia/Shanghai'), 'day'); + + // 使用默认值确保即使没有数据也能显示圆环 + const data = item.data || { + activeEnergyBurned: 0, + activeEnergyBurnedGoal: 350, + appleExerciseTime: 0, + appleExerciseTimeGoal: 30, + appleStandHours: 0, + appleStandHoursGoal: 12, + }; + + const { activeEnergyBurned, activeEnergyBurnedGoal, appleExerciseTime, appleExerciseTimeGoal, appleStandHours, appleStandHoursGoal } = data; + + // 计算进度百分比 + const caloriesProgress = Math.min(1, Math.max(0, activeEnergyBurned / activeEnergyBurnedGoal)); + const exerciseProgress = Math.min(1, Math.max(0, appleExerciseTime / appleExerciseTimeGoal)); + const standProgress = Math.min(1, Math.max(0, appleStandHours / appleStandHoursGoal)); + + // 检查是否完成了所有目标 + const isComplete = caloriesProgress >= 1 && exerciseProgress >= 1 && standProgress >= 1; + + return ( + setSelectedDate(item.date)} + > + + {/* {isComplete && ( + + + + )} */} + + + {/* 外圈 - 活动卡路里 (红色) */} + + + + + {/* 中圈 - 锻炼分钟 (橙色) */} + + + + + {/* 内圈 - 站立小时 (蓝色) */} + + + + + + + {dayjs(item.date).tz('Asia/Shanghai').date()} + + + + + {item.dayName} + + + ); + }; + + const getClosedRingCount = () => { + let count = 0; + weekData.forEach(item => { + // 使用默认值处理空数据情况 + const data = item.data || { + activeEnergyBurned: 0, + activeEnergyBurnedGoal: 350, + appleExerciseTime: 0, + appleExerciseTimeGoal: 30, + appleStandHours: 0, + appleStandHoursGoal: 12, + }; + + const { activeEnergyBurned, activeEnergyBurnedGoal, appleExerciseTime, appleExerciseTimeGoal, appleStandHours, appleStandHoursGoal } = data; + const caloriesComplete = activeEnergyBurned >= activeEnergyBurnedGoal; + const exerciseComplete = appleExerciseTime >= appleExerciseTimeGoal; + const standComplete = appleStandHours >= appleStandHoursGoal; + + if (caloriesComplete && exerciseComplete && standComplete) { + count++; + } + }); + return count; + }; + + const handleKnowButtonPress = async () => { + try { + await setFitnessExerciseMinutesInfoDismissed(true); + Animated.timing(exerciseInfoAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }).start(() => { + setShowExerciseInfo(false); + }); + } catch (error) { + console.error('保存锻炼分钟说明偏好失败:', error); + } + }; + + // 渲染简单的柱状图 + const renderBarChart = (data: number[], maxValue: number, color: string, unit: string) => { + // 确保始终有24小时的数据,没有数据时用0填充 + const chartData = Array.from({ length: 24 }, (_, index) => { + if (data && data.length > index) { + return data[index] || 0; + } + return 0; + }); + + // 计算最大值,如果所有数据都是0,使用传入的maxValue作为参考 + const maxChartValue = Math.max(...chartData, 1); // 确保最小值为1,避免除零 + const effectiveMaxValue = Math.max(maxChartValue, maxValue); + + return ( + + + {chartData.map((value, index) => { + const height = Math.max(2, (value / effectiveMaxValue) * 40); // 最小高度2,最大40 + return ( + 0 ? height : 2, // 没有数据时显示最小高度的灰色条 + backgroundColor: value > 0 ? color : '#E5E5EA', + opacity: value > 0 ? 1 : 0.5 + } + ]} + /> + ); + })} + + + 00:00 + 06:00 + 12:00 + 18:00 + + + ); + }; + + const renderSelectedDayDetail = () => { + // 使用默认值确保即使没有数据也能显示图表 + const data = selectedDayData || { + activeEnergyBurned: 0, + activeEnergyBurnedGoal: 350, + appleExerciseTime: 0, + appleExerciseTimeGoal: 30, + appleStandHours: 0, + appleStandHoursGoal: 12, + }; + + const { activeEnergyBurned, activeEnergyBurnedGoal, appleExerciseTime, appleExerciseTimeGoal, appleStandHours, appleStandHoursGoal } = data; + + return ( + + {/* 活动热量卡片 */} + + + 活动热量 + + ? + + + + + + {Math.round(activeEnergyBurned)}/{activeEnergyBurnedGoal} + + 千卡 + + + + {Math.round(activeEnergyBurned)}千卡 + + + {renderBarChart( + hourlyCaloriesData.map(h => h.calories), + Math.max(activeEnergyBurnedGoal / 24, 1), + '#FF3B30', + '千卡' + )} + + + {/* 锻炼分钟卡片 */} + + + 锻炼分钟数 + + ? + + + + + + {Math.round(appleExerciseTime)}/{appleExerciseTimeGoal} + + 分钟 + + + + {Math.round(appleExerciseTime)}分钟 + + + {renderBarChart( + hourlyExerciseData.map(h => h.minutes), + Math.max(appleExerciseTimeGoal / 8, 1), + '#FF9500', + '分钟' + )} + + {/* 锻炼分钟说明 */} + {showExerciseInfo && ( + + 锻炼分钟数: + + 进行强度不低于"快走"的运动锻炼,就会积累对应时长的锻炼分钟数。 + + + 世卫组织推荐的成年人每天至少保持30分钟以上的中高强度运动。 + + + 知道了 + + + )} + + + {/* 活动小时数卡片 */} + + + 活动小时数 + + ? + + + + + + {Math.round(appleStandHours)}/{appleStandHoursGoal} + + 小时 + + + + {Math.round(appleStandHours)}小时 + + + {renderBarChart( + hourlyStandData.map(h => h.hasStood), + 1, + '#007AFF', + '小时' + )} + + + ); + }; + + return ( + + {/* 头部 */} + router.back()} + right={ + + + + } + withSafeTop={true} + transparent={true} + variant="default" + /> + + + {/* 本周圆环横向滚动 */} + + + {weekData.map((item, index) => renderWeekRingItem(item, index))} + + + + {/* 选中日期的详细数据 */} + {renderSelectedDayDetail()} + + {/* 周闭环天数统计 */} + + + 周闭环天数 + + {getClosedRingCount()}天 + + + + + + {/* 日期选择器弹窗 */} + + + + { + if (Platform.OS === 'ios') { + if (date) setPickerDate(date); + } else { + if (event.type === 'set' && date) { + onConfirmDate(date); + } else { + closeDatePicker(); + } + } + }} + /> + {Platform.OS === 'ios' && ( + + + 取消 + + { + onConfirmDate(pickerDate); + }} style={[styles.modalBtn, styles.modalBtnPrimary]}> + 确定 + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + calendarButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 32, + }, + weekSection: { + paddingVertical: 20, + }, + weekScrollView: { + paddingHorizontal: 16, + }, + weekScrollContent: { + paddingHorizontal: 8, + }, + weekRingItem: { + alignItems: 'center', + marginHorizontal: 8, + padding: 8, + borderRadius: 12, + }, + weekRingItemSelected: { + backgroundColor: 'rgba(0, 122, 255, 0.1)', + }, + weekRingContainer: { + position: 'relative', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 8, + }, + weekStarContainer: { + position: 'absolute', + top: -8, + right: -8, + zIndex: 10, + }, + weekStarIcon: { + fontSize: 12, + }, + weekRingsWrapper: { + position: 'relative', + width: 50, + height: 50, + alignItems: 'center', + justifyContent: 'center', + }, + ringPosition: { + position: 'absolute', + alignItems: 'center', + justifyContent: 'center', + }, + weekDayNumber: { + fontSize: 11, + fontWeight: '600', + marginTop: 6, + }, + weekTodayNumber: { + color: '#007AFF', + }, + weekSelectedNumber: { + fontWeight: '700', + }, + weekDayLabel: { + fontSize: 10, + fontWeight: '500', + marginTop: 2, + }, + weekTodayLabel: { + color: '#007AFF', + }, + weekSelectedLabel: { + fontWeight: '600', + }, + detailContainer: { + paddingHorizontal: 16, + paddingVertical: 20, + }, + // 卡片样式 + metricCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 20, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.06, + shadowRadius: 8, + elevation: 3, + }, + cardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + cardTitle: { + fontSize: 18, + fontWeight: '600', + color: '#1C1C1E', + }, + helpButton: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: '#F2F2F7', + alignItems: 'center', + justifyContent: 'center', + }, + helpIcon: { + fontSize: 14, + fontWeight: '600', + color: '#8E8E93', + }, + cardValue: { + flexDirection: 'row', + alignItems: 'baseline', + marginBottom: 8, + }, + valueText: { + fontSize: 32, + fontWeight: '700', + letterSpacing: -1, + }, + unitText: { + fontSize: 18, + fontWeight: '500', + color: '#8E8E93', + marginLeft: 4, + }, + cardSubtext: { + fontSize: 14, + color: '#8E8E93', + marginBottom: 20, + }, + // 图表样式 + chartContainer: { + marginTop: 16, + }, + chartBars: { + flexDirection: 'row', + alignItems: 'flex-end', + height: 60, + marginBottom: 8, + paddingHorizontal: 4, + justifyContent: 'space-between', + }, + chartBar: { + width: 3, + borderRadius: 1.5, + marginHorizontal: 0.5, + }, + chartLabels: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 4, + }, + chartLabel: { + fontSize: 12, + color: '#8E8E93', + fontWeight: '500', + }, + // 锻炼信息样式 + exerciseInfo: { + marginTop: 20, + padding: 16, + backgroundColor: '#F2F2F7', + borderRadius: 12, + }, + exerciseTitle: { + fontSize: 16, + fontWeight: '600', + color: '#1C1C1E', + marginBottom: 8, + }, + exerciseDesc: { + fontSize: 14, + color: '#3C3C43', + lineHeight: 20, + marginBottom: 12, + }, + exerciseRecommendation: { + fontSize: 14, + color: '#3C3C43', + lineHeight: 20, + marginBottom: 16, + }, + knowButton: { + alignSelf: 'flex-end', + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: '#007AFF', + borderRadius: 20, + }, + knowButtonText: { + fontSize: 14, + fontWeight: '600', + color: '#FFFFFF', + }, + noDataText: { + fontSize: 16, + textAlign: 'center', + marginTop: 40, + }, + statsContainer: { + marginHorizontal: 16, + marginTop: 32, + padding: 16, + backgroundColor: 'rgba(0, 0, 0, 0.05)', + borderRadius: 12, + }, + statRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + statLabel: { + fontSize: 16, + fontWeight: '500', + }, + statValue: { + flexDirection: 'row', + alignItems: 'center', + }, + statNumber: { + fontSize: 16, + fontWeight: '600', + marginLeft: 4, + }, + starIcon: { + fontSize: 16, + }, + // 日期选择器样式 + modalBackdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.4)', + }, + modalSheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + padding: 16, + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + }, + modalActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: 8, + gap: 12, + }, + modalBtn: { + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 10, + backgroundColor: '#F1F5F9', + }, + modalBtnPrimary: { + backgroundColor: '#7a5af8', + }, + modalBtnText: { + color: '#334155', + fontWeight: '700', + }, + modalBtnTextPrimary: { + color: '#FFFFFF', + fontWeight: '700', + }, +}); \ No newline at end of file diff --git a/app/mood/calendar.tsx b/app/mood/calendar.tsx index 3fdbf16..5be6ae8 100644 --- a/app/mood/calendar.tsx +++ b/app/mood/calendar.tsx @@ -1,7 +1,5 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; -import { Colors } from '@/constants/Colors'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import { useColorScheme } from '@/hooks/useColorScheme'; +import { useAppSelector } from '@/hooks/redux'; import { useMoodData } from '@/hooks/useMoodData'; import { getMoodOptions } from '@/services/moodCheckins'; import { selectLatestMoodRecordByDate } from '@/store/moodSlice'; @@ -22,16 +20,22 @@ const { width } = Dimensions.get('window'); // 心情日历数据生成函数 const generateCalendarData = (targetDate: Date) => { - const year = targetDate.getFullYear(); - const month = targetDate.getMonth(); - const daysInMonth = new Date(year, month + 1, 0).getDate(); - const firstDayOfWeek = new Date(year, month, 1).getDay(); + // 使用 dayjs 确保时区一致性 + const targetDayjs = dayjs(targetDate); + const year = targetDayjs.year(); + const month = targetDayjs.month(); // dayjs month is 0-based + const daysInMonth = targetDayjs.daysInMonth(); + + // 使用 dayjs 获取月初第一天是周几(0=周日,1=周一...6=周六) + const firstDayOfWeek = targetDayjs.startOf('month').day(); + // 转换为中国习惯(周一为一周开始):周日(0)转为6,其他减1 + const firstDayAdjusted = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1; const calendar = []; const weeks = []; - // 添加空白日期 - for (let i = 0; i < firstDayOfWeek; i++) { + // 添加空白日期(基于周一开始) + for (let i = 0; i < firstDayAdjusted; i++) { weeks.push(null); } @@ -45,14 +49,18 @@ const generateCalendarData = (targetDate: Date) => { calendar.push(weeks.slice(i, i + 7)); } - return { calendar, today: new Date().getDate(), month: month + 1, year }; + // 使用 dayjs 获取今天的日期,确保时区一致 + const today = dayjs(); + return { + calendar, + today: today.date(), + month: month + 1, // 转回1-based用于显示 + year + }; }; export default function MoodCalendarScreen() { - const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; - const colorTokens = Colors[theme]; const params = useLocalSearchParams(); - const dispatch = useAppDispatch(); const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData(); // 使用 useRef 来存储函数引用,避免依赖循环 @@ -116,9 +124,9 @@ export default function MoodCalendarScreen() { loadDailyMoodCheckins(dateString); loadMonthMoodData(date.toDate()); } else { - const today = new Date(); + const today = dayjs().toDate(); setCurrentMonth(today); - setSelectedDay(today.getDate()); + setSelectedDay(dayjs().date()); const dateString = dayjs().format('YYYY-MM-DD'); loadDailyMoodCheckins(dateString); loadMonthMoodData(today); @@ -144,16 +152,14 @@ export default function MoodCalendarScreen() { // 月份切换函数 const goToPreviousMonth = () => { - const newMonth = new Date(currentMonth); - newMonth.setMonth(newMonth.getMonth() - 1); + const newMonth = dayjs(currentMonth).subtract(1, 'month').toDate(); setCurrentMonth(newMonth); setSelectedDay(null); loadMonthMoodData(newMonth); }; const goToNextMonth = () => { - const newMonth = new Date(currentMonth); - newMonth.setMonth(newMonth.getMonth() + 1); + const newMonth = dayjs(currentMonth).add(1, 'month').toDate(); setCurrentMonth(newMonth); setSelectedDay(null); loadMonthMoodData(newMonth); @@ -188,9 +194,9 @@ export default function MoodCalendarScreen() { const dayRecords = moodRecords[dayDateString] || []; const moodRecord = dayRecords.length > 0 ? dayRecords[0] : null; - const isToday = day === new Date().getDate() && - month === new Date().getMonth() + 1 && - year === new Date().getFullYear(); + const isToday = day === dayjs().date() && + month === dayjs().month() + 1 && + year === dayjs().year(); if (moodRecord) { const mood = moodOptions.find(m => m.type === moodRecord.moodType); @@ -260,43 +266,45 @@ export default function MoodCalendarScreen() { ))} - {calendar.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => { - const isSelected = day === selectedDay; - const isToday = day === today && month === new Date().getMonth() + 1 && year === new Date().getFullYear(); - const isFutureDate = Boolean(day && dayjs(currentMonth).date(day).isAfter(dayjs(), 'day')); + + {calendar.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => { + const isSelected = day === selectedDay; + const isToday = day === today && month === dayjs().month() + 1 && year === dayjs().year(); + const isFutureDate = Boolean(day && dayjs(currentMonth).date(day).isAfter(dayjs(), 'day')); - return ( - - {day && ( - !isFutureDate && day && onSelectDate(day)} - disabled={isFutureDate} - > - - - {day.toString().padStart(2, '0')} - - {renderMoodRing(day, isSelected)} - - - )} - - ); - })} - - ))} + return ( + + {day && ( + !isFutureDate && day && onSelectDate(day)} + disabled={isFutureDate} + > + + + {day.toString().padStart(2, '0')} + + {renderMoodRing(day, isSelected)} + + + )} + + ); + })} + + ))} + {/* 选中日期的记录 */} @@ -402,6 +410,8 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(255,255,255,0.95)', margin: 16, borderRadius: 20, + justifyContent: 'center', + alignItems: 'center', padding: 20, shadowColor: '#7a5af8', shadowOffset: { width: 0, height: 4 }, @@ -411,6 +421,7 @@ const styles = StyleSheet.create({ }, monthNavigation: { flexDirection: 'row', + width: '100%', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24, @@ -440,7 +451,7 @@ const styles = StyleSheet.create({ }, weekHeader: { flexDirection: 'row', - justifyContent: 'space-around', + justifyContent: 'flex-start', marginBottom: 20, }, weekDay: { @@ -452,7 +463,7 @@ const styles = StyleSheet.create({ }, weekRow: { flexDirection: 'row', - justifyContent: 'space-around', + justifyContent: 'flex-start', marginBottom: 16, }, dayContainer: { diff --git a/components/FitnessRingsCard.tsx b/components/FitnessRingsCard.tsx index ee9872f..36a2275 100644 --- a/components/FitnessRingsCard.tsx +++ b/components/FitnessRingsCard.tsx @@ -1,6 +1,8 @@ import React from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import { StyleSheet, Text, View, TouchableOpacity } from 'react-native'; +import { router } from 'expo-router'; import { CircularRing } from './CircularRing'; +import { ROUTES } from '@/constants/Routes'; type FitnessRingsCardProps = { style?: any; @@ -35,8 +37,12 @@ export function FitnessRingsCard({ const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal)); const standProgress = Math.min(1, Math.max(0, standHours / standHoursGoal)); + const handlePress = () => { + router.push(ROUTES.FITNESS_RINGS_DETAIL); + }; + return ( - + {/* 左侧圆环 */} @@ -112,7 +118,7 @@ export function FitnessRingsCard({ - + ); } diff --git a/components/NutritionRadarCard.tsx b/components/NutritionRadarCard.tsx index 97d36c5..12f55eb 100644 --- a/components/NutritionRadarCard.tsx +++ b/components/NutritionRadarCard.tsx @@ -2,13 +2,12 @@ import { AnimatedNumber } from '@/components/AnimatedNumber'; import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay'; import { ROUTES } from '@/constants/Routes'; import { NutritionSummary } from '@/services/dietRecords'; +import { triggerLightHaptic } from '@/utils/haptics'; import { NutritionGoals, calculateRemainingCalories } from '@/utils/nutrition'; -import { Ionicons } from '@expo/vector-icons'; import dayjs from 'dayjs'; -import * as Haptics from 'expo-haptics'; import { router } from 'expo-router'; import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Animated, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import Svg, { Circle } from 'react-native-svg'; const AnimatedCircle = Animated.createAnimatedComponent(Circle); @@ -138,25 +137,27 @@ export function NutritionRadarCard({ }); const handleNavigateToRecords = () => { - // ios 下震动反馈 - if (Platform.OS === 'ios') { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } + triggerLightHaptic(); router.push(ROUTES.NUTRITION_RECORDS); }; const handleAddFood = () => { + triggerLightHaptic(); setShowFoodOverlay(true); }; return ( - 营养摄入分析 + 饮食分析 更新: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')} - + {/* */} + 添加+ @@ -419,10 +420,10 @@ const styles = StyleSheet.create({ fontSize: 24, }, addButton: { - width: 22, - height: 22, - borderRadius: 12, - backgroundColor: '#c1c1eeff', + width: 52, + height: 26, + borderRadius: 16, + backgroundColor: '#7b7be2ff', marginLeft: 8, alignItems: 'center', justifyContent: 'center', diff --git a/constants/Routes.ts b/constants/Routes.ts index dfb2aab..a9baa5d 100644 --- a/constants/Routes.ts +++ b/constants/Routes.ts @@ -41,6 +41,9 @@ export const ROUTES = { // 体重记录相关路由 WEIGHT_RECORDS: '/weight-records', + // 健康相关路由 + FITNESS_RINGS_DETAIL: '/fitness-rings-detail', + // 任务相关路由 TASK_DETAIL: '/task-detail', diff --git a/services/backgroundTaskManager.ts b/services/backgroundTaskManager.ts index 9c2a479..39bbba9 100644 --- a/services/backgroundTaskManager.ts +++ b/services/backgroundTaskManager.ts @@ -38,7 +38,7 @@ export class BackgroundTaskManager { try { // 配置后台获取 const status = await BackgroundFetch.configure({ - minimumFetchInterval: 15000, // 最小间隔15分钟(iOS 实际控制间隔) + minimumFetchInterval: 15, // 最小间隔15分钟(iOS 实际控制间隔) }, async (taskId) => { console.log('[BackgroundFetch] 后台任务执行:', taskId); await this.executeBackgroundTasks(); @@ -73,6 +73,24 @@ export class BackgroundTaskManager { return; } + // 获取用户名 + const state = store.getState(); + const userName = state.user.profile?.name || '朋友'; + + // 发送测试通知 + const Notifications = await import('expo-notifications'); + + await Notifications.scheduleNotificationAsync({ + content: { + title: '测试通知', + body: `你好 ${userName}!这是一条测试消息,用于验证通知功能是否正常工作。`, + data: { type: 'test_notification', timestamp: Date.now() }, + sound: true, + priority: Notifications.AndroidNotificationPriority.HIGH, + }, + trigger: null, // 立即发送 + }); + // 执行喝水提醒检查任务 await this.executeWaterReminderTask(); @@ -375,6 +393,7 @@ export class BackgroundTaskManager { console.error('站立提醒后台任务测试失败:', error); } } + } /** diff --git a/utils/haptics.ts b/utils/haptics.ts new file mode 100644 index 0000000..7d4e3c8 --- /dev/null +++ b/utils/haptics.ts @@ -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); + } +}; \ No newline at end of file diff --git a/utils/health.ts b/utils/health.ts index eb3ce82..209783c 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -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 { (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 { }); } +// 获取每小时活动热量数据 +// 优化版本:使用更精确的时间间隔来获取每小时数据 +async function fetchHourlyActiveCalories(date: Date): Promise { + 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((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 { + 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 { + 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 { 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 { + return fetchHourlyActiveCalories(date); +} + +// 导出每小时锻炼分钟数据获取函数 +export async function fetchHourlyExerciseMinutesForDate(date: Date): Promise { + return fetchHourlyExerciseMinutes(date); +} + +// 导出每小时站立数据获取函数 +export async function fetchHourlyStandHoursForDate(date: Date): Promise { + const hourlyStandData = await fetchHourlyStandHours(date); + return hourlyStandData.map((hasStood, hour) => ({ + hour, + hasStood + })); +} + +// 专门为活动圆环详情页获取精简的数据 +export async function fetchActivityRingsForDate(date: Date): Promise { + 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; + } +} diff --git a/utils/userPreferences.ts b/utils/userPreferences.ts index 7b1f237..47c9920 100644 --- a/utils/userPreferences.ts +++ b/utils/userPreferences.ts @@ -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 => { 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 => { } }; +/** + * 设置健身锻炼分钟说明已阅读状态 + * @param dismissed 是否已阅读 + */ +export const setFitnessExerciseMinutesInfoDismissed = async (dismissed: boolean): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { 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;