feat: add food camera and recognition features
- Implemented FoodCameraScreen for capturing food images with meal type selection. - Created FoodRecognitionScreen for processing and recognizing food images. - Added Redux slice for managing food recognition state and results. - Integrated image upload functionality to cloud storage. - Enhanced UI components for better user experience during food recognition. - Updated FloatingFoodOverlay to navigate to the new camera screen. - Added food recognition service for API interaction. - Improved styling and layout for various components.
This commit is contained in:
885
app/food/analysis-result.tsx
Normal file
885
app/food/analysis-result.tsx
Normal file
@@ -0,0 +1,885 @@
|
||||
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 { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
|
||||
// 模拟食物摄入列表数据
|
||||
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;
|
||||
}>();
|
||||
|
||||
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;
|
||||
|
||||
// 从 Redux 获取识别结果
|
||||
const recognitionResult = useAppSelector(recognitionId ? selectFoodRecognitionResult(recognitionId) : () => null);
|
||||
|
||||
// 处理识别结果数据
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [recognitionResult]);
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
// 获取随机食物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) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<HeaderBar
|
||||
title="分析结果"
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>未找到图片</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||||
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()}
|
||||
transparent={true}
|
||||
/>
|
||||
|
||||
<ScrollView style={styles.scrollContainer} showsVerticalScrollIndicator={false}>
|
||||
{/* 食物主图 */}
|
||||
<View style={styles.imageContainer}>
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={styles.foodImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
|
||||
{/* 识别信息气泡 */}
|
||||
<View style={styles.descriptionBubble}>
|
||||
<Text style={styles.descriptionText}>
|
||||
{recognitionResult ?
|
||||
`置信度: ${recognitionResult.confidence}%` :
|
||||
'2025年9月4日'
|
||||
}
|
||||
</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"
|
||||
/>
|
||||
<NutritionRing
|
||||
label="脂肪"
|
||||
value={totalFat.toFixed(1)}
|
||||
unit="克"
|
||||
percentage={Math.min(100, fatPercentage)}
|
||||
color="#FF9800"
|
||||
/>
|
||||
<NutritionRing
|
||||
label="碳水"
|
||||
value={totalCarbohydrate.toFixed(1)}
|
||||
unit="克"
|
||||
percentage={Math.min(100, carbohydratePercentage)}
|
||||
color="#2196F3"
|
||||
/>
|
||||
</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>
|
||||
<TouchableOpacity style={styles.editButton}>
|
||||
<Ionicons name="create-outline" size={16} color="#666" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={() => handleRemoveFood(item.id)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={16} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 营养圆环组件
|
||||
function NutritionRing({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
percentage,
|
||||
color
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
unit?: string;
|
||||
percentage: number;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.nutritionRingContainer}>
|
||||
<View style={styles.ringWrapper}>
|
||||
<CircularRing
|
||||
size={60}
|
||||
strokeWidth={4}
|
||||
trackColor="#E2E8F0"
|
||||
progressColor={color}
|
||||
progress={percentage / 100}
|
||||
showCenterText={false}
|
||||
/>
|
||||
<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,
|
||||
marginTop: -60,
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user