feat: 添加食物分析结果页面的图片预览功能,优化记录栏显示逻辑
This commit is contained in:
@@ -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<MealType>((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 (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.container}>
|
||||
<HeaderBar
|
||||
title="分析结果"
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>未找到图片</Text>
|
||||
<Text style={styles.errorText}>未找到图片或识别结果</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,17 +237,12 @@ export default function FoodAnalysisResultScreen() {
|
||||
<View style={styles.container}>
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#ffffffff']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* 装饰性圆圈 */}
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
<View style={styles.decorativeCircle3} />
|
||||
|
||||
<HeaderBar
|
||||
title="分析结果"
|
||||
onBack={() => router.back()}
|
||||
@@ -226,18 +252,36 @@ export default function FoodAnalysisResultScreen() {
|
||||
<ScrollView style={styles.scrollContainer} showsVerticalScrollIndicator={false}>
|
||||
{/* 食物主图 */}
|
||||
<View style={styles.imageContainer}>
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={styles.foodImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
{imageUri ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowImagePreview(true)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={styles.foodImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
{/* 预览提示图标 */}
|
||||
<View style={styles.previewHint}>
|
||||
<Ionicons name="expand-outline" size={20} color="#FFF" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.placeholderContainer}>
|
||||
<View style={styles.placeholderContent}>
|
||||
<Ionicons name="restaurant-outline" size={48} color="#666" />
|
||||
<Text style={styles.placeholderText}>营养记录</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 识别信息气泡 */}
|
||||
<View style={styles.descriptionBubble}>
|
||||
<Text style={styles.descriptionText}>
|
||||
{recognitionResult ?
|
||||
`置信度: ${recognitionResult.confidence}%` :
|
||||
'2025年9月4日'
|
||||
dayjs().format('YYYY年M月D日')
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -259,6 +303,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
unit="克"
|
||||
percentage={Math.min(100, proteinPercentage)}
|
||||
color="#4CAF50"
|
||||
resetToken={animationTrigger}
|
||||
/>
|
||||
<NutritionRing
|
||||
label="脂肪"
|
||||
@@ -266,6 +311,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
unit="克"
|
||||
percentage={Math.min(100, fatPercentage)}
|
||||
color="#FF9800"
|
||||
resetToken={animationTrigger}
|
||||
/>
|
||||
<NutritionRing
|
||||
label="碳水"
|
||||
@@ -273,6 +319,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
unit="克"
|
||||
percentage={Math.min(100, carbohydratePercentage)}
|
||||
color="#2196F3"
|
||||
resetToken={animationTrigger}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -320,15 +367,15 @@ export default function FoodAnalysisResultScreen() {
|
||||
|
||||
<View style={styles.foodIntakeCalories}>
|
||||
<Text style={styles.foodIntakeCaloriesValue}>{item.calories}千卡</Text>
|
||||
<TouchableOpacity style={styles.editButton}>
|
||||
{shouldHideRecordBar ? null : <TouchableOpacity style={styles.editButton}>
|
||||
<Ionicons name="create-outline" size={16} color="#666" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
</TouchableOpacity>}
|
||||
{shouldHideRecordBar ? null : <TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={() => handleRemoveFood(item.id)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={16} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
@@ -337,48 +384,50 @@ export default function FoodAnalysisResultScreen() {
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部餐次选择和记录按钮 */}
|
||||
{recognitionResult && !recognitionResult.isFoodDetected ? (
|
||||
// 非食物检测情况显示重新拍照按钮
|
||||
<View style={styles.bottomContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.retakePhotoButton}
|
||||
onPress={() => router.back()}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
|
||||
<Text style={styles.retakePhotoButtonText}>重新拍照</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
// 正常食物识别情况
|
||||
<View style={styles.bottomContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.mealSelector}
|
||||
onPress={() => setShowMealSelector(true)}
|
||||
>
|
||||
<View style={[
|
||||
styles.mealIndicator,
|
||||
{ backgroundColor: mealOptions.find(option => option.key === currentMealType)?.color || '#FF6B35' }
|
||||
]} />
|
||||
<Text style={styles.mealText}>{MEAL_TYPE_MAP[currentMealType as keyof typeof MEAL_TYPE_MAP]}</Text>
|
||||
<Ionicons name="chevron-down" size={16} color="#333" />
|
||||
</TouchableOpacity>
|
||||
{!shouldHideRecordBar && (
|
||||
recognitionResult && !recognitionResult.isFoodDetected ? (
|
||||
// 非食物检测情况显示重新拍照按钮
|
||||
<View style={styles.bottomContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.retakePhotoButton}
|
||||
onPress={() => router.back()}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
|
||||
<Text style={styles.retakePhotoButtonText}>重新拍照</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
// 正常食物识别情况
|
||||
<View style={styles.bottomContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.mealSelector}
|
||||
onPress={() => setShowMealSelector(true)}
|
||||
>
|
||||
<View style={[
|
||||
styles.mealIndicator,
|
||||
{ backgroundColor: mealOptions.find(option => option.key === currentMealType)?.color || '#FF6B35' }
|
||||
]} />
|
||||
<Text style={styles.mealText}>{MEAL_TYPE_MAP[currentMealType as keyof typeof MEAL_TYPE_MAP]}</Text>
|
||||
<Ionicons name="chevron-down" size={16} color="#333" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.recordButton,
|
||||
(isRecording || foodItems.length === 0) && styles.recordButtonDisabled
|
||||
]}
|
||||
disabled={isRecording || foodItems.length === 0}
|
||||
onPress={handleSaveToDiary}
|
||||
>
|
||||
{isRecording ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
) : (
|
||||
<Text style={styles.recordButtonText}>记录</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.recordButton,
|
||||
(isRecording || foodItems.length === 0) && styles.recordButtonDisabled
|
||||
]}
|
||||
disabled={isRecording || foodItems.length === 0}
|
||||
onPress={handleSaveToDiary}
|
||||
>
|
||||
{isRecording ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
) : (
|
||||
<Text style={styles.recordButtonText}>记录</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 餐次选择弹窗 */}
|
||||
@@ -426,6 +475,39 @@ export default function FoodAnalysisResultScreen() {
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* 图片预览 */}
|
||||
<ImageViewing
|
||||
images={imageUri ? [{ uri: imageUri }] : []}
|
||||
imageIndex={0}
|
||||
visible={showImagePreview}
|
||||
onRequestClose={() => {
|
||||
console.log('ImageViewing onRequestClose called');
|
||||
setShowImagePreview(false);
|
||||
}}
|
||||
swipeToCloseEnabled={true}
|
||||
doubleTapToZoomEnabled={true}
|
||||
HeaderComponent={() => (
|
||||
<View style={styles.imageViewerHeader}>
|
||||
<Text style={styles.imageViewerHeaderText}>
|
||||
{recognitionResult ?
|
||||
`置信度: ${recognitionResult.confidence}%` :
|
||||
dayjs().format('YYYY年M月D日 HH:mm')
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
FooterComponent={() => (
|
||||
<View style={styles.imageViewerFooter}>
|
||||
<TouchableOpacity
|
||||
style={styles.imageViewerFooterButton}
|
||||
onPress={() => setShowImagePreview(false)}
|
||||
>
|
||||
<Text style={styles.imageViewerFooterButtonText}>关闭</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<View style={styles.nutritionRingContainer}>
|
||||
<View style={styles.ringWrapper}>
|
||||
<CircularRing
|
||||
size={60}
|
||||
strokeWidth={4}
|
||||
strokeWidth={6}
|
||||
trackColor="#E2E8F0"
|
||||
progressColor={color}
|
||||
progress={percentage / 100}
|
||||
showCenterText={false}
|
||||
resetToken={resetToken}
|
||||
durationMs={1200}
|
||||
/>
|
||||
<View style={styles.ringCenter}>
|
||||
<Text style={styles.ringCenterText}>{percentage}%</Text>
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -159,21 +159,23 @@ export default function FoodCameraScreen() {
|
||||
<View style={styles.cameraFrameContainer}>
|
||||
<Text style={styles.hintText}>确保食物在取景框内</Text>
|
||||
|
||||
{/* 相机取景框 */}
|
||||
<View style={styles.cameraFrame}>
|
||||
<CameraView
|
||||
ref={cameraRef}
|
||||
style={styles.cameraView}
|
||||
facing={facing}
|
||||
>
|
||||
{/* 取景框装饰 */}
|
||||
<View style={styles.viewfinderOverlay}>
|
||||
<View style={[styles.corner, styles.topLeft]} />
|
||||
<View style={[styles.corner, styles.topRight]} />
|
||||
<View style={[styles.corner, styles.bottomLeft]} />
|
||||
<View style={[styles.corner, styles.bottomRight]} />
|
||||
</View>
|
||||
</CameraView>
|
||||
{/* 相机取景框包装器 */}
|
||||
<View style={styles.cameraWrapper}>
|
||||
{/* 相机取景框 */}
|
||||
<View style={styles.cameraFrame}>
|
||||
<CameraView
|
||||
ref={cameraRef}
|
||||
style={styles.cameraView}
|
||||
facing={facing}
|
||||
/>
|
||||
</View>
|
||||
{/* 取景框装饰 - 放在外层避免被截断 */}
|
||||
<View style={styles.viewfinderOverlay}>
|
||||
<View style={[styles.corner, styles.topLeft]} />
|
||||
<View style={[styles.corner, styles.topRight]} />
|
||||
<View style={[styles.corner, styles.bottomLeft]} />
|
||||
<View style={[styles.corner, styles.bottomRight]} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user