diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index fd4f424..8ece39b 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -12,17 +12,14 @@ import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; -import { notificationService } from '@/services/notifications'; import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice'; import { fetchTodayWaterStats, selectTodayStats } from '@/store/waterSlice'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; -import { ensureHealthPermissions, fetchHealthDataForDate, fetchRecentHRV } from '@/utils/health'; +import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health'; import { getTestHealthData } from '@/utils/mockHealthData'; -import { WaterNotificationHelpers } from '@/utils/notificationHelpers'; import { calculateNutritionGoals } from '@/utils/nutrition'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; import dayjs from 'dayjs'; @@ -412,121 +409,6 @@ export default function ExploreScreen() { }; }, [loadAllData, currentSelectedDate]); - // 检查压力水平并发送通知 - const checkStressLevelAndNotify = React.useCallback(async () => { - try { - console.log('开始检查压力水平...'); - - // 确保有健康权限 - const hasPermission = await ensureHealthPermissions(); - if (!hasPermission) { - console.log('没有健康权限,跳过压力检查'); - return; - } - - // 获取最近2小时内的实时HRV数据 - const recentHRV = await fetchRecentHRV(2); - console.log('获取到的最近2小时HRV值:', recentHRV); - - if (recentHRV === null || recentHRV === undefined) { - console.log('没有最近的HRV数据,跳过压力检查'); - return; - } - - // 判断压力水平(HRV值低于60表示压力过大) - if (recentHRV < 60) { - console.log(`检测到压力过大,HRV值: ${recentHRV},准备发送鼓励通知`); - - // 检查是否在过去2小时内已经发送过压力提醒,避免重复打扰 - const lastNotificationKey = '@last_stress_notification'; - const lastNotificationTime = await AsyncStorage.getItem(lastNotificationKey); - const now = new Date().getTime(); - const twoHoursAgo = now - (2 * 60 * 60 * 1000); // 2小时前 - - if (lastNotificationTime && parseInt(lastNotificationTime) > twoHoursAgo) { - console.log('2小时内已发送过压力提醒,跳过本次通知'); - return; - } - - // 随机选择一条鼓励性消息 - const encouragingMessages = [ - '放松一下吧 🌸\n检测到您的压力指数较高,不妨暂停一下,做几个深呼吸,或者来一段轻松的普拉提练习。您的健康最重要!', - '该休息一下了 🧘‍♀️\n您的身体在提醒您需要放松。试试冥想、散步或听听舒缓的音乐,让心情平静下来。', - '压力山大?我们来帮您 💆‍♀️\n高压力对健康不利,建议您做一些放松运动,比如瑜伽或普拉提,释放身心压力。', - '关爱自己,从现在开始 💝\n检测到您可能承受较大压力,记得给自己一些时间,做喜欢的事情,保持身心健康。', - '深呼吸,一切都会好的 🌈\n压力只是暂时的,试试腹式呼吸或简单的伸展运动,让身体和心灵都得到放松。' - ]; - - const randomMessage = encouragingMessages[Math.floor(Math.random() * encouragingMessages.length)]; - const [title, body] = randomMessage.split('\n'); - - // 发送鼓励性推送通知 - await notificationService.sendImmediateNotification({ - title: title, - body: body, - data: { - type: 'stress_alert', - hrvValue: recentHRV, - timestamp: new Date().toISOString(), - url: '/mood/calendar' // 点击通知跳转到心情页面 - }, - sound: true, - priority: 'high' - }); - - // 记录通知发送时间 - await AsyncStorage.setItem(lastNotificationKey, now.toString()); - - console.log('压力提醒通知已发送'); - } else { - console.log(`压力水平正常,HRV值: ${recentHRV}`); - } - } catch (error) { - console.error('检查压力水平失败:', error); - } - }, []); - - // 检查喝水目标并发送通知 - const checkWaterGoalAndNotify = React.useCallback(async () => { - try { - console.log('开始检查喝水目标完成情况...'); - - // 获取最新的喝水统计数据 - if (!todayWaterStats || !todayWaterStats.dailyGoal || todayWaterStats.dailyGoal <= 0) { - console.log('没有设置喝水目标或目标无效,跳过喝水检查'); - return; - } - - // 获取用户名 - const userName = userProfile?.name || '朋友'; - const currentHour = new Date().getHours(); - - // 构造今日统计数据 - const waterStatsForCheck = { - totalAmount: todayWaterStats.totalAmount || 0, - dailyGoal: todayWaterStats.dailyGoal, - completionRate: todayWaterStats.completionRate || 0 - }; - - // 调用喝水通知检查函数 - const notificationSent = await WaterNotificationHelpers.checkWaterGoalAndNotify( - userName, - waterStatsForCheck, - currentHour - ); - - if (notificationSent) { - console.log('喝水提醒通知已发送'); - } else { - console.log('无需发送喝水提醒通知'); - } - - } catch (error) { - console.error('检查喝水目标失败:', error); - } - }, [todayWaterStats, userProfile]); - - // 日期点击时,加载对应日期数据 const onSelectDate = React.useCallback((index: number, date: Date) => { diff --git a/app/steps/detail.tsx b/app/steps/detail.tsx index 95d46c1..77dbd7a 100644 --- a/app/steps/detail.tsx +++ b/app/steps/detail.tsx @@ -176,6 +176,39 @@ export default function StepsDetailScreen() { return maxStepsData.steps > 0 ? maxStepsData : null; }, [hourlySteps]); + // 活动等级配置 + const activityLevels = useMemo(() => [ + { key: 'inactive', label: '不怎么动', minSteps: 0, maxSteps: 3000, color: '#B8C8D6' }, + { key: 'light', label: '轻度活跃', minSteps: 3000, maxSteps: 7500, color: '#93C5FD' }, + { key: 'moderate', label: '中等活跃', minSteps: 7500, maxSteps: 10000, color: '#FCD34D' }, + { key: 'very_active', label: '非常活跃', minSteps: 10000, maxSteps: Infinity, color: '#FB923C' } + ], []); + + // 计算当前活动等级 + const currentActivityLevel = useMemo(() => { + return activityLevels.find(level => + totalSteps >= level.minSteps && totalSteps < level.maxSteps + ) || activityLevels[0]; + }, [totalSteps, activityLevels]); + + // 计算下一等级 + const nextActivityLevel = useMemo(() => { + const currentIndex = activityLevels.indexOf(currentActivityLevel); + return currentIndex < activityLevels.length - 1 ? activityLevels[currentIndex + 1] : null; + }, [currentActivityLevel, activityLevels]); + + // 计算进度百分比 + const progressPercentage = useMemo(() => { + if (!nextActivityLevel) return 100; // 已达到最高级 + + const rangeSize = nextActivityLevel.minSteps - currentActivityLevel.minSteps; + const currentProgress = totalSteps - currentActivityLevel.minSteps; + return Math.min(Math.max((currentProgress / rangeSize) * 100, 0), 100); + }, [totalSteps, currentActivityLevel, nextActivityLevel]); + + // 倒序显示的活动等级(用于图例) + const reversedActivityLevels = useMemo(() => [...activityLevels].reverse(), [activityLevels]); + return ( {/* 背景渐变 */} @@ -343,6 +376,63 @@ export default function StepsDetailScreen() { + + {/* 活动等级展示卡片 */} + + + + {/* 活动级别文本 */} + 你今天的活动量处于 + {currentActivityLevel.label} + + {/* 进度条 */} + + + + + + + {/* 步数信息 */} + + + {totalSteps.toLocaleString()} 步 + 当前 + + + + {nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()} 步` : '--'} + + + {nextActivityLevel ? `下一级: ${nextActivityLevel.label}` : '已达最高级'} + + + + + {/* 活动等级图例 */} + + {reversedActivityLevels.map((level) => ( + + + 🏃 + + {level.label} + + {level.maxSteps === Infinity + ? `> ${level.minSteps.toLocaleString()}` + : `${level.minSteps.toLocaleString()} - ${level.maxSteps.toLocaleString()}`} + + + ))} + + @@ -541,4 +631,127 @@ const styles = StyleSheet.create({ borderWidth: 0.5, borderColor: '#FFA726', }, + activityLevelCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 24, + marginVertical: 16, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.08, + shadowRadius: 12, + elevation: 6, + }, + activityIconContainer: { + marginBottom: 16, + }, + activityIcon: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: '#E0F2FE', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: '#93C5FD', + borderStyle: 'dashed', + }, + meditationIcon: { + width: 50, + height: 50, + borderRadius: 25, + backgroundColor: '#93C5FD', + alignItems: 'center', + justifyContent: 'center', + }, + meditationEmoji: { + fontSize: 24, + }, + activityMainText: { + fontSize: 16, + color: '#64748B', + marginBottom: 4, + }, + activityLevelText: { + fontSize: 24, + fontWeight: '700', + color: '#192126', + marginBottom: 20, + }, + progressBarContainer: { + width: '100%', + marginBottom: 24, + }, + progressBarBackground: { + width: '100%', + height: 8, + backgroundColor: '#F0F9FF', + borderRadius: 4, + overflow: 'hidden', + }, + progressBarFill: { + height: '100%', + borderRadius: 4, + }, + stepsInfoContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + width: '100%', + marginBottom: 32, + }, + currentStepsInfo: { + alignItems: 'flex-start', + }, + nextStepsInfo: { + alignItems: 'flex-end', + }, + stepsValue: { + fontSize: 20, + fontWeight: '700', + color: '#192126', + marginBottom: 4, + }, + stepsLabel: { + fontSize: 14, + color: '#64748B', + }, + activityLegendContainer: { + width: '100%', + }, + legendItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 12, + paddingHorizontal: 16, + backgroundColor: '#F8FAFC', + marginBottom: 8, + borderRadius: 12, + }, + legendIcon: { + width: 32, + height: 32, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + legendIconText: { + fontSize: 16, + }, + legendLabel: { + flex: 1, + fontSize: 14, + fontWeight: '600', + color: '#192126', + }, + legendRange: { + fontSize: 14, + color: '#64748B', + fontWeight: '500', + }, }); \ No newline at end of file diff --git a/components/StressMeter.tsx b/components/StressMeter.tsx index 91b4a00..5ee464a 100644 --- a/components/StressMeter.tsx +++ b/components/StressMeter.tsx @@ -1,3 +1,4 @@ +import dayjs from 'dayjs'; import { LinearGradient } from 'expo-linear-gradient'; import React, { useState } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; @@ -11,42 +12,47 @@ interface StressMeterProps { } export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterProps) { + // 格式化更新时间显示 + const formatUpdateTime = (date: Date): string => { + const now = dayjs(); + const updateTime = dayjs(date); + const diffMinutes = now.diff(updateTime, 'minute'); + const diffHours = now.diff(updateTime, 'hour'); + const diffDays = now.diff(updateTime, 'day'); + + if (diffMinutes < 1) { + return '刚刚更新'; + } else if (diffMinutes < 60) { + return `${diffMinutes}分钟前更新`; + } else if (diffHours < 24) { + return `${diffHours}小时前更新`; + } else if (diffDays < 7) { + return `${diffDays}天前更新`; + } else { + return updateTime.format('MM-DD HH:mm'); + } + }; + // 将HRV值转换为压力指数(0-100) // HRV值范围:30-110ms,映射到压力指数100-0 // HRV值越高,压力越小;HRV值越低,压力越大 - // 根据压力指数计算状态 - const getStressStatus = () => { - if (value === null) { - return '未知'; - } else if (value >= 70) { - return '放松'; - } else if (value >= 30) { - return '正常'; - } else { - return '紧张'; - } + const convertHrvToStressIndex = (hrv: number | null): number | null => { + if (hrv === null || hrv === 0) return null; + + // HRV 范围: 30-110ms,对应压力指数: 100-0 + // 线性映射: stressIndex = 100 - ((hrv - 30) / (110 - 30)) * 100 + const normalizedHrv = Math.max(30, Math.min(110, hrv)); + const stressIndex = 100 - ((normalizedHrv - 30) / (110 - 30)) * 100; + + return Math.round(stressIndex); }; - // 根据状态获取表情 - const getStatusEmoji = () => { - // 当HRV值为null或0时,不展示表情 - if (value === null || value === 0) { - return ''; - } - - const status = getStressStatus(); - switch (status) { - case '未知': return ''; - case '放松': return '😌'; - case '正常': return '😊'; - case '紧张': return '😰'; - default: return '😊'; - } - }; + // 使用传入的 hrvValue 进行转换 + const stressIndex = convertHrvToStressIndex(hrvValue); // 计算进度条位置(0-100%) // 压力指数越高,进度条越满(红色区域越多) - const progressPercentage = value !== null ? Math.max(0, Math.min(100, value)) : 0; + const progressPercentage = stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0; // 在组件内部添加状态 const [showStressModal, setShowStressModal] = useState(false); @@ -68,12 +74,15 @@ export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterP 压力 + {updateTime && ( + {formatUpdateTime(updateTime)} + )} {/* 数值显示区域 */} - {value === null ? '--' : value} - 指数 + {stressIndex === null ? '--' : stressIndex} + ms {/* 进度条区域 */} @@ -89,14 +98,10 @@ export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterP /> {/* 白色圆形指示器 */} - + - {/* 更新时间 - {updateTime && ( - {formatUpdateTime(updateTime)} - )} */} {/* 压力分析浮窗 */} @@ -207,4 +212,9 @@ const styles = StyleSheet.create({ textAlign: 'right', marginTop: 2, }, + headerUpdateTime: { + fontSize: 11, + color: '#9AA3AE', + fontWeight: '500', + }, }); diff --git a/utils/health.ts b/utils/health.ts index 55f9833..486807d 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -465,7 +465,8 @@ async function fetchHeartRateVariability(options: HealthDataOptions): Promise