From 05a643a9e6bb00f41399dcee73a08d48c27773cf Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 4 Sep 2025 15:12:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=A3=9F=E7=89=A9?= =?UTF-8?q?=E5=88=86=E6=9E=90=E7=BB=93=E6=9E=9C=E9=A1=B5=E9=9D=A2=E7=9A=84?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E9=A2=84=E8=A7=88=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AE=B0=E5=BD=95=E6=A0=8F=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/food/analysis-result.tsx | 289 +++++++++++++++++++++++------- app/food/camera.tsx | 48 +++-- app/nutrition/records.tsx | 70 +++++++- components/CalorieRingChart.tsx | 5 +- components/DateSelector.tsx | 175 +++++++++++++++++- components/NutritionRadarCard.tsx | 7 +- 6 files changed, 495 insertions(+), 99 deletions(-) diff --git a/app/food/analysis-result.tsx b/app/food/analysis-result.tsx index a5c6843..b856230 100644 --- a/app/food/analysis-result.tsx +++ b/app/food/analysis-result.tsx @@ -6,11 +6,13 @@ import { useAppSelector } from '@/hooks/redux'; import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/services/dietRecords'; import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice'; import { Ionicons } from '@expo/vector-icons'; +import dayjs from 'dayjs'; import { LinearGradient } from 'expo-linear-gradient'; import { useLocalSearchParams, useRouter } from 'expo-router'; import React, { useEffect, useState } from 'react'; import { ActivityIndicator, + BackHandler, Image, Modal, ScrollView, @@ -19,7 +21,7 @@ import { TouchableOpacity, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import ImageViewing from 'react-native-image-viewing'; // 模拟食物摄入列表数据 @@ -73,17 +75,36 @@ export default function FoodAnalysisResultScreen() { imageUri?: string; mealType?: string; recognitionId?: string; + hideRecordBar?: string; }>(); const [foodItems, setFoodItems] = useState(mockFoodItems); const [currentMealType, setCurrentMealType] = useState((params.mealType as MealType) || 'breakfast'); const [showMealSelector, setShowMealSelector] = useState(false); const [isRecording, setIsRecording] = useState(false); - const { imageUri, recognitionId } = params; + const [showImagePreview, setShowImagePreview] = useState(false); + const [animationTrigger, setAnimationTrigger] = useState(0); + const { imageUri, recognitionId, hideRecordBar } = params; + + // 判断是否隐藏记录栏(默认显示) + const shouldHideRecordBar = hideRecordBar === 'true'; // 从 Redux 获取识别结果 const recognitionResult = useAppSelector(recognitionId ? selectFoodRecognitionResult(recognitionId) : () => null); + // 处理Android返回键关闭图片预览 + useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + if (showImagePreview) { + setShowImagePreview(false); + return true; // 阻止默认返回行为 + } + return false; + }); + + return () => backHandler.remove(); + }, [showImagePreview]); + // 处理识别结果数据 useEffect(() => { if (recognitionResult) { @@ -116,9 +137,19 @@ export default function FoodAnalysisResultScreen() { setCurrentMealType(mealTypeFromApi); } } + + // 触发营养圆环动画 + setAnimationTrigger(prev => prev + 1); } }, [recognitionResult]); + // 当食物项发生变化时也触发动画 + useEffect(() => { + if (foodItems.length > 0) { + setAnimationTrigger(prev => prev + 1); + } + }, [foodItems]); + const handleSaveToDiary = async () => { if (isRecording || foodItems.length === 0) return; @@ -188,17 +219,17 @@ export default function FoodAnalysisResultScreen() { { key: 'snack' as const, label: '加餐', color: '#FF9800' }, ]; - if (!imageUri) { + if (!imageUri && !recognitionResult) { return ( - + router.back()} /> - 未找到图片 + 未找到图片或识别结果 - + ); } @@ -206,17 +237,12 @@ export default function FoodAnalysisResultScreen() { {/* 背景渐变 */} - {/* 装饰性圆圈 */} - - - - router.back()} @@ -226,18 +252,36 @@ export default function FoodAnalysisResultScreen() { {/* 食物主图 */} - + {imageUri ? ( + setShowImagePreview(true)} + activeOpacity={0.9} + > + + {/* 预览提示图标 */} + + + + + ) : ( + + + + 营养记录 + + + )} {/* 识别信息气泡 */} {recognitionResult ? `置信度: ${recognitionResult.confidence}%` : - '2025年9月4日' + dayjs().format('YYYY年M月D日') } @@ -259,6 +303,7 @@ export default function FoodAnalysisResultScreen() { unit="克" percentage={Math.min(100, proteinPercentage)} color="#4CAF50" + resetToken={animationTrigger} /> @@ -320,15 +367,15 @@ export default function FoodAnalysisResultScreen() { {item.calories}千卡 - + {shouldHideRecordBar ? null : - - } + {shouldHideRecordBar ? null : handleRemoveFood(item.id)} > - + } ))} @@ -337,48 +384,50 @@ export default function FoodAnalysisResultScreen() { {/* 底部餐次选择和记录按钮 */} - {recognitionResult && !recognitionResult.isFoodDetected ? ( - // 非食物检测情况显示重新拍照按钮 - - router.back()} - activeOpacity={0.8} - > - - 重新拍照 - - - ) : ( - // 正常食物识别情况 - - setShowMealSelector(true)} - > - option.key === currentMealType)?.color || '#FF6B35' } - ]} /> - {MEAL_TYPE_MAP[currentMealType as keyof typeof MEAL_TYPE_MAP]} - - + {!shouldHideRecordBar && ( + recognitionResult && !recognitionResult.isFoodDetected ? ( + // 非食物检测情况显示重新拍照按钮 + + router.back()} + activeOpacity={0.8} + > + + 重新拍照 + + + ) : ( + // 正常食物识别情况 + + setShowMealSelector(true)} + > + option.key === currentMealType)?.color || '#FF6B35' } + ]} /> + {MEAL_TYPE_MAP[currentMealType as keyof typeof MEAL_TYPE_MAP]} + + - - {isRecording ? ( - - ) : ( - 记录 - )} - - + + {isRecording ? ( + + ) : ( + 记录 + )} + + + ) )} {/* 餐次选择弹窗 */} @@ -426,6 +475,39 @@ export default function FoodAnalysisResultScreen() { + + {/* 图片预览 */} + { + console.log('ImageViewing onRequestClose called'); + setShowImagePreview(false); + }} + swipeToCloseEnabled={true} + doubleTapToZoomEnabled={true} + HeaderComponent={() => ( + + + {recognitionResult ? + `置信度: ${recognitionResult.confidence}%` : + dayjs().format('YYYY年M月D日 HH:mm') + } + + + )} + FooterComponent={() => ( + + setShowImagePreview(false)} + > + 关闭 + + + )} + /> ); } @@ -436,24 +518,28 @@ function NutritionRing({ value, unit, percentage, - color + color, + resetToken }: { label: string; value: string | number; unit?: string; percentage: number; color: string; + resetToken?: unknown; }) { return ( {percentage}% @@ -547,7 +633,8 @@ const styles = StyleSheet.create({ backgroundColor: Colors.light.background, borderTopLeftRadius: 24, borderTopRightRadius: 24, - marginTop: -60, + height: '100%', + marginTop: -24, paddingTop: 24, paddingHorizontal: 20, paddingBottom: 40, @@ -882,4 +969,74 @@ const styles = StyleSheet.create({ fontWeight: '600', letterSpacing: 0.5, }, + placeholderContainer: { + width: '100%', + height: '100%', + backgroundColor: '#F5F5F5', + alignItems: 'center', + justifyContent: 'center', + }, + placeholderContent: { + alignItems: 'center', + }, + placeholderText: { + fontSize: 16, + color: '#666', + fontWeight: '500', + marginTop: 8, + }, + // 预览提示图标样式 + previewHint: { + position: 'absolute', + top: 16, + right: 16, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + borderRadius: 20, + padding: 8, + }, + // ImageViewing 组件样式 + imageViewerHeader: { + position: 'absolute', + top: 60, + left: 20, + right: 20, + backgroundColor: 'rgba(0, 0, 0, 0.7)', + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 12, + zIndex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + imageViewerCloseButton: { + padding: 4, + }, + imageViewerHeaderText: { + color: '#FFF', + fontSize: 14, + fontWeight: '500', + flex: 1, + textAlign: 'center', + marginLeft: -28, // 补偿关闭按钮的宽度,保持文字居中 + }, + imageViewerFooter: { + position: 'absolute', + bottom: 60, + left: 20, + right: 20, + alignItems: 'center', + zIndex: 1, + }, + imageViewerFooterButton: { + backgroundColor: 'rgba(0, 0, 0, 0.7)', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 20, + }, + imageViewerFooterButtonText: { + color: '#FFF', + fontSize: 16, + fontWeight: '500', + }, }); \ No newline at end of file diff --git a/app/food/camera.tsx b/app/food/camera.tsx index 978d6f7..b0fe2dc 100644 --- a/app/food/camera.tsx +++ b/app/food/camera.tsx @@ -159,21 +159,23 @@ export default function FoodCameraScreen() { 确保食物在取景框内 - {/* 相机取景框 */} - - - {/* 取景框装饰 */} - - - - - - - + {/* 相机取景框包装器 */} + + {/* 相机取景框 */} + + + + {/* 取景框装饰 - 放在外层避免被截断 */} + + + + + + @@ -294,21 +296,29 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingHorizontal: 20, }, + cameraWrapper: { + width: 300, + height: 300, + position: 'relative', + }, cameraFrame: { width: 300, height: 300, borderRadius: 20, overflow: 'hidden', - borderWidth: 3, - borderColor: '#FFF', backgroundColor: '#000', }, cameraView: { flex: 1, }, viewfinderOverlay: { - flex: 1, - position: 'relative', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 1, + pointerEvents: 'none', }, camera: { flex: 1, diff --git a/app/nutrition/records.tsx b/app/nutrition/records.tsx index 3406bec..6cbb26a 100644 --- a/app/nutrition/records.tsx +++ b/app/nutrition/records.tsx @@ -1,11 +1,14 @@ import { CalorieRingChart } from '@/components/CalorieRingChart'; import { DateSelector } from '@/components/DateSelector'; +import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay'; import { NutritionRecordCard } from '@/components/NutritionRecordCard'; import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { DietRecord } from '@/services/dietRecords'; +import { type FoodRecognitionResponse } from '@/services/foodRecognition'; +import { saveRecognitionResult } from '@/store/foodRecognitionSlice'; import { selectHealthDataByDate } from '@/store/healthSlice'; import { deleteNutritionRecord, @@ -70,6 +73,9 @@ export default function NutritionRecordsScreen() { const [hasMoreData, setHasMoreData] = useState(true); const [page, setPage] = useState(1); + // 食物添加弹窗状态 + const [showFoodOverlay, setShowFoodOverlay] = useState(false); + // 根据视图模式选择使用的数据 const displayRecords = viewMode === 'daily' ? nutritionRecords : allRecords; const loading = viewMode === 'daily' ? nutritionLoading.records : allRecordsLoading; @@ -249,6 +255,51 @@ export default function NutritionRecordsScreen() { } }; + // 处理营养记录卡片点击 + const handleRecordPress = (record: DietRecord) => { + // 将 DietRecord 转换为 FoodRecognitionResponse 格式 + const recognitionResult: FoodRecognitionResponse = { + items: [{ + id: record.id.toString(), + label: record.foodName, + foodName: record.foodName, + portion: record.portionDescription || `${record.estimatedCalories || 0}g`, + calories: record.estimatedCalories || 0, + mealType: record.mealType, + nutritionData: { + proteinGrams: record.proteinGrams || 0, + carbohydrateGrams: record.carbohydrateGrams || 0, + fatGrams: record.fatGrams || 0, + fiberGrams: 0, // DietRecord 中没有纤维数据,设为0 + } + }], + analysisText: record.foodDescription || `${record.foodName} - ${record.portionDescription}`, + confidence: 95, // 设置一个默认置信度 + isFoodDetected: true, + nonFoodMessage: undefined + }; + + // 生成唯一的识别ID + const recognitionId = `record-${record.id}-${Date.now()}`; + + // 保存到 Redux + dispatch(saveRecognitionResult({ + id: recognitionId, + result: recognitionResult + })); + + // 跳转到分析结果页面 + router.push({ + pathname: '/food/analysis-result', + params: { + imageUri: record.imageUrl || '', + mealType: record.mealType, + recognitionId: recognitionId, + hideRecordBar: 'true' + } + }); + }; + // 渲染视图模式切换器 const renderViewModeToggle = () => ( @@ -294,8 +345,12 @@ export default function NutritionRecordsScreen() { setSelectedIndex(index)} - showMonthTitle={false} + showMonthTitle={true} disableFutureDates={true} + showCalendarIcon={true} + containerStyle={{ + paddingHorizontal: 16 + }} /> ); }; @@ -317,6 +372,7 @@ export default function NutritionRecordsScreen() { const renderRecord = ({ item, index }: { item: DietRecord; index: number }) => ( handleRecordPress(item)} onDelete={() => handleDeleteRecord(item.id)} /> ); @@ -362,8 +418,7 @@ export default function NutritionRecordsScreen() { // 添加食物的处理函数 const handleAddFood = () => { - const mealType = getCurrentMealType(); - router.push(`/food-library?mealType=${mealType}`); + setShowFoodOverlay(true); }; // 渲染右侧添加按钮 @@ -385,7 +440,7 @@ export default function NutritionRecordsScreen() { right={renderRightButton()} /> - {renderViewModeToggle()} + {/* {renderViewModeToggle()} */} {renderDateSelector()} {/* Calorie Ring Chart */} @@ -425,6 +480,13 @@ export default function NutritionRecordsScreen() { onEndReachedThreshold={0.1} /> )} + + {/* 食物添加悬浮窗 */} + setShowFoodOverlay(false)} + mealType={getCurrentMealType()} + /> ); } diff --git a/components/CalorieRingChart.tsx b/components/CalorieRingChart.tsx index 273f12d..ff17b06 100644 --- a/components/CalorieRingChart.tsx +++ b/components/CalorieRingChart.tsx @@ -115,9 +115,6 @@ export function CalorieRingChart({ {Math.round(canEat)}千卡 - - {Math.round(progressPercentage)}% - @@ -187,7 +184,7 @@ const styles = StyleSheet.create({ borderRadius: 16, padding: 16, marginHorizontal: 16, - marginBottom: 16, + marginBottom: 8, shadowColor: '#000000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, diff --git a/components/DateSelector.tsx b/components/DateSelector.tsx index fb8778f..7b70a24 100644 --- a/components/DateSelector.tsx +++ b/components/DateSelector.tsx @@ -1,7 +1,12 @@ import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; +import { Ionicons } from '@expo/vector-icons'; +import DateTimePicker from '@react-native-community/datetimepicker'; import dayjs from 'dayjs'; import React, { useEffect, useRef, useState } from 'react'; import { + Modal, + Platform, + Pressable, ScrollView, StyleSheet, Text, @@ -28,6 +33,8 @@ export interface DateSelectorProps { dayItemStyle?: any; /** 是否自动滚动到选中项 */ autoScrollToSelected?: boolean; + /** 是否显示日历图标 */ + showCalendarIcon?: boolean; } export const DateSelector: React.FC = ({ @@ -40,14 +47,16 @@ export const DateSelector: React.FC = ({ containerStyle, dayItemStyle, autoScrollToSelected = true, + showCalendarIcon = true, }) => { // 内部状态管理 const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth()); + const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份 const selectedIndex = externalSelectedIndex ?? internalSelectedIndex; // 获取日期数据 - const days = getMonthDaysZh(); - const monthTitle = externalMonthTitle ?? getMonthTitleZh(); + const days = getMonthDaysZh(currentMonth); + const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth); // 滚动相关 const daysScrollRef = useRef(null); @@ -55,6 +64,10 @@ export const DateSelector: React.FC = ({ const DAY_PILL_WIDTH = 48; const DAY_PILL_SPACING = 8; + // 日历弹窗相关 + const [datePickerVisible, setDatePickerVisible] = useState(false); + const [pickerDate, setPickerDate] = useState(new Date()); + // 滚动到指定索引 const scrollToIndex = (index: number, animated = true) => { if (!daysScrollRef.current || scrollWidth === 0) return; @@ -103,6 +116,17 @@ export const DateSelector: React.FC = ({ } }, [selectedIndex, autoScrollToSelected]); + // 当月份变化时,重新滚动到选中位置 + useEffect(() => { + if (scrollWidth > 0 && autoScrollToSelected) { + const timer = setTimeout(() => { + scrollToIndex(selectedIndex, true); + }, 100); + + return () => clearTimeout(timer); + } + }, [currentMonth, scrollWidth, autoScrollToSelected]); + // 处理日期选择 const handleDateSelect = (index: number) => { const targetDate = days[index]?.date?.toDate(); @@ -127,10 +151,61 @@ export const DateSelector: React.FC = ({ onDateSelect?.(index, targetDate); }; + // 日历弹窗相关函数 + const openDatePicker = () => { + const currentSelectedDate = days[selectedIndex]?.date?.toDate() || new Date(); + setPickerDate(currentSelectedDate); + setDatePickerVisible(true); + }; + + const closeDatePicker = () => setDatePickerVisible(false); + + const onConfirmDate = (date: Date) => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const picked = new Date(date); + picked.setHours(0, 0, 0, 0); + + // 如果禁用未来日期,则限制选择 + const finalDate = (disableFutureDates && picked > today) ? today : picked; + + closeDatePicker(); + + // 更新当前月份为选中日期的月份 + const selectedMonth = dayjs(finalDate); + setCurrentMonth(selectedMonth); + + // 计算选中日期在新月份中的索引 + const newMonthDays = getMonthDaysZh(selectedMonth); + const selectedDay = selectedMonth.date(); + const newSelectedIndex = newMonthDays.findIndex(day => day.dayOfMonth === selectedDay); + + // 更新内部状态(如果使用外部控制则不更新) + if (externalSelectedIndex === undefined && newSelectedIndex !== -1) { + setInternalSelectedIndex(newSelectedIndex); + } + + // 调用统一的日期选择回调 + if (newSelectedIndex !== -1) { + onDateSelect?.(newSelectedIndex, finalDate); + } + }; + return ( {showMonthTitle && ( - {monthTitle} + + {monthTitle} + {showCalendarIcon && ( + + + + )} + )} = ({ ); })} + + {/* 日历选择弹窗 */} + + + + { + 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]}> + 确定 + + + )} + + ); }; @@ -183,12 +301,22 @@ export const DateSelector: React.FC = ({ const styles = StyleSheet.create({ container: { + }, + monthTitleContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + marginBottom: 8, }, monthTitle: { - fontSize: 18, + fontSize: 20, fontWeight: '800', color: '#192126', - marginBottom: 14, + }, + calendarIconButton: { + padding: 4, + borderRadius: 6, + marginLeft: 4 }, daysContainer: { paddingBottom: 8, @@ -243,4 +371,41 @@ const styles = StyleSheet.create({ dayDateDisabled: { color: 'gray', }, + 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', + }, }); diff --git a/components/NutritionRadarCard.tsx b/components/NutritionRadarCard.tsx index a490e64..97d36c5 100644 --- a/components/NutritionRadarCard.tsx +++ b/components/NutritionRadarCard.tsx @@ -5,9 +5,10 @@ import { NutritionSummary } from '@/services/dietRecords'; 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, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Animated, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import Svg, { Circle } from 'react-native-svg'; const AnimatedCircle = Animated.createAnimatedComponent(Circle); @@ -137,6 +138,10 @@ export function NutritionRadarCard({ }); const handleNavigateToRecords = () => { + // ios 下震动反馈 + if (Platform.OS === 'ios') { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } router.push(ROUTES.NUTRITION_RECORDS); };