1261 lines
34 KiB
TypeScript
1261 lines
34 KiB
TypeScript
import { CircularRing } from '@/components/CircularRing';
|
||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { ROUTES } from '@/constants/Routes';
|
||
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,
|
||
Pressable,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Text,
|
||
TextInput,
|
||
TouchableOpacity,
|
||
View
|
||
} from 'react-native';
|
||
import ImageViewing from 'react-native-image-viewing';
|
||
|
||
|
||
// 模拟食物摄入列表数据
|
||
const mockFoodItems = [
|
||
{
|
||
id: '1',
|
||
name: '每日豆奶',
|
||
emoji: '🥛',
|
||
amount: 190,
|
||
unit: 'g',
|
||
calories: 80,
|
||
protein: 4.6,
|
||
carbohydrate: 4.0,
|
||
fat: 4.2,
|
||
},
|
||
{
|
||
id: '2',
|
||
name: '全麦面包',
|
||
emoji: '🍞',
|
||
amount: 50,
|
||
unit: 'g',
|
||
calories: 120,
|
||
protein: 4.0,
|
||
carbohydrate: 22.0,
|
||
fat: 2.0,
|
||
},
|
||
{
|
||
id: '3',
|
||
name: '香蕉',
|
||
emoji: '🍌',
|
||
amount: 100,
|
||
unit: 'g',
|
||
calories: 89,
|
||
protein: 1.1,
|
||
carbohydrate: 23.0,
|
||
fat: 0.3,
|
||
}
|
||
];
|
||
|
||
// 餐次映射
|
||
const MEAL_TYPE_MAP = {
|
||
breakfast: '早餐',
|
||
lunch: '午餐',
|
||
dinner: '晚餐',
|
||
snack: '加餐'
|
||
};
|
||
|
||
export default function FoodAnalysisResultScreen() {
|
||
const router = useRouter();
|
||
const params = useLocalSearchParams<{
|
||
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 [showImagePreview, setShowImagePreview] = useState(false);
|
||
const [animationTrigger, setAnimationTrigger] = useState(0);
|
||
const [editingFood, setEditingFood] = useState<string | null>(null);
|
||
const [editFormData, setEditFormData] = useState({ name: '', amount: '', calories: '' });
|
||
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) {
|
||
// 检查是否识别到食物
|
||
if (!recognitionResult.isFoodDetected) {
|
||
// 非食物检测情况,清空食物列表
|
||
setFoodItems([]);
|
||
return;
|
||
}
|
||
|
||
// 将识别结果转换为食物项格式
|
||
const convertedItems = recognitionResult.items.map((item, index) => ({
|
||
id: item.id || `item-${index}`,
|
||
name: item.foodName,
|
||
emoji: getRandomFoodEmoji(), // 使用随机emoji
|
||
amount: parseInt(item.portion.match(/\d+/)?.[0] || '100'),
|
||
unit: item.portion.includes('g') ? 'g' : item.portion.includes('ml') ? 'ml' : 'g',
|
||
calories: item.calories,
|
||
protein: item.nutritionData.proteinGrams || 0,
|
||
carbohydrate: item.nutritionData.carbohydrateGrams || 0,
|
||
fat: item.nutritionData.fatGrams || 0,
|
||
}));
|
||
|
||
setFoodItems(convertedItems);
|
||
|
||
// 如果识别结果中有餐次信息,更新餐次
|
||
if (recognitionResult.items.length > 0 && recognitionResult.items[0].mealType) {
|
||
const mealTypeFromApi = recognitionResult.items[0].mealType as MealType;
|
||
if (['breakfast', 'lunch', 'dinner', 'snack'].includes(mealTypeFromApi)) {
|
||
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;
|
||
|
||
setIsRecording(true);
|
||
|
||
try {
|
||
// 逐个记录所有食物
|
||
for (const item of foodItems) {
|
||
const dietRecordData: CreateDietRecordDto = {
|
||
mealType: currentMealType,
|
||
foodName: item.name,
|
||
foodDescription: `${item.amount}${item.unit}`,
|
||
portionDescription: `${item.amount}${item.unit}`,
|
||
estimatedCalories: item.calories,
|
||
proteinGrams: item.protein,
|
||
carbohydrateGrams: item.carbohydrate,
|
||
fatGrams: item.fat,
|
||
source: 'vision',
|
||
mealTime: new Date().toISOString(),
|
||
imageUrl: imageUri,
|
||
};
|
||
|
||
await addDietRecord(dietRecordData);
|
||
}
|
||
|
||
router.replace(ROUTES.TAB_STATISTICS)
|
||
} catch (error) {
|
||
console.error('记录饮食失败:', error);
|
||
} finally {
|
||
setIsRecording(false);
|
||
}
|
||
};
|
||
|
||
// 计算所有食物的总营养数据
|
||
const totalCalories = foodItems.reduce((sum, item) => sum + item.calories, 0);
|
||
const totalProtein = foodItems.reduce((sum, item) => sum + item.protein, 0);
|
||
const totalCarbohydrate = foodItems.reduce((sum, item) => sum + item.carbohydrate, 0);
|
||
const totalFat = foodItems.reduce((sum, item) => sum + item.fat, 0);
|
||
|
||
// 计算营养比例(基于推荐日摄入量)
|
||
const proteinPercentage = Math.round((totalProtein / 50) * 100); // 推荐50g
|
||
const fatPercentage = Math.round((totalFat / 65) * 100); // 推荐65g
|
||
const carbohydratePercentage = Math.round((totalCarbohydrate / 300) * 100); // 推荐300g
|
||
|
||
// 删除食物项
|
||
const handleRemoveFood = (itemId: string) => {
|
||
setFoodItems(prev => prev.filter(item => item.id !== itemId));
|
||
};
|
||
|
||
// 打开编辑弹窗
|
||
const handleEditFood = (item: typeof mockFoodItems[0]) => {
|
||
setEditFormData({
|
||
name: item.name,
|
||
amount: item.amount.toString(),
|
||
calories: item.calories.toString(),
|
||
});
|
||
setEditingFood(item.id);
|
||
};
|
||
|
||
// 保存编辑
|
||
const handleSaveEdit = () => {
|
||
if (!editingFood) return;
|
||
|
||
const amount = parseFloat(editFormData.amount) || 0;
|
||
const calories = parseFloat(editFormData.calories) || 0;
|
||
|
||
setFoodItems(prev => prev.map(item =>
|
||
item.id === editingFood
|
||
? { ...item, name: editFormData.name, amount, calories }
|
||
: item
|
||
));
|
||
|
||
setEditingFood(null);
|
||
setEditFormData({ name: '', amount: '', calories: '' });
|
||
};
|
||
|
||
// 关闭编辑弹窗
|
||
const handleCloseEdit = () => {
|
||
setEditingFood(null);
|
||
setEditFormData({ name: '', amount: '', calories: '' });
|
||
};
|
||
|
||
// 获取随机食物emoji
|
||
function getRandomFoodEmoji(): string {
|
||
const foodEmojis = ['🍎', '🍌', '🍞', '🥛', '🥗', '🍗', '🍖', '🥕', '🥦', '🥬', '🍅', '🥒', '🍇', '🥝', '🍓'];
|
||
return foodEmojis[Math.floor(Math.random() * foodEmojis.length)];
|
||
}
|
||
|
||
// 处理餐次选择
|
||
const handleMealTypeSelect = (selectedMealType: MealType) => {
|
||
setCurrentMealType(selectedMealType);
|
||
setShowMealSelector(false);
|
||
};
|
||
|
||
// 餐次选择选项
|
||
const mealOptions = [
|
||
{ key: 'breakfast' as const, label: '早餐', color: '#FF6B35' },
|
||
{ key: 'lunch' as const, label: '午餐', color: '#4CAF50' },
|
||
{ key: 'dinner' as const, label: '晚餐', color: '#2196F3' },
|
||
{ key: 'snack' as const, label: '加餐', color: '#FF9800' },
|
||
];
|
||
|
||
if (!imageUri && !recognitionResult) {
|
||
return (
|
||
<View style={styles.container}>
|
||
<HeaderBar
|
||
title="分析结果"
|
||
onBack={() => router.back()}
|
||
/>
|
||
<View style={styles.errorContainer}>
|
||
<Text style={styles.errorText}>未找到图片或识别结果</Text>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<View style={styles.container}>
|
||
{/* 背景渐变 */}
|
||
<LinearGradient
|
||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#ffffffff']}
|
||
style={styles.gradientBackground}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 0, y: 1 }}
|
||
/>
|
||
|
||
<HeaderBar
|
||
title="分析结果"
|
||
onBack={() => router.back()}
|
||
transparent={true}
|
||
/>
|
||
|
||
<ScrollView style={styles.scrollContainer} showsVerticalScrollIndicator={false}>
|
||
{/* 食物主图 */}
|
||
<View style={styles.imageContainer}>
|
||
{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}%` :
|
||
dayjs().format('YYYY年M月D日')
|
||
}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 营养信息卡片 */}
|
||
<View style={styles.nutritionCard}>
|
||
{/* 卡路里 */}
|
||
<View style={styles.calorieSection}>
|
||
<Text style={styles.calorieValue}>{totalCalories}</Text>
|
||
<Text style={styles.calorieUnit}>千卡</Text>
|
||
</View>
|
||
|
||
{/* 营养圆环图 */}
|
||
<View style={styles.nutritionRings}>
|
||
<NutritionRing
|
||
label="蛋白质"
|
||
value={totalProtein.toFixed(1)}
|
||
unit="克"
|
||
percentage={Math.min(100, proteinPercentage)}
|
||
color="#4CAF50"
|
||
resetToken={animationTrigger}
|
||
/>
|
||
<NutritionRing
|
||
label="脂肪"
|
||
value={totalFat.toFixed(1)}
|
||
unit="克"
|
||
percentage={Math.min(100, fatPercentage)}
|
||
color="#FF9800"
|
||
resetToken={animationTrigger}
|
||
/>
|
||
<NutritionRing
|
||
label="碳水"
|
||
value={totalCarbohydrate.toFixed(1)}
|
||
unit="克"
|
||
percentage={Math.min(100, carbohydratePercentage)}
|
||
color="#2196F3"
|
||
resetToken={animationTrigger}
|
||
/>
|
||
</View>
|
||
|
||
|
||
{/* 食物摄入部分 */}
|
||
<View style={styles.foodIntakeSection}>
|
||
<Text style={styles.foodIntakeTitle}>
|
||
{recognitionResult ? '识别结果' : '食物摄入'}
|
||
</Text>
|
||
{recognitionResult && recognitionResult.analysisText && (
|
||
<Text style={styles.analysisText}>{recognitionResult.analysisText}</Text>
|
||
)}
|
||
|
||
{/* 非食物检测提示 */}
|
||
{recognitionResult && !recognitionResult.isFoodDetected && (
|
||
<View style={styles.nonFoodContainer}>
|
||
<View style={styles.nonFoodIcon}>
|
||
<Ionicons name="alert-circle-outline" size={48} color="#FF9800" />
|
||
</View>
|
||
<Text style={styles.nonFoodTitle}>未识别到食物</Text>
|
||
<Text style={styles.nonFoodMessage}>
|
||
{recognitionResult.nonFoodMessage || recognitionResult.analysisText}
|
||
</Text>
|
||
<View style={styles.nonFoodSuggestions}>
|
||
<Text style={styles.nonFoodSuggestionsTitle}>建议:</Text>
|
||
<Text style={styles.nonFoodSuggestionItem}>• 确保图片中包含食物</Text>
|
||
<Text style={styles.nonFoodSuggestionItem}>• 尝试更清晰的照片角度</Text>
|
||
<Text style={styles.nonFoodSuggestionItem}>• 避免过度模糊或光线不足</Text>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* 食物列表 */}
|
||
{foodItems.length > 0 && foodItems.map((item, index) => (
|
||
<View key={item.id} style={[styles.foodIntakeRow, index > 0 && { marginTop: 8 }]}>
|
||
<View style={styles.foodIntakeInfo}>
|
||
<View style={styles.foodIntakeIcon}>
|
||
<Text style={styles.foodIconEmoji}>{item.emoji}</Text>
|
||
</View>
|
||
<View style={styles.foodIntakeDetails}>
|
||
<Text style={styles.foodIntakeName}>{item.name}</Text>
|
||
<Text style={styles.foodIntakeAmount}>{item.amount}{item.unit}</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.foodIntakeCalories}>
|
||
<Text style={styles.foodIntakeCaloriesValue}>{item.calories}千卡</Text>
|
||
{shouldHideRecordBar ? null : <TouchableOpacity
|
||
style={styles.editButton}
|
||
onPress={() => handleEditFood(item)}
|
||
>
|
||
<Ionicons name="create-outline" size={16} color="#666" />
|
||
</TouchableOpacity>}
|
||
{shouldHideRecordBar ? null : <TouchableOpacity
|
||
style={styles.deleteButton}
|
||
onPress={() => handleRemoveFood(item.id)}
|
||
>
|
||
<Ionicons name="trash-outline" size={16} color="#666" />
|
||
</TouchableOpacity>}
|
||
</View>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
</ScrollView>
|
||
|
||
{/* 底部餐次选择和记录按钮 */}
|
||
{!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>
|
||
)
|
||
)}
|
||
|
||
{/* 餐次选择弹窗 */}
|
||
<Modal
|
||
visible={showMealSelector}
|
||
transparent={true}
|
||
animationType="fade"
|
||
onRequestClose={() => setShowMealSelector(false)}
|
||
>
|
||
<View style={styles.mealSelectorOverlay}>
|
||
<TouchableOpacity
|
||
style={styles.mealSelectorBackdrop}
|
||
onPress={() => setShowMealSelector(false)}
|
||
/>
|
||
<View style={styles.mealSelectorModal}>
|
||
<View style={styles.mealSelectorHeader}>
|
||
<Text style={styles.mealSelectorTitle}>选择餐次</Text>
|
||
<TouchableOpacity onPress={() => setShowMealSelector(false)}>
|
||
<Ionicons name="close" size={24} color="#666" />
|
||
</TouchableOpacity>
|
||
</View>
|
||
<View style={styles.mealOptionsContainer}>
|
||
{mealOptions.map((option) => (
|
||
<TouchableOpacity
|
||
key={option.key}
|
||
style={[
|
||
styles.mealOption,
|
||
currentMealType === option.key && styles.mealOptionActive
|
||
]}
|
||
onPress={() => handleMealTypeSelect(option.key)}
|
||
>
|
||
<View style={[styles.mealOptionIndicator, { backgroundColor: option.color }]} />
|
||
<Text style={[
|
||
styles.mealOptionText,
|
||
currentMealType === option.key && styles.mealOptionTextActive
|
||
]}>
|
||
{option.label}
|
||
</Text>
|
||
{currentMealType === option.key && (
|
||
<Ionicons name="checkmark" size={20} color="#4CAF50" />
|
||
)}
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
</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>
|
||
)}
|
||
/>
|
||
|
||
{/* 编辑食物弹窗 */}
|
||
<FoodEditModal
|
||
visible={!!editingFood}
|
||
formData={editFormData}
|
||
onFormDataChange={setEditFormData}
|
||
onClose={handleCloseEdit}
|
||
onSave={handleSaveEdit}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// 编辑食物弹窗组件
|
||
function FoodEditModal({
|
||
visible,
|
||
formData,
|
||
onFormDataChange,
|
||
onClose,
|
||
onSave
|
||
}: {
|
||
visible: boolean;
|
||
formData: { name: string; amount: string; calories: string };
|
||
onFormDataChange: (data: { name: string; amount: string; calories: string }) => void;
|
||
onClose: () => void;
|
||
onSave: () => void;
|
||
}) {
|
||
const handleFieldChange = (field: string, value: string) => {
|
||
onFormDataChange({ ...formData, [field]: value });
|
||
};
|
||
|
||
return (
|
||
<Modal
|
||
visible={visible}
|
||
transparent
|
||
animationType="fade"
|
||
onRequestClose={onClose}
|
||
>
|
||
<Pressable style={styles.modalBackdrop} onPress={onClose} />
|
||
<View style={styles.editModalSheet}>
|
||
<View style={styles.modalHandle} />
|
||
|
||
<Text style={styles.modalTitle}>编辑食物信息</Text>
|
||
|
||
{/* 食物名称 */}
|
||
<View style={styles.editFieldContainer}>
|
||
<Text style={styles.editFieldLabel}>食物名称</Text>
|
||
<TextInput
|
||
style={styles.editInput}
|
||
placeholder="输入食物名称"
|
||
placeholderTextColor="#999"
|
||
value={formData.name}
|
||
onChangeText={(value) => handleFieldChange('name', value)}
|
||
autoFocus
|
||
/>
|
||
</View>
|
||
|
||
{/* 重量/数量 */}
|
||
<View style={styles.editFieldContainer}>
|
||
<Text style={styles.editFieldLabel}>重量 (克)</Text>
|
||
<TextInput
|
||
style={styles.editInput}
|
||
placeholder="输入重量"
|
||
placeholderTextColor="#999"
|
||
value={formData.amount}
|
||
onChangeText={(value) => handleFieldChange('amount', value)}
|
||
keyboardType="numeric"
|
||
/>
|
||
</View>
|
||
|
||
{/* 卡路里 */}
|
||
<View style={styles.editFieldContainer}>
|
||
<Text style={styles.editFieldLabel}>卡路里 (千卡)</Text>
|
||
<TextInput
|
||
style={styles.editInput}
|
||
placeholder="输入卡路里"
|
||
placeholderTextColor="#999"
|
||
value={formData.calories}
|
||
onChangeText={(value) => handleFieldChange('calories', value)}
|
||
keyboardType="numeric"
|
||
/>
|
||
</View>
|
||
|
||
{/* 按钮区域 */}
|
||
<View style={styles.modalButtons}>
|
||
<TouchableOpacity
|
||
onPress={onClose}
|
||
style={styles.modalCancelBtn}
|
||
>
|
||
<Text style={styles.modalCancelText}>取消</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
onPress={onSave}
|
||
style={[styles.modalSaveBtn, { backgroundColor: Colors.light.primary }]}
|
||
>
|
||
<Text style={[styles.modalSaveText, { color: Colors.light.onPrimary }]}>保存</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// 营养圆环组件
|
||
function NutritionRing({
|
||
label,
|
||
value,
|
||
unit,
|
||
percentage,
|
||
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={6}
|
||
trackColor="#E2E8F0"
|
||
progressColor={color}
|
||
progress={percentage / 100}
|
||
showCenterText={false}
|
||
resetToken={resetToken}
|
||
durationMs={1200}
|
||
/>
|
||
<View style={styles.ringCenter}>
|
||
<Text style={styles.ringCenterText}>{percentage}%</Text>
|
||
</View>
|
||
</View>
|
||
<Text style={styles.nutritionRingLabel}>{label}</Text>
|
||
<Text style={styles.nutritionRingValue}>
|
||
{value}{unit}
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
backgroundColor: '#f5e5fbff',
|
||
},
|
||
gradientBackground: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
},
|
||
decorativeCircle1: {
|
||
position: 'absolute',
|
||
top: 120,
|
||
right: 30,
|
||
width: 80,
|
||
height: 80,
|
||
borderRadius: 40,
|
||
backgroundColor: '#0EA5E9',
|
||
opacity: 0.08,
|
||
},
|
||
decorativeCircle2: {
|
||
position: 'absolute',
|
||
top: 250,
|
||
left: -20,
|
||
width: 60,
|
||
height: 60,
|
||
borderRadius: 30,
|
||
backgroundColor: '#8B74F3',
|
||
opacity: 0.06,
|
||
},
|
||
decorativeCircle3: {
|
||
position: 'absolute',
|
||
bottom: 200,
|
||
right: -15,
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
backgroundColor: '#0EA5E9',
|
||
opacity: 0.05,
|
||
},
|
||
scrollContainer: {
|
||
flex: 1,
|
||
},
|
||
imageContainer: {
|
||
position: 'relative',
|
||
height: 300,
|
||
},
|
||
foodImage: {
|
||
width: '100%',
|
||
height: '100%',
|
||
},
|
||
descriptionBubble: {
|
||
position: 'absolute',
|
||
top: 20,
|
||
left: 20,
|
||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
borderRadius: 16,
|
||
shadowColor: '#000',
|
||
shadowOffset: {
|
||
width: 0,
|
||
height: 2,
|
||
},
|
||
shadowOpacity: 0.1,
|
||
shadowRadius: 4,
|
||
elevation: 3,
|
||
},
|
||
descriptionText: {
|
||
fontSize: 14,
|
||
color: Colors.light.text,
|
||
fontWeight: '500',
|
||
},
|
||
nutritionCard: {
|
||
backgroundColor: Colors.light.background,
|
||
borderTopLeftRadius: 24,
|
||
borderTopRightRadius: 24,
|
||
height: '100%',
|
||
marginTop: -24,
|
||
paddingTop: 24,
|
||
paddingHorizontal: 20,
|
||
paddingBottom: 40,
|
||
shadowColor: '#000',
|
||
shadowOffset: {
|
||
width: 0,
|
||
height: -4,
|
||
},
|
||
shadowOpacity: 0.1,
|
||
shadowRadius: 12,
|
||
elevation: 8,
|
||
},
|
||
calorieSection: {
|
||
flexDirection: 'row',
|
||
alignItems: 'baseline',
|
||
marginBottom: 8,
|
||
},
|
||
calorieValue: {
|
||
fontSize: 32,
|
||
fontWeight: '700',
|
||
|
||
lineHeight: 32,
|
||
},
|
||
calorieUnit: {
|
||
fontSize: 16,
|
||
color: Colors.light.textSecondary,
|
||
marginLeft: 8,
|
||
},
|
||
foodName: {
|
||
fontSize: 20,
|
||
fontWeight: '600',
|
||
color: Colors.light.text,
|
||
marginBottom: 24,
|
||
},
|
||
nutritionHeader: {
|
||
marginBottom: 16,
|
||
},
|
||
nutritionTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '600',
|
||
color: Colors.light.text,
|
||
textAlign: 'center',
|
||
},
|
||
nutritionRings: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-around',
|
||
marginBottom: 32,
|
||
paddingVertical: 20,
|
||
},
|
||
nutritionRingContainer: {
|
||
alignItems: 'center',
|
||
},
|
||
ringWrapper: {
|
||
position: 'relative',
|
||
marginBottom: 8,
|
||
},
|
||
ringCenter: {
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
ringCenterText: {
|
||
fontSize: 14,
|
||
fontWeight: '600',
|
||
color: Colors.light.text,
|
||
},
|
||
nutritionRingLabel: {
|
||
fontSize: 14,
|
||
color: Colors.light.textSecondary,
|
||
fontWeight: '500',
|
||
marginBottom: 2,
|
||
},
|
||
nutritionRingValue: {
|
||
fontSize: 12,
|
||
color: Colors.light.text,
|
||
fontWeight: '600',
|
||
},
|
||
foodIntakeSection: {
|
||
marginBottom: 20,
|
||
},
|
||
foodIntakeTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '600',
|
||
color: Colors.light.text,
|
||
marginBottom: 12,
|
||
},
|
||
foodIntakeRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingVertical: 12,
|
||
paddingHorizontal: 16,
|
||
backgroundColor: '#F8F9FA',
|
||
borderRadius: 12,
|
||
},
|
||
foodIntakeInfo: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
flex: 1,
|
||
},
|
||
foodIntakeIcon: {
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
backgroundColor: '#FFF',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginRight: 12,
|
||
},
|
||
foodIconEmoji: {
|
||
fontSize: 20,
|
||
},
|
||
foodIntakeDetails: {
|
||
flex: 1,
|
||
},
|
||
foodIntakeName: {
|
||
fontSize: 16,
|
||
fontWeight: '500',
|
||
color: Colors.light.text,
|
||
marginBottom: 2,
|
||
},
|
||
foodIntakeAmount: {
|
||
fontSize: 14,
|
||
color: Colors.light.textSecondary,
|
||
},
|
||
foodIntakeCalories: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
foodIntakeCaloriesValue: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
color: Colors.light.text,
|
||
marginRight: 8,
|
||
},
|
||
editButton: {
|
||
padding: 4,
|
||
marginRight: 4,
|
||
},
|
||
deleteButton: {
|
||
padding: 4,
|
||
},
|
||
bottomContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 12,
|
||
backgroundColor: '#FFF',
|
||
borderTopWidth: 1,
|
||
borderTopColor: '#E5E5E5',
|
||
},
|
||
mealSelector: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
backgroundColor: '#F8F9FA',
|
||
borderRadius: 20,
|
||
},
|
||
mealIndicator: {
|
||
width: 8,
|
||
height: 8,
|
||
borderRadius: 4,
|
||
backgroundColor: '#FF6B35',
|
||
marginRight: 8,
|
||
},
|
||
mealText: {
|
||
fontSize: 14,
|
||
color: '#333',
|
||
marginRight: 4,
|
||
},
|
||
recordButton: {
|
||
backgroundColor: Colors.light.primary,
|
||
paddingHorizontal: 24,
|
||
paddingVertical: 10,
|
||
borderRadius: 20,
|
||
},
|
||
recordButtonText: {
|
||
fontSize: 16,
|
||
color: '#FFF',
|
||
fontWeight: '500',
|
||
},
|
||
recordButtonDisabled: {
|
||
backgroundColor: '#CCC',
|
||
},
|
||
// 餐次选择弹窗样式
|
||
mealSelectorOverlay: {
|
||
flex: 1,
|
||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||
justifyContent: 'flex-end',
|
||
},
|
||
mealSelectorBackdrop: {
|
||
flex: 1,
|
||
},
|
||
mealSelectorModal: {
|
||
backgroundColor: '#FFF',
|
||
borderTopLeftRadius: 20,
|
||
borderTopRightRadius: 20,
|
||
paddingBottom: 34,
|
||
},
|
||
mealSelectorHeader: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
paddingHorizontal: 20,
|
||
paddingVertical: 16,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: '#E5E5E5',
|
||
},
|
||
mealSelectorTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '600',
|
||
color: '#333',
|
||
},
|
||
mealOptionsContainer: {
|
||
paddingHorizontal: 20,
|
||
paddingVertical: 16,
|
||
},
|
||
mealOption: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingVertical: 16,
|
||
paddingHorizontal: 16,
|
||
borderRadius: 12,
|
||
marginBottom: 8,
|
||
backgroundColor: '#F8F9FA',
|
||
},
|
||
mealOptionActive: {
|
||
backgroundColor: '#E8F5E8',
|
||
borderWidth: 1,
|
||
borderColor: '#4CAF50',
|
||
},
|
||
mealOptionIndicator: {
|
||
width: 12,
|
||
height: 12,
|
||
borderRadius: 6,
|
||
marginRight: 12,
|
||
},
|
||
mealOptionText: {
|
||
flex: 1,
|
||
fontSize: 16,
|
||
color: '#333',
|
||
fontWeight: '500',
|
||
},
|
||
mealOptionTextActive: {
|
||
color: '#4CAF50',
|
||
},
|
||
errorContainer: {
|
||
flex: 1,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
},
|
||
errorText: {
|
||
fontSize: 16,
|
||
color: Colors.light.textMuted,
|
||
textAlign: 'center',
|
||
},
|
||
analysisText: {
|
||
fontSize: 14,
|
||
color: Colors.light.textSecondary,
|
||
marginBottom: 12,
|
||
lineHeight: 20,
|
||
},
|
||
// 非食物检测样式
|
||
nonFoodContainer: {
|
||
backgroundColor: '#FFF8E1',
|
||
borderRadius: 16,
|
||
padding: 24,
|
||
alignItems: 'center',
|
||
marginVertical: 16,
|
||
borderWidth: 1,
|
||
borderColor: '#FFE0B2',
|
||
},
|
||
nonFoodIcon: {
|
||
marginBottom: 16,
|
||
},
|
||
nonFoodTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '600',
|
||
color: '#E65100',
|
||
marginBottom: 8,
|
||
textAlign: 'center',
|
||
},
|
||
nonFoodMessage: {
|
||
fontSize: 16,
|
||
color: Colors.light.text,
|
||
textAlign: 'center',
|
||
lineHeight: 24,
|
||
marginBottom: 16,
|
||
},
|
||
nonFoodSuggestions: {
|
||
alignSelf: 'stretch',
|
||
},
|
||
nonFoodSuggestionsTitle: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
color: Colors.light.text,
|
||
marginBottom: 8,
|
||
},
|
||
nonFoodSuggestionItem: {
|
||
fontSize: 14,
|
||
color: Colors.light.textSecondary,
|
||
marginBottom: 4,
|
||
lineHeight: 20,
|
||
},
|
||
retakePhotoButton: {
|
||
backgroundColor: Colors.light.primary,
|
||
paddingVertical: 16,
|
||
paddingHorizontal: 32,
|
||
borderRadius: 28,
|
||
alignItems: 'center',
|
||
flexDirection: 'row',
|
||
justifyContent: 'center',
|
||
flex: 1,
|
||
shadowColor: Colors.light.primary,
|
||
shadowOffset: {
|
||
width: 0,
|
||
height: 4,
|
||
},
|
||
shadowOpacity: 0.3,
|
||
shadowRadius: 8,
|
||
elevation: 6,
|
||
},
|
||
retakePhotoButtonText: {
|
||
color: Colors.light.onPrimary,
|
||
fontSize: 16,
|
||
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',
|
||
},
|
||
modalBackdrop: {
|
||
...StyleSheet.absoluteFillObject,
|
||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||
},
|
||
// 编辑弹窗样式
|
||
editModalSheet: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
backgroundColor: '#FFFFFF',
|
||
borderTopLeftRadius: 20,
|
||
borderTopRightRadius: 20,
|
||
paddingHorizontal: 20,
|
||
paddingBottom: 40,
|
||
paddingTop: 20,
|
||
},
|
||
modalHandle: {
|
||
width: 36,
|
||
height: 4,
|
||
backgroundColor: '#E0E0E0',
|
||
borderRadius: 2,
|
||
alignSelf: 'center',
|
||
marginBottom: 20,
|
||
},
|
||
modalTitle: {
|
||
fontSize: 20,
|
||
fontWeight: '600',
|
||
color: '#333333',
|
||
marginBottom: 24,
|
||
textAlign: 'center',
|
||
},
|
||
editFieldContainer: {
|
||
marginBottom: 20,
|
||
},
|
||
editFieldLabel: {
|
||
fontSize: 16,
|
||
fontWeight: '500',
|
||
color: '#333333',
|
||
marginBottom: 8,
|
||
},
|
||
editInput: {
|
||
height: 50,
|
||
borderWidth: 1,
|
||
borderColor: '#E0E0E0',
|
||
borderRadius: 12,
|
||
paddingHorizontal: 16,
|
||
fontSize: 16,
|
||
backgroundColor: '#FFFFFF',
|
||
color: '#333333',
|
||
},
|
||
modalButtons: {
|
||
flexDirection: 'row',
|
||
gap: 12,
|
||
marginTop: 8,
|
||
},
|
||
modalCancelBtn: {
|
||
flex: 1,
|
||
height: 50,
|
||
backgroundColor: '#F0F0F0',
|
||
borderRadius: 12,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
modalCancelText: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
color: '#666666',
|
||
},
|
||
modalSaveBtn: {
|
||
flex: 1,
|
||
height: 50,
|
||
borderRadius: 12,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
modalSaveText: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
},
|
||
}); |