diff --git a/app/_layout.tsx b/app/_layout.tsx index ae17fad..e39ad9b 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -16,7 +16,7 @@ import { WaterRecordSource } from '@/services/waterRecords'; import { store } from '@/store'; import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice'; import { createWaterRecordAction } from '@/store/waterSlice'; -import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers'; +import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers'; import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync'; import React from 'react'; @@ -54,6 +54,21 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { await notificationService.initialize(); console.log('通知服务初始化成功'); + // 注册午餐提醒(12:00) + await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || ''); + console.log('午餐提醒已注册'); + + // 注册晚餐提醒(18:00) + await NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || ''); + console.log('晚餐提醒已注册'); + + // 注册心情提醒(21:00) + await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || ''); + console.log('心情提醒已注册'); + + await DailySummaryNotificationHelpers.scheduleDailySummaryNotification(profile.name || '') + + // 初始化快捷动作 await setupQuickActions(); console.log('快捷动作初始化成功'); @@ -104,33 +119,6 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { } }, [userDataLoaded, privacyAgreed]); - // 当用户数据加载完成且用户名存在时,注册所有提醒 - React.useEffect(() => { - const registerAllReminders = async () => { - try { - await notificationService.initialize(); - // 注册午餐提醒(12:00) - await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || ''); - console.log('午餐提醒已注册'); - - // 注册晚餐提醒(18:00) - await NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || ''); - console.log('晚餐提醒已注册'); - - // 注册心情提醒(21:00) - await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || ''); - console.log('心情提醒已注册'); - - - console.log('喝水提醒后台任务已注册'); - } catch (error) { - console.error('注册提醒失败:', error); - } - }; - - registerAllReminders(); - }, [userDataLoaded, profile?.name]); - const handlePrivacyAgree = () => { dispatch(setPrivacyAgreed()); setShowPrivacyModal(false); diff --git a/app/voice-record.tsx b/app/voice-record.tsx index 9821d50..d07bdcd 100644 --- a/app/voice-record.tsx +++ b/app/voice-record.tsx @@ -13,15 +13,12 @@ import React, { useEffect, useRef, useState } from 'react'; import { Alert, Animated, - Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -const { width } = Dimensions.get('window'); - type VoiceRecordState = 'idle' | 'listening' | 'processing' | 'result' | 'analyzing'; export default function VoiceRecordScreen() { @@ -43,21 +40,6 @@ export default function VoiceRecordScreen() { const glowAnimation = useRef(new Animated.Value(0)).current; const progressAnimation = useRef(new Animated.Value(0)).current; - useEffect(() => { - // 初始化语音识别 - Voice.onSpeechStart = onSpeechStart; - Voice.onSpeechRecognized = onSpeechRecognized; - Voice.onSpeechEnd = onSpeechEnd; - Voice.onSpeechError = onSpeechError; - Voice.onSpeechResults = onSpeechResults; - Voice.onSpeechPartialResults = onSpeechPartialResults; - Voice.onSpeechVolumeChanged = onSpeechVolumeChanged; - - return () => { - Voice.destroy().then(Voice.removeAllListeners); - }; - }, []); - // 启动脉动动画 const startPulseAnimation = () => { Animated.loop( @@ -149,7 +131,6 @@ export default function VoiceRecordScreen() { setIsListening(false); setRecordState('idle'); stopAnimations(); - Alert.alert('录音失败', '请检查麦克风权限或稍后重试'); }; const onSpeechResults = (event: any) => { @@ -171,16 +152,33 @@ export default function VoiceRecordScreen() { scaleAnimation.setValue(Math.min(scale, 1.5)); }; + + useEffect(() => { + // 初始化语音识别 + Voice.onSpeechStart = onSpeechStart; + Voice.onSpeechRecognized = onSpeechRecognized; + Voice.onSpeechEnd = onSpeechEnd; + Voice.onSpeechError = onSpeechError; + Voice.onSpeechResults = onSpeechResults; + Voice.onSpeechPartialResults = onSpeechPartialResults; + Voice.onSpeechVolumeChanged = onSpeechVolumeChanged; + + return () => { + Voice.destroy().then(Voice.removeAllListeners); + }; + }, [onSpeechStart, onSpeechRecognized, onSpeechEnd, onSpeechError, onSpeechResults, onSpeechPartialResults, onSpeechVolumeChanged]); + + // 开始录音 const startRecording = async () => { try { setRecognizedText(''); - setRecordState('idle'); triggerHapticFeedback('impactMedium'); await Voice.start('zh-CN'); // 设置为中文识别 } catch (error) { console.log('启动语音识别失败:', error); + setRecordState('idle'); Alert.alert('录音失败', '无法启动语音识别,请检查权限设置'); } }; @@ -196,10 +194,27 @@ export default function VoiceRecordScreen() { }; // 重新录音 - const retryRecording = () => { + const retryRecording = async () => { + // 停止所有动画 + stopAnimations(); + + // 重置所有状态 setRecognizedText(''); + setAnalysisProgress(0); + setIsListening(false); setRecordState('idle'); - startRecording(); + + // 确保语音识别已停止 + try { + await Voice.stop(); + } catch { + // 忽略停止错误,可能已经停止了 + } + + // 延迟一点再开始新的录音,确保状态完全重置 + setTimeout(() => { + startRecording(); + }, 100); }; // 确认并分析食物文本 @@ -213,7 +228,7 @@ export default function VoiceRecordScreen() { triggerHapticFeedback('impactMedium'); setRecordState('analyzing'); setAnalysisProgress(0); - + // 启动科幻分析动画 startAnalysisAnimation(); @@ -242,7 +257,7 @@ export default function VoiceRecordScreen() { // 停止动画并导航到结果页面 stopAnimations(); - + // 延迟一点让用户看到100%完成 setTimeout(() => { router.replace({ @@ -259,7 +274,7 @@ export default function VoiceRecordScreen() { console.error('食物分析失败:', error); stopAnimations(); setRecordState('result'); - + const errorMessage = error instanceof Error ? error.message : '分析失败,请重试'; dispatch(setError(errorMessage)); Alert.alert('分析失败', errorMessage); @@ -277,15 +292,15 @@ export default function VoiceRecordScreen() { const getStatusText = () => { switch (recordState) { case 'idle': - return '点击开始录音'; + return '轻触麦克风开始录音'; case 'listening': - return '正在聆听...'; + return '正在聆听中,请开始说话...'; case 'processing': - return 'AI处理中...'; + return 'AI正在处理语音内容...'; case 'analyzing': - return 'AI大模型分析中...'; + return 'AI大模型深度分析营养成分中...'; case 'result': - return '识别完成'; + return '语音识别完成,请确认结果'; default: return ''; } @@ -344,145 +359,180 @@ export default function VoiceRecordScreen() { /> - {/* 录音动画区域 */} - - {/* 背景波浪效果 */} - {recordState === 'listening' && ( - <> - {[1, 2, 3].map((index) => ( + {/* 上半部分:介绍 */} + + + + 通过语音描述您的饮食内容,AI将智能分析营养成分和卡路里 + + + + + {/* 中间部分:录音动画区域 */} + + + {/* 背景波浪效果 */} + {recordState === 'listening' && ( + <> + {[1, 2, 3].map((index) => ( + + ))} + + )} + + {/* 科幻分析特效 */} + {recordState === 'analyzing' && ( + <> + {/* 外光环 */} - ))} - - )} - - {/* 科幻分析特效 */} - {recordState === 'analyzing' && ( - <> - {/* 外光环 */} - - {/* 内光环 */} - - - )} - - {/* 主录音按钮 */} - - - - - - - - {/* 状态文本 */} - - - {getStatusText()} - - - {recordState === 'listening' && ( - - 说出您想记录的食物内容 - - )} - - {recordState === 'analyzing' && ( - - - 分析进度: {Math.round(analysisProgress)}% - - + {/* 内光环 */} - - - AI正在深度分析您的食物描述... + + )} + + {/* 主录音按钮 */} + + + + + + + + + {/* 下半部分:状态文本和示例 */} + + + + {getStatusText()} + + + {recordState === 'listening' && ( + + 说出您想记录的食物内容 - - )} + )} + + {/* 食物记录示例 */} + {recordState === 'idle' && ( + + + + 记录示例: + + + + “今早吃了两个煎蛋、一片全麦面包和一杯牛奶” + + + “午饭吃了红烧肉约150克、米饭一小碗、青菜一份” + + + “晚饭吃了蒸蛋羹、紫菜蛋花汤、小米粥一碗” + + + + + )} + + {recordState === 'analyzing' && ( + + + 分析进度: {Math.round(analysisProgress)}% + + + + + + AI正在深度分析您的食物描述... + + + )} + {/* 识别结果 */} @@ -528,17 +578,46 @@ const styles = StyleSheet.create({ flex: 1, }, content: { + flex: 1, + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: 40, + }, + topSection: { + alignItems: 'center', + paddingBottom: 20, + }, + middleSection: { flex: 1, alignItems: 'center', justifyContent: 'center', + minHeight: 200, + }, + bottomSection: { + alignItems: 'center', + }, + introContainer: { + alignItems: 'center', paddingHorizontal: 20, }, + introTitle: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 8, + textAlign: 'center', + }, + introDescription: { + fontSize: 16, + lineHeight: 24, + textAlign: 'center', + paddingHorizontal: 10, + }, animationContainer: { alignItems: 'center', justifyContent: 'center', - marginBottom: 40, height: 200, width: 200, + flex: 1, }, waveRing: { position: 'absolute', @@ -573,7 +652,7 @@ const styles = StyleSheet.create({ }, statusContainer: { alignItems: 'center', - marginBottom: 30, + paddingHorizontal: 20, }, statusText: { fontSize: 18, @@ -585,6 +664,35 @@ const styles = StyleSheet.create({ textAlign: 'center', lineHeight: 20, }, + examplesContainer: { + marginTop: 24, + marginHorizontal: 20, + borderRadius: 16, + overflow: 'hidden', + }, + examplesContent: { + padding: 20, + backgroundColor: 'rgba(255, 255, 255, 0.9)', + borderRadius: 16, + alignItems: 'center', + }, + examplesTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 16, + }, + examplesList: { + paddingHorizontal: 10, + gap: 8, + }, + exampleText: { + fontSize: 14, + lineHeight: 22, + textAlign: 'center', + paddingHorizontal: 12, + paddingVertical: 8, + marginVertical: 4, + }, resultContainer: { position: 'absolute', bottom: 100, diff --git a/assets/images/icons/icon-broadcast.png b/assets/images/icons/icon-broadcast.png new file mode 100644 index 0000000..f846efc Binary files /dev/null and b/assets/images/icons/icon-broadcast.png differ diff --git a/assets/images/icons/icon-camera.png b/assets/images/icons/icon-camera.png new file mode 100644 index 0000000..ed17f9c Binary files /dev/null and b/assets/images/icons/icon-camera.png differ diff --git a/assets/images/icons/icon-food.png b/assets/images/icons/icon-food.png new file mode 100644 index 0000000..9e722e9 Binary files /dev/null and b/assets/images/icons/icon-food.png differ diff --git a/components/NotificationTest.tsx b/components/NotificationTest.tsx index b02bc9b..efd888c 100644 --- a/components/NotificationTest.tsx +++ b/components/NotificationTest.tsx @@ -10,6 +10,7 @@ import { import { useNotifications } from '../hooks/useNotifications'; import { ThemedText } from './ThemedText'; import { ThemedView } from './ThemedView'; +import { DailySummaryTest } from './DailySummaryTest'; export const NotificationTest: React.FC = () => { const { @@ -246,6 +247,9 @@ export const NotificationTest: React.FC = () => { ))} )} + + {/* 每日总结推送测试 */} + ); diff --git a/components/NutritionRadarCard.tsx b/components/NutritionRadarCard.tsx index 660aafc..d69e6f8 100644 --- a/components/NutritionRadarCard.tsx +++ b/components/NutritionRadarCard.tsx @@ -1,5 +1,4 @@ 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'; @@ -7,7 +6,7 @@ import { NutritionGoals, calculateRemainingCalories } from '@/utils/nutrition'; import dayjs from 'dayjs'; import { router } from 'expo-router'; import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Animated, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import Svg, { Circle } from 'react-native-svg'; const AnimatedCircle = Animated.createAnimatedComponent(Circle); @@ -107,7 +106,6 @@ export function NutritionRadarCard({ onMealPress }: NutritionRadarCardProps) { const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast'); - const [showFoodOverlay, setShowFoodOverlay] = useState(false); const nutritionStats = useMemo(() => { return [ @@ -138,25 +136,12 @@ export function NutritionRadarCard({ router.push(ROUTES.NUTRITION_RECORDS); }; - const handleAddFood = () => { - triggerLightHaptic(); - setShowFoodOverlay(true); - }; return ( 饮食分析 - - 更新: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')} - - {/* */} - 添加+ - - + 更新: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')} @@ -228,12 +213,59 @@ export function NutritionRadarCard({ - {/* 食物添加悬浮窗 */} - setShowFoodOverlay(false)} - mealType={currentMealType} - /> + {/* 添加食物选项 */} + + { + triggerLightHaptic(); + router.push(`/food/camera?mealType=${currentMealType}`); + }} + activeOpacity={0.7} + > + + + + AI识别 + + + { + triggerLightHaptic(); + router.push(`${ROUTES.FOOD_LIBRARY}?mealType=${currentMealType}`); + }} + activeOpacity={0.7} + > + + + + 食物库 + + + { + triggerLightHaptic(); + router.push(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`); + }} + activeOpacity={0.7} + > + + + + 一句话记录 + + ); @@ -264,11 +296,6 @@ const styles = StyleSheet.create({ fontSize: 14, color: '#192126', }, - cardRightContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - }, cardSubtitle: { fontSize: 10, color: '#9AA3AE', @@ -416,26 +443,53 @@ const styles = StyleSheet.create({ mealEmoji: { fontSize: 24, }, - addButton: { - width: 52, - height: 26, - borderRadius: 16, - backgroundColor: '#7b7be2ff', - marginLeft: 8, - alignItems: 'center', - justifyContent: 'center', - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, mealName: { fontSize: 10, color: '#64748B', fontWeight: '600', }, + // 食物选项样式 + foodOptionsContainer: { + flexDirection: 'row', + justifyContent: 'space-around', + marginTop: 12, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: '#F1F5F9', + gap: 16, + }, + foodOptionItem: { + alignItems: 'center', + flex: 1, + }, + foodOptionIcon: { + width: 24, + height: 24, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 6, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.15, + shadowRadius: 4, + elevation: 4, + }, + foodOptionEmoji: { + fontSize: 14, + }, + foodOptionImage: { + width: 20, + height: 20, + resizeMode: 'contain', + }, + foodOptionText: { + fontSize: 10, + fontWeight: '500', + color: '#192126', + textAlign: 'center', + }, }); diff --git a/components/weight/WeightHistoryCard.tsx b/components/weight/WeightHistoryCard.tsx index 726ac5f..13669d1 100644 --- a/components/weight/WeightHistoryCard.tsx +++ b/components/weight/WeightHistoryCard.tsx @@ -4,9 +4,8 @@ import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; import { fetchWeightHistory } from '@/store/userSlice'; -import { BMI_CATEGORIES, canCalculateBMI, getBMIResult } from '@/utils/bmi'; +import { BMI_CATEGORIES } from '@/utils/bmi'; import { Ionicons } from '@expo/vector-icons'; -import dayjs from 'dayjs'; import { LinearGradient } from 'expo-linear-gradient'; import React, { useEffect, useState } from 'react'; import { @@ -18,20 +17,12 @@ import { TouchableOpacity, View } from 'react-native'; -import Animated, { - Extrapolation, - interpolate, - runOnJS, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; -import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg'; +import Svg, { Circle, Path } from 'react-native-svg'; const { width: screenWidth } = Dimensions.get('window'); const CARD_WIDTH = screenWidth - 40; // 减去左右边距 const CHART_WIDTH = CARD_WIDTH - 36; // 减去卡片内边距 -const CHART_HEIGHT = 100; +const CHART_HEIGHT = 60; const PADDING = 10; @@ -40,12 +31,8 @@ export function WeightHistoryCard() { const userProfile = useAppSelector((s) => s.user.profile); const weightHistory = useAppSelector((s) => s.user.weightHistory); - const [showChart, setShowChart] = useState(false); const [showBMIModal, setShowBMIModal] = useState(false); - // 动画相关状态 - const animationProgress = useSharedValue(0); - const [isAnimating, setIsAnimating] = useState(false); const { pushIfAuthedElseLogin } = useAuthGuard(); const colorScheme = useColorScheme(); @@ -53,15 +40,6 @@ export function WeightHistoryCard() { const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0; - // BMI 计算 - const canCalculate = canCalculateBMI( - userProfile?.weight ? parseFloat(userProfile.weight) : undefined, - userProfile?.height ? parseFloat(userProfile.height) : undefined - ); - - const bmiResult = canCalculate && userProfile?.weight && userProfile?.height - ? getBMIResult(parseFloat(userProfile.weight), parseFloat(userProfile.height)) - : null; useEffect(() => { if (hasWeight) { @@ -81,115 +59,14 @@ export function WeightHistoryCard() { pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS); }; - const handleShowBMIModal = () => { - setShowBMIModal(true); - }; - const handleHideBMIModal = () => { setShowBMIModal(false); }; - // 切换图表显示状态的动画函数 const navigateToWeightRecords = () => { pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS); }; - const toggleChart = () => { - if (isAnimating) return; // 防止动画期间重复触发 - - setIsAnimating(true); - const newShowChart = !showChart; - setShowChart(newShowChart); - - animationProgress.value = withTiming( - newShowChart ? 1 : 0, - { - duration: 350, - }, - (finished) => { - if (finished) { - runOnJS(setIsAnimating)(false); - } - } - ); - }; - - // 动画容器的高度动画 - const containerAnimatedStyle = useAnimatedStyle(() => { - // 只有在展开状态时才应用固定高度 - if (animationProgress.value === 0) { - return {}; - } - - const height = interpolate( - animationProgress.value, - [0, 1], - [80, 200], // 从摘要高度到图表高度,适应毛玻璃背景 - Extrapolation.CLAMP - ); - - return { - height, - }; - }); - - // 摘要信息的动画样式 - const summaryAnimatedStyle = useAnimatedStyle(() => { - const opacity = interpolate( - animationProgress.value, - [0, 0.4, 1], - [1, 0.2, 0], - Extrapolation.CLAMP - ); - - const scale = interpolate( - animationProgress.value, - [0, 1], - [1, 0.9], - Extrapolation.CLAMP - ); - - const translateY = interpolate( - animationProgress.value, - [0, 1], - [0, -20], - Extrapolation.CLAMP - ); - - return { - opacity, - transform: [{ scale }, { translateY }], - }; - }); - - // 图表容器的动画样式 - const chartAnimatedStyle = useAnimatedStyle(() => { - const opacity = interpolate( - animationProgress.value, - [0, 0.6, 1], - [0, 0.2, 1], - Extrapolation.CLAMP - ); - - const scale = interpolate( - animationProgress.value, - [0, 1], - [0.9, 1], - Extrapolation.CLAMP - ); - - const translateY = interpolate( - animationProgress.value, - [0, 1], - [20, 0], - Extrapolation.CLAMP - ); - - return { - opacity, - transform: [{ scale }, { translateY }], - }; - }); // 如果没有体重数据,显示引导卡片 @@ -263,7 +140,7 @@ export function WeightHistoryCard() { const x = PADDING + (index / Math.max(sortedHistory.length - 1, 1)) * (CHART_WIDTH - 2 * PADDING); const normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange; // 减少顶部边距,压缩留白 - const y = PADDING + 15 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 30); + const y = PADDING + 8 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 16); return { x, y, weight: item.weight, date: item.createdAt }; }); @@ -282,166 +159,70 @@ export function WeightHistoryCard() { 体重记录 - - { - e.stopPropagation(); - toggleChart(); - }} - activeOpacity={0.8} - > - - - + { + e.stopPropagation(); + navigateToCoach(); + }} + activeOpacity={0.8} + > + + - {/* 动画容器 */} + {/* 默认显示图表 */} {sortedHistory.length > 0 && ( - - {/* 默认信息显示 - 带动画 */} - - - - - 当前体重 - {userProfile.weight}kg - - - 记录天数 - {sortedHistory.length}天 - - - 变化范围 - - {minWeight.toFixed(1)}-{maxWeight.toFixed(1)} - - - {bmiResult && ( - - BMI - - - {bmiResult.value} - - { - e.stopPropagation(); - handleShowBMIModal(); - }} - style={styles.bmiInfoButton} - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - > - - - - - )} - + + + {/* 背景网格线 */} + + {/* 更抽象的折线 - 减小线宽和显示的细节 */} + + + {/* 简化的数据点 - 更小更精致 */} + {points.map((point, index) => { + const isLastPoint = index === points.length - 1; + + return ( + + + + ); + })} + + + + + {/* 精简的图表信息 */} + + + {userProfile.weight}kg - - - {/* 图表容器 - 带动画 */} - - - {/* 背景网格线 */} - {[0, 1, 2, 3, 4].map(i => ( - - ))} - - {/* 折线 */} - - - {/* 数据点和标签 */} - {points.map((point, index) => { - const isLastPoint = index === points.length - 1; - const isFirstPoint = index === 0; - const showLabel = isFirstPoint || isLastPoint || points.length <= 3 || points.length === 1; - - return ( - - - {/* 体重标签 - 只在关键点显示 */} - {showLabel && ( - <> - - - {point.weight} - - - )} - - ); - })} - - - - - {/* 图表底部信息 */} - - - 当前体重 - {userProfile.weight}kg - - - 记录天数 - {sortedHistory.length}天 - - - 变化范围 - - {minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg - - + + {sortedHistory.length}天 - - {/* 最近记录时间 */} - {sortedHistory.length > 0 && ( - - 最近记录:{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')} + + + {minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg - )} - - + + + )} {/* BMI 信息弹窗 */} @@ -627,37 +408,10 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '700', }, - animationContainer: { - position: 'relative', - overflow: 'hidden', - minHeight: 80, // 增加最小高度以容纳毛玻璃背景 - }, - summaryInfo: { - position: 'absolute', - width: '100%', - marginTop: 8, - }, - summaryBackground: { - backgroundColor: 'rgba(248, 250, 252, 0.8)', // 毛玻璃背景色 - borderRadius: 12, - padding: 12, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 1, - }, - shadowOpacity: 0.06, - shadowRadius: 3, - elevation: 1, - // 添加边框增强毛玻璃效果 - borderWidth: 0.5, - borderColor: 'rgba(255, 255, 255, 0.8)', - }, chartContainer: { - position: 'absolute', width: '100%', alignItems: 'center', - minHeight: 100, + marginTop: 12, }, chartInfo: { flexDirection: 'row', @@ -668,67 +422,15 @@ const styles = StyleSheet.create({ alignItems: 'center', }, infoLabel: { - fontSize: 12, + fontSize: 11, color: '#687076', - marginBottom: 4, + fontWeight: '500', }, infoValue: { fontSize: 14, fontWeight: '700', color: '#192126', }, - lastRecordText: { - fontSize: 12, - color: '#687076', - textAlign: 'center', - marginTop: 4, - }, - summaryRow: { - flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: 0, - flexWrap: 'wrap', - gap: 8, - }, - summaryItem: { - alignItems: 'center', - flex: 1, - minWidth: 0, - }, - summaryLabel: { - fontSize: 12, - color: '#687076', - marginBottom: 3, - }, - summaryValue: { - fontSize: 14, - marginTop: 2, - fontWeight: '600', - color: '#192126', - }, - - // BMI 相关样式 - bmiValueContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 1, - }, - bmiValue: { - fontSize: 12, - fontWeight: '700', - }, - bmiInfoButton: { - padding: 0, - }, - bmiStatusBadge: { - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 8, - }, - bmiStatusText: { - fontSize: 10, - fontWeight: '700', - }, // BMI 弹窗样式 bmiModalContainer: { diff --git a/package.json b/package.json index 608c38b..3e98360 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint": "expo lint" }, "dependencies": { - "@expo/ui": "~0.2.0-beta.1", + "@expo/ui": "~0.2.0-beta.2", "@expo/vector-icons": "^15.0.2", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/datetimepicker": "^8.4.4", @@ -27,22 +27,22 @@ "@types/lodash": "^4.17.20", "cos-js-sdk-v5": "^1.6.0", "dayjs": "^1.11.13", - "expo": "^54.0.1", + "expo": "^54.0.7", "expo-apple-authentication": "~8.0.6", - "expo-background-task": "~1.0.6", - "expo-blur": "~15.0.6", + "expo-background-task": "~1.0.7", + "expo-blur": "~15.0.7", "expo-camera": "~17.0.7", "expo-constants": "~18.0.8", "expo-font": "~14.0.8", - "expo-glass-effect": "^0.1.2", + "expo-glass-effect": "^0.1.3", "expo-haptics": "~15.0.6", - "expo-image": "~3.0.7", + "expo-image": "~3.0.8", "expo-image-picker": "~17.0.7", "expo-linear-gradient": "~15.0.6", - "expo-linking": "~8.0.7", + "expo-linking": "~8.0.8", "expo-notifications": "~0.32.11", "expo-quick-actions": "^5.0.0", - "expo-router": "~6.0.1", + "expo-router": "~6.0.4", "expo-splash-screen": "~31.0.8", "expo-status-bar": "~3.0.7", "expo-symbols": "~1.0.6", diff --git a/services/backgroundTaskManager.ts b/services/backgroundTaskManager.ts index 44cb2e2..0220e81 100644 --- a/services/backgroundTaskManager.ts +++ b/services/backgroundTaskManager.ts @@ -80,13 +80,6 @@ async function executeStandReminderTask(): Promise { const state = store.getState(); const userProfile = state.user.profile; - // 检查时间限制(工作时间内提醒,避免深夜或清晨打扰) - const currentHour = new Date().getHours(); - if (currentHour < 9 || currentHour >= 21) { - console.log(`当前时间${currentHour}点,不在站立提醒时间范围内,跳过站立提醒`); - return; - } - // 获取用户名 const userName = userProfile?.name || '朋友'; diff --git a/services/notifications.ts b/services/notifications.ts index ebfbe61..e58d740 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -58,6 +58,7 @@ type CalendarTrigger = DailyTrigger | WeeklyTrigger | MonthlyTrigger; export class NotificationService { private static instance: NotificationService; private isInitialized = false; + private isIniting = false private constructor() { } @@ -72,9 +73,10 @@ export class NotificationService { * 初始化推送通知服务 */ async initialize(): Promise { - if (this.isInitialized) return; + if (this.isInitialized || this.isIniting) return; try { + this.isIniting = true // 请求通知权限 const { status: existingStatus } = await Notifications.getPermissionsAsync(); let finalStatus = existingStatus; @@ -89,13 +91,6 @@ export class NotificationService { return; } - // 获取推送令牌(用于远程推送,本地推送不需要) - // if (Platform.OS !== 'web') { - // const token = await Notifications.getExpoPushTokenAsync({ - // projectId: 'your-project-id', // 需要替换为实际的Expo项目ID - // }); - // console.log('推送令牌:', token.data); - // } // 设置通知监听器 this.setupNotificationListeners(); @@ -107,6 +102,8 @@ export class NotificationService { console.log('推送通知服务初始化成功'); } catch (error) { console.error('推送通知服务初始化失败:', error); + } finally { + this.isIniting = false } } diff --git a/utils/notificationHelpers.ts b/utils/notificationHelpers.ts index c830dd6..38ad824 100644 --- a/utils/notificationHelpers.ts +++ b/utils/notificationHelpers.ts @@ -945,7 +945,7 @@ export class StandReminderHelpers { // 检查时间范围(工作时间内提醒,避免深夜或清晨打扰) const currentHour = new Date().getHours(); - if (currentHour < 9 || currentHour >= 21) { + if (currentHour < 9 || currentHour >= 22) { console.log(`当前时间${currentHour}点,不在站立提醒时间范围内`); return false; } @@ -1014,6 +1014,305 @@ export class StandReminderHelpers { } } +/** + * 每日总结通知助手 + */ +export class DailySummaryNotificationHelpers { + /** + * 获取当日数据汇总 + */ + static async getDailySummaryData(date: string = new Date().toISOString().split('T')[0]) { + try { + console.log('获取每日汇总数据:', date); + + // 动态导入相关服务,避免循环依赖 + const { getDietRecords } = await import('@/services/dietRecords'); + const { getDailyMoodCheckins } = await import('@/services/moodCheckins'); + const { getWaterRecords } = await import('@/services/waterRecords'); + const { workoutsApi } = await import('@/services/workoutsApi'); + + // 设置日期范围 + const startDate = new Date(`${date}T00:00:00.000Z`).toISOString(); + const endDate = new Date(`${date}T23:59:59.999Z`).toISOString(); + + // 并行获取各项数据 + const [dietData, moodData, waterData, workoutData] = await Promise.allSettled([ + getDietRecords({ startDate, endDate, limit: 100 }), + getDailyMoodCheckins(date), + getWaterRecords({ date, limit: 100 }), + workoutsApi.getTodayWorkout() + ]); + + // 处理饮食数据 + const dietSummary = { + hasRecords: false, + mealCount: 0, + recordCount: 0 + }; + if (dietData.status === 'fulfilled' && dietData.value.records.length > 0) { + dietSummary.hasRecords = true; + dietSummary.recordCount = dietData.value.records.length; + dietSummary.mealCount = new Set(dietData.value.records.map(r => r.mealType)).size; + } + + // 处理心情数据 + const moodSummary = { + hasRecords: false, + recordCount: 0, + latestMood: null as string | null + }; + if (moodData.status === 'fulfilled' && moodData.value.length > 0) { + moodSummary.hasRecords = true; + moodSummary.recordCount = moodData.value.length; + moodSummary.latestMood = moodData.value[0].moodType; + } + + // 处理饮水数据 + const waterSummary = { + hasRecords: false, + recordCount: 0, + totalAmount: 0, + completionRate: 0 + }; + if (waterData.status === 'fulfilled' && waterData.value.records.length > 0) { + waterSummary.hasRecords = true; + waterSummary.recordCount = waterData.value.records.length; + waterSummary.totalAmount = waterData.value.records.reduce((sum, r) => sum + r.amount, 0); + // 假设默认目标是2000ml,实际应该从用户设置获取 + const dailyGoal = 2000; + waterSummary.completionRate = Math.round((waterSummary.totalAmount / dailyGoal) * 100); + } + + // 处理锻炼数据 + const workoutSummary = { + hasWorkout: false, + isCompleted: false, + exerciseCount: 0, + completedCount: 0, + duration: 0 + }; + if (workoutData.status === 'fulfilled' && workoutData.value) { + workoutSummary.hasWorkout = true; + workoutSummary.isCompleted = workoutData.value.status === 'completed'; + workoutSummary.exerciseCount = workoutData.value.exercises?.length || 0; + workoutSummary.completedCount = workoutData.value.exercises?.filter(e => e.status === 'completed').length || 0; + if (workoutData.value.completedAt && workoutData.value.startedAt) { + workoutSummary.duration = Math.round((new Date(workoutData.value.completedAt).getTime() - new Date(workoutData.value.startedAt).getTime()) / (1000 * 60)); + } + } + + return { + date, + diet: dietSummary, + mood: moodSummary, + water: waterSummary, + workout: workoutSummary + }; + + } catch (error) { + console.error('获取每日汇总数据失败:', error); + throw error; + } + } + + /** + * 生成每日总结推送消息 + */ + static generateDailySummaryMessage(userName: string, summaryData: any): { title: string; body: string } { + const { diet, mood, water, workout } = summaryData; + + // 计算完成的项目数量 + const completedItems = []; + const encouragementItems = []; + + // 饮食记录检查 + if (diet.hasRecords) { + completedItems.push(`记录了${diet.mealCount}餐饮食`); + } else { + encouragementItems.push('饮食记录'); + } + + // 心情记录检查 + if (mood.hasRecords) { + completedItems.push(`记录了心情状态`); + } else { + encouragementItems.push('心情记录'); + } + + // 饮水记录检查 + if (water.hasRecords) { + if (water.completionRate >= 80) { + completedItems.push(`完成了${water.completionRate}%的饮水目标`); + } else { + completedItems.push(`喝水${water.completionRate}%`); + encouragementItems.push('多喝水'); + } + } else { + encouragementItems.push('饮水记录'); + } + + // 锻炼记录检查 + if (workout.hasWorkout) { + if (workout.isCompleted) { + completedItems.push(`完成了${workout.duration}分钟锻炼`); + } else { + completedItems.push(`开始了锻炼训练`); + encouragementItems.push('完成锻炼'); + } + } else { + encouragementItems.push('运动锻炼'); + } + + // 生成标题和内容 + let title = '今日健康总结'; + let body = ''; + + if (completedItems.length > 0) { + if (completedItems.length >= 3) { + // 完成度很高的鼓励 + const titles = ['今天表现棒极了!', '健康习惯养成中!', '今日收获满满!']; + title = titles[Math.floor(Math.random() * titles.length)]; + body = `${userName},今天您${completedItems.join('、')},真的很棒!`; + + if (encouragementItems.length > 0) { + body += `明天在${encouragementItems.join('、')}方面再加把劲哦~`; + } else { + body += '继续保持这样的好习惯!🌟'; + } + } else { + // 中等完成度的鼓励 + title = '今日健康小结'; + body = `${userName},今天您${completedItems.join('、')},已经很不错了!`; + + if (encouragementItems.length > 0) { + body += `明天记得关注一下${encouragementItems.join('、')},让健康生活更完整~`; + } + } + } else { + // 完成度较低的温柔提醒 + const titles = ['明天是新的开始', '健康从每一天开始', '小步前进也是进步']; + title = titles[Math.floor(Math.random() * titles.length)]; + body = `${userName},今天可能比较忙碌。明天记得关注${encouragementItems.slice(0, 2).join('和')},每一个小改变都是向健康生活迈进的一步~💪`; + } + + return { title, body }; + } + + /** + * 发送每日总结推送 + */ + static async sendDailySummaryNotification(userName: string, date?: string): Promise { + try { + console.log('开始发送每日总结推送...'); + + // 检查是否启用了通知 + if (!(await getNotificationEnabled())) { + console.log('用户未启用通知功能,跳过每日总结推送'); + return false; + } + + // 获取当日数据汇总 + const summaryData = await this.getDailySummaryData(date); + console.log('每日汇总数据:', summaryData); + + // 生成推送消息 + const { title, body } = this.generateDailySummaryMessage(userName, summaryData); + + // 发送通知 + await notificationService.sendImmediateNotification({ + title, + body, + data: { + type: 'daily_summary', + date: summaryData.date, + summaryData, + url: '/statistics' // 跳转到统计页面 + }, + sound: true, + priority: 'normal', + }); + + console.log('每日总结推送发送成功'); + return true; + + } catch (error) { + console.error('发送每日总结推送失败:', error); + return false; + } + } + + /** + * 安排每日总结推送(每天晚上9点) + */ + static async scheduleDailySummaryNotification( + userName: string, + hour: number = 21, + minute: number = 0 + ): Promise { + try { + // 检查是否已经存在每日总结提醒 + const existingNotifications = await notificationService.getAllScheduledNotifications(); + + const existingSummaryReminder = existingNotifications.find( + notification => + notification.content.data?.type === 'daily_summary_reminder' && + notification.content.data?.isDailyReminder === true + ); + + if (existingSummaryReminder) { + console.log('每日总结推送已存在,跳过重复注册:', existingSummaryReminder.identifier); + return existingSummaryReminder.identifier; + } + + // 创建每日总结推送通知 + const notificationId = await notificationService.scheduleCalendarRepeatingNotification( + { + title: '今日健康总结', + body: `${userName},来看看今天的健康生活总结吧~每一份记录都是成长的足迹!✨`, + data: { + type: 'daily_summary_reminder', + isDailyReminder: true, + url: '/statistics' + }, + sound: true, + priority: 'normal', + }, + { + type: 'DAILY' as any, + hour: hour, + minute: minute, + } + ); + + console.log('每日总结推送已安排,ID:', notificationId); + return notificationId; + } catch (error) { + console.error('安排每日总结推送失败:', error); + throw error; + } + } + + /** + * 取消每日总结推送 + */ + static async cancelDailySummaryNotification(): Promise { + try { + const notifications = await notificationService.getAllScheduledNotifications(); + + for (const notification of notifications) { + if (notification.content.data?.type === 'daily_summary_reminder' && + notification.content.data?.isDailyReminder === true) { + await notificationService.cancelNotification(notification.identifier); + console.log('已取消每日总结推送:', notification.identifier); + } + } + } catch (error) { + console.error('取消每日总结推送失败:', error); + throw error; + } + } +} + /** * 通知模板 */ @@ -1140,4 +1439,50 @@ export const NotificationTemplates = { priority: 'high' as const, }), }, + dailySummary: { + reminder: (userName: string) => ({ + title: '今日健康总结', + body: `${userName},来看看今天的健康生活总结吧~每一份记录都是成长的足迹!✨`, + data: { + type: 'daily_summary_reminder', + url: '/statistics' + }, + sound: true, + priority: 'normal' as const, + }), + highCompletion: (userName: string, completedItems: string[]) => ({ + title: '今天表现棒极了!', + body: `${userName},今天您${completedItems.join('、')},真的很棒!继续保持这样的好习惯!🌟`, + data: { + type: 'daily_summary', + completedItems, + url: '/statistics' + }, + sound: true, + priority: 'normal' as const, + }), + mediumCompletion: (userName: string, completedItems: string[], encouragementItems: string[]) => ({ + title: '今日健康小结', + body: `${userName},今天您${completedItems.join('、')},已经很不错了!明天记得关注一下${encouragementItems.join('、')},让健康生活更完整~`, + data: { + type: 'daily_summary', + completedItems, + encouragementItems, + url: '/statistics' + }, + sound: true, + priority: 'normal' as const, + }), + lowCompletion: (userName: string, encouragementItems: string[]) => ({ + title: '明天是新的开始', + body: `${userName},今天可能比较忙碌。明天记得关注${encouragementItems.slice(0, 2).join('和')},每一个小改变都是向健康生活迈进的一步~💪`, + data: { + type: 'daily_summary', + encouragementItems, + url: '/statistics' + }, + sound: true, + priority: 'normal' as const, + }), + }, };