From 55d133c47062d2026937481b9a0e6caf2dd86773 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sun, 14 Sep 2025 21:41:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20UI=20=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E4=BB=A5=E5=8F=8A=E6=B6=88=E6=81=AF=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/_layout.tsx | 44 +-- app/voice-record.tsx | 408 +++++++++++++--------- assets/images/icons/icon-broadcast.png | Bin 0 -> 6677 bytes assets/images/icons/icon-camera.png | Bin 0 -> 14957 bytes assets/images/icons/icon-food.png | Bin 0 -> 9494 bytes components/NotificationTest.tsx | 4 + components/NutritionRadarCard.tsx | 144 +++++--- components/weight/WeightHistoryCard.tsx | 428 ++++-------------------- package.json | 16 +- services/backgroundTaskManager.ts | 7 - services/notifications.ts | 13 +- utils/notificationHelpers.ts | 347 ++++++++++++++++++- 12 files changed, 801 insertions(+), 610 deletions(-) create mode 100644 assets/images/icons/icon-broadcast.png create mode 100644 assets/images/icons/icon-camera.png create mode 100644 assets/images/icons/icon-food.png 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 0000000000000000000000000000000000000000..f846efcfa3b16c79f315fabce2b1b5635e34a5d3 GIT binary patch literal 6677 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 literal 0 HcmV?d00001 diff --git a/assets/images/icons/icon-food.png b/assets/images/icons/icon-food.png new file mode 100644 index 0000000000000000000000000000000000000000..9e722e98f870e18daed771ba6611f085ab926a55 GIT binary patch literal 9494 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 K`q+pX^M3#cFUh_D literal 0 HcmV?d00001 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, + }), + }, };