- Introduced new translation files for medication, personal, and weight management in Chinese. - Updated the main index file to include the new translation modules. - Enhanced the medication type definitions to include 'ointment'. - Refactored workout type labels to utilize i18n for better localization support. - Improved sleep quality descriptions and recommendations with i18n integration.
1274 lines
36 KiB
TypeScript
1274 lines
36 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 { useI18n } from '@/hooks/useI18n';
|
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
|
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 { Image } from 'expo-image';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
|
import React, { useEffect, useState } from 'react';
|
|
|
|
import {
|
|
ActivityIndicator,
|
|
BackHandler,
|
|
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,
|
|
}
|
|
];
|
|
|
|
export default function FoodAnalysisResultScreen() {
|
|
const { t } = useI18n();
|
|
const safeAreaTop = useSafeAreaTop()
|
|
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 MEAL_TYPE_MAP = {
|
|
breakfast: t('nutritionRecords.mealTypes.breakfast'),
|
|
lunch: t('nutritionRecords.mealTypes.lunch'),
|
|
dinner: t('nutritionRecords.mealTypes.dinner'),
|
|
snack: t('nutritionRecords.mealTypes.snack'),
|
|
other: t('nutritionRecords.mealTypes.other'),
|
|
};
|
|
|
|
// 计算所有食物的总营养数据
|
|
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: t('nutritionRecords.mealTypes.breakfast'), color: '#FF6B35' },
|
|
{ key: 'lunch' as const, label: t('nutritionRecords.mealTypes.lunch'), color: '#4CAF50' },
|
|
{ key: 'dinner' as const, label: t('nutritionRecords.mealTypes.dinner'), color: '#2196F3' },
|
|
{ key: 'snack' as const, label: t('nutritionRecords.mealTypes.snack'), color: '#FF9800' },
|
|
];
|
|
|
|
if (!imageUri && !recognitionResult) {
|
|
return (
|
|
<View style={styles.container}>
|
|
<HeaderBar
|
|
title={t('foodAnalysisResult.title')}
|
|
onBack={() => router.back()}
|
|
/>
|
|
<View style={{
|
|
paddingTop: safeAreaTop
|
|
}} />
|
|
<View style={styles.errorContainer}>
|
|
<Text style={styles.errorText}>{t('foodAnalysisResult.error.notFound')}</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={t('foodAnalysisResult.title')}
|
|
onBack={() => router.back()}
|
|
transparent={true}
|
|
/>
|
|
|
|
<ScrollView style={styles.scrollContainer} contentContainerStyle={{
|
|
paddingTop: safeAreaTop
|
|
}} showsVerticalScrollIndicator={false}>
|
|
{/* 食物主图 */}
|
|
<View style={styles.imageContainer}>
|
|
{imageUri ? (
|
|
<TouchableOpacity
|
|
onPress={() => setShowImagePreview(true)}
|
|
activeOpacity={0.9}
|
|
>
|
|
<Image
|
|
source={{ uri: imageUri }}
|
|
style={styles.foodImage}
|
|
cachePolicy={'memory-disk'}
|
|
/>
|
|
{/* 预览提示图标 */}
|
|
<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}>{t('foodAnalysisResult.placeholder')}</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* 识别信息气泡 */}
|
|
<View style={styles.descriptionBubble}>
|
|
<Text style={styles.descriptionText}>
|
|
{recognitionResult ?
|
|
t('foodAnalysisResult.confidence', { value: recognitionResult.confidence }) :
|
|
dayjs().format(t('foodAnalysisResult.dateFormats.today'))
|
|
}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* 营养信息卡片 */}
|
|
<View style={styles.nutritionCard}>
|
|
{/* 卡路里 */}
|
|
<View style={styles.calorieSection}>
|
|
<Text style={styles.calorieValue}>{totalCalories}</Text>
|
|
<Text style={styles.calorieUnit}>{t('foodAnalysisResult.nutrients.caloriesUnit')}</Text>
|
|
</View>
|
|
|
|
{/* 营养圆环图 */}
|
|
<View style={styles.nutritionRings}>
|
|
<NutritionRing
|
|
label={t('foodAnalysisResult.nutrients.protein')}
|
|
value={totalProtein.toFixed(1)}
|
|
unit={t('foodAnalysisResult.nutrients.unit')}
|
|
percentage={Math.min(100, proteinPercentage)}
|
|
color="#4CAF50"
|
|
resetToken={animationTrigger}
|
|
/>
|
|
<NutritionRing
|
|
label={t('foodAnalysisResult.nutrients.fat')}
|
|
value={totalFat.toFixed(1)}
|
|
unit={t('foodAnalysisResult.nutrients.unit')}
|
|
percentage={Math.min(100, fatPercentage)}
|
|
color="#FF9800"
|
|
resetToken={animationTrigger}
|
|
/>
|
|
<NutritionRing
|
|
label={t('foodAnalysisResult.nutrients.carbs')}
|
|
value={totalCarbohydrate.toFixed(1)}
|
|
unit={t('foodAnalysisResult.nutrients.unit')}
|
|
percentage={Math.min(100, carbohydratePercentage)}
|
|
color="#2196F3"
|
|
resetToken={animationTrigger}
|
|
/>
|
|
</View>
|
|
|
|
|
|
{/* 食物摄入部分 */}
|
|
<View style={styles.foodIntakeSection}>
|
|
<Text style={styles.foodIntakeTitle}>
|
|
{recognitionResult ? t('foodAnalysisResult.sections.recognitionResult') : t('foodAnalysisResult.sections.foodIntake')}
|
|
</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}>{t('foodAnalysisResult.nonFood.title')}</Text>
|
|
<Text style={styles.nonFoodMessage}>
|
|
{recognitionResult.nonFoodMessage || recognitionResult.analysisText}
|
|
</Text>
|
|
<View style={styles.nonFoodSuggestions}>
|
|
<Text style={styles.nonFoodSuggestionsTitle}>{t('foodAnalysisResult.nonFood.suggestions.title')}</Text>
|
|
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item1')}</Text>
|
|
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item2')}</Text>
|
|
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item3')}</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}{t('foodAnalysisResult.nutrients.caloriesUnit')}</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}>{t('foodAnalysisResult.actions.retake')}</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}>{t('foodAnalysisResult.actions.record')}</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}>{t('foodAnalysisResult.mealSelector.title')}</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 ?
|
|
t('foodAnalysisResult.confidence', { value: recognitionResult.confidence }) :
|
|
dayjs().format(t('foodAnalysisResult.dateFormats.full'))
|
|
}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
FooterComponent={() => (
|
|
<View style={styles.imageViewerFooter}>
|
|
<TouchableOpacity
|
|
style={styles.imageViewerFooterButton}
|
|
onPress={() => setShowImagePreview(false)}
|
|
>
|
|
<Text style={styles.imageViewerFooterButtonText}>{t('foodAnalysisResult.actions.close')}</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 });
|
|
};
|
|
|
|
const { t } = useI18n();
|
|
|
|
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}>{t('foodAnalysisResult.editModal.title')}</Text>
|
|
|
|
{/* 食物名称 */}
|
|
<View style={styles.editFieldContainer}>
|
|
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.name')}</Text>
|
|
<TextInput
|
|
style={styles.editInput}
|
|
placeholder={t('foodAnalysisResult.editModal.fields.namePlaceholder')}
|
|
placeholderTextColor="#999"
|
|
value={formData.name}
|
|
onChangeText={(value) => handleFieldChange('name', value)}
|
|
autoFocus
|
|
/>
|
|
</View>
|
|
|
|
{/* 重量/数量 */}
|
|
<View style={styles.editFieldContainer}>
|
|
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.amount')}</Text>
|
|
<TextInput
|
|
style={styles.editInput}
|
|
placeholder={t('foodAnalysisResult.editModal.fields.amountPlaceholder')}
|
|
placeholderTextColor="#999"
|
|
value={formData.amount}
|
|
onChangeText={(value) => handleFieldChange('amount', value)}
|
|
keyboardType="numeric"
|
|
/>
|
|
</View>
|
|
|
|
{/* 卡路里 */}
|
|
<View style={styles.editFieldContainer}>
|
|
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.calories')}</Text>
|
|
<TextInput
|
|
style={styles.editInput}
|
|
placeholder={t('foodAnalysisResult.editModal.fields.caloriesPlaceholder')}
|
|
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}>{t('foodAnalysisResult.editModal.actions.cancel')}</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={onSave}
|
|
style={[styles.modalSaveBtn, { backgroundColor: Colors.light.primary }]}
|
|
>
|
|
<Text style={[styles.modalSaveText, { color: Colors.light.onPrimary }]}>{t('foodAnalysisResult.editModal.actions.save')}</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',
|
|
},
|
|
}); |