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:
@@ -12,11 +12,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- **Navigation**:
|
||||
- File-based routing in `app/` directory with nested layouts
|
||||
- Tab-based navigation with custom styling and haptic feedback
|
||||
- Route constants defined in `constants/Routes.ts`
|
||||
- Route constants defined in `constants/Routes.ts`, every page should use Routes define and jump
|
||||
- **UI System**:
|
||||
- Themed components (`ThemedText`, `ThemedView`) with color scheme support
|
||||
- Custom icon system with `IconSymbol` component for iOS symbols
|
||||
- Reusable UI components in `components/ui/`
|
||||
- UI Colors in `constants/Colors.ts`
|
||||
- **Data Layer**:
|
||||
- API services in `services/` directory with centralized API client
|
||||
- AsyncStorage for local persistence
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -62,7 +62,7 @@ export default function FoodCameraScreen() {
|
||||
<HeaderBar
|
||||
title="食物拍摄"
|
||||
onBack={() => router.back()}
|
||||
transparent={true}
|
||||
backColor='#ffffff'
|
||||
/>
|
||||
<View style={styles.permissionContainer}>
|
||||
<Ionicons name="camera-outline" size={64} color="#999" />
|
||||
@@ -96,10 +96,9 @@ export default function FoodCameraScreen() {
|
||||
});
|
||||
|
||||
if (photo) {
|
||||
// TODO: 处理拍摄的照片,可以传递到下一个页面进行食物识别
|
||||
// 跳转到食物识别页面
|
||||
console.log('照片拍摄成功:', photo.uri);
|
||||
Alert.alert('拍摄成功', '照片已保存,后续会添加食物识别功能');
|
||||
// router.push(`/food-recognition?imageUri=${photo.uri}&mealType=${currentMealType}`);
|
||||
router.replace(`/food/food-recognition?imageUri=${encodeURIComponent(photo.uri)}&mealType=${currentMealType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('拍照失败:', error);
|
||||
@@ -121,8 +120,7 @@ export default function FoodCameraScreen() {
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const imageUri = result.assets[0].uri;
|
||||
console.log('从相册选择的照片:', imageUri);
|
||||
Alert.alert('选择成功', '照片已选择,后续会添加食物识别功能');
|
||||
// router.push(`/food-recognition?imageUri=${imageUri}&mealType=${currentMealType}`);
|
||||
router.push(`/food/food-recognition?imageUri=${encodeURIComponent(imageUri)}&mealType=${currentMealType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择照片失败:', error);
|
||||
@@ -149,7 +147,7 @@ export default function FoodCameraScreen() {
|
||||
title=""
|
||||
onBack={() => router.back()}
|
||||
transparent={true}
|
||||
backColor={'#333'}
|
||||
backColor={'#fff'}
|
||||
/>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
750
app/food/food-recognition.tsx
Normal file
750
app/food/food-recognition.tsx
Normal file
@@ -0,0 +1,750 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { recognizeFood } from '@/services/foodRecognition';
|
||||
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Animated,
|
||||
Easing,
|
||||
Image,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function FoodRecognitionScreen() {
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
imageUri?: string;
|
||||
mealType?: string;
|
||||
}>();
|
||||
|
||||
const { imageUri, mealType } = params;
|
||||
const { upload, uploading } = useCosUpload();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [showRecognitionProcess, setShowRecognitionProcess] = useState(false);
|
||||
const [recognitionLogs, setRecognitionLogs] = useState<string[]>([]);
|
||||
const [currentStep, setCurrentStep] = useState<'idle' | 'uploading' | 'recognizing' | 'completed' | 'failed'>('idle');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// 动画引用
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(50)).current;
|
||||
const progressAnim = useRef(new Animated.Value(0)).current;
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// 启动动画效果
|
||||
useEffect(() => {
|
||||
if (showRecognitionProcess) {
|
||||
// 进入动画
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start();
|
||||
|
||||
// 启动进度条动画
|
||||
if (currentStep === 'uploading' || currentStep === 'recognizing') {
|
||||
Animated.timing(progressAnim, {
|
||||
toValue: currentStep === 'uploading' ? 0.5 : 1,
|
||||
duration: 2000,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
}
|
||||
|
||||
// 脉冲动画
|
||||
const pulseAnimation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.1,
|
||||
duration: 800,
|
||||
easing: Easing.inOut(Easing.sin),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
easing: Easing.inOut(Easing.sin),
|
||||
useNativeDriver: true,
|
||||
})
|
||||
])
|
||||
);
|
||||
|
||||
if (currentStep === 'uploading' || currentStep === 'recognizing') {
|
||||
pulseAnimation.start();
|
||||
} else {
|
||||
pulseAnimation.stop();
|
||||
pulseAnim.setValue(1);
|
||||
}
|
||||
} else {
|
||||
fadeAnim.setValue(0);
|
||||
slideAnim.setValue(50);
|
||||
progressAnim.setValue(0);
|
||||
}
|
||||
}, [showRecognitionProcess, currentStep]);
|
||||
|
||||
const addLog = (message: string) => {
|
||||
setRecognitionLogs(prev => [...prev, message]);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!imageUri) return;
|
||||
|
||||
// 按钮动画效果
|
||||
Animated.sequence([
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 0.95,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start();
|
||||
|
||||
try {
|
||||
setShowRecognitionProcess(true);
|
||||
setRecognitionLogs([]);
|
||||
setCurrentStep('uploading');
|
||||
setIsUploading(true);
|
||||
dispatch(setLoading(true));
|
||||
|
||||
addLog('📤 正在上传图片到云端...');
|
||||
|
||||
// 上传图片到 COS
|
||||
const { url } = await upload(
|
||||
{ uri: imageUri, name: 'food-image.jpg', type: 'image/jpeg' },
|
||||
{ prefix: 'food-images/' }
|
||||
);
|
||||
|
||||
addLog('✅ 图片上传完成');
|
||||
addLog('🤖 AI大模型分析中...');
|
||||
setCurrentStep('recognizing');
|
||||
|
||||
// 调用食物识别 API
|
||||
const recognitionResult = await recognizeFood({
|
||||
imageUrls: [url]
|
||||
});
|
||||
|
||||
console.log('食物识别结果:', recognitionResult);
|
||||
|
||||
if (!recognitionResult.isFoodDetected) {
|
||||
addLog('❌ 识别失败:未检测到食物');
|
||||
addLog(`💭 ${recognitionResult.nonFoodMessage || recognitionResult.analysisText}`);
|
||||
setCurrentStep('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
addLog('✅ AI分析完成');
|
||||
addLog(`🎯 识别置信度: ${recognitionResult.confidence}%`);
|
||||
addLog(`🍽️ 识别到 ${recognitionResult.items.length} 种食物`);
|
||||
|
||||
setCurrentStep('completed');
|
||||
|
||||
// 生成唯一的识别ID
|
||||
const recognitionId = `recognition_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// 保存识别结果到 Redux
|
||||
await dispatch(saveRecognitionResult({
|
||||
id: recognitionId,
|
||||
result: recognitionResult
|
||||
}));
|
||||
|
||||
// 延迟跳转,让用户看到完成状态
|
||||
setTimeout(() => {
|
||||
router.replace(`/food/analysis-result?imageUri=${encodeURIComponent(url)}&mealType=${mealType}&recognitionId=${recognitionId}`);
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.warn('食物识别失败', error);
|
||||
addLog('❌ 识别过程出错');
|
||||
addLog(`💥 ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
setCurrentStep('failed');
|
||||
dispatch(setError('食物识别失败,请重试'));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setShowRecognitionProcess(false);
|
||||
setCurrentStep('idle');
|
||||
setRecognitionLogs([]);
|
||||
dispatch(setError(null));
|
||||
|
||||
router.back()
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (showRecognitionProcess && currentStep !== 'failed') {
|
||||
Alert.alert(
|
||||
'正在识别中',
|
||||
'识别过程尚未完成,确定要返回吗?',
|
||||
[
|
||||
{ text: '继续识别', style: 'cancel' },
|
||||
{ text: '返回', style: 'destructive', onPress: () => router.back() }
|
||||
]
|
||||
);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
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}>
|
||||
<HeaderBar
|
||||
title={showRecognitionProcess ? "食物识别" : "确认食物"}
|
||||
onBack={handleGoBack}
|
||||
/>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<ScrollView style={styles.contentContainer} showsVerticalScrollIndicator={false}>
|
||||
{!showRecognitionProcess ? (
|
||||
// 确认界面
|
||||
<>
|
||||
{/* 照片卡片 */}
|
||||
<View style={styles.photoCard}>
|
||||
<View style={styles.photoFrame}>
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={styles.photoImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
|
||||
{/* 餐次标签叠加 */}
|
||||
{mealType && (
|
||||
<View style={styles.mealTypeBadge}>
|
||||
<Text style={styles.mealTypeBadgeText}>
|
||||
{getMealTypeLabel(mealType)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* AI 识别说明卡片 */}
|
||||
<View style={styles.infoCard}>
|
||||
<View style={styles.infoHeader}>
|
||||
<View style={styles.aiIconContainer}>
|
||||
<Ionicons name="sparkles" size={20} color={Colors.light.primary} />
|
||||
</View>
|
||||
<Text style={styles.infoTitle}>智能食物识别</Text>
|
||||
</View>
|
||||
<Text style={styles.infoDescription}>
|
||||
AI 将分析您的照片,识别食物种类、估算营养成分,为您生成详细的营养分析报告
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 底部按钮区域 */}
|
||||
<View style={styles.bottomContainer}>
|
||||
<Animated.View style={[styles.confirmButtonContainer, { transform: [{ scale: scaleAnim }] }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.confirmButton}
|
||||
onPress={handleConfirm}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.confirmButtonContent}>
|
||||
<Ionicons name="scan" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
|
||||
<Text style={styles.confirmButtonText}>开始智能识别</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
// 识别过程界面
|
||||
<Animated.View style={[styles.recognitionContainer, {
|
||||
opacity: fadeAnim,
|
||||
transform: [{ translateY: slideAnim }]
|
||||
}]}>
|
||||
{/* 照片缩略图卡片 */}
|
||||
<View style={styles.thumbnailCard}>
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={styles.thumbnailImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<View style={styles.thumbnailInfo}>
|
||||
{mealType && (
|
||||
<View style={styles.thumbnailMealType}>
|
||||
<Text style={styles.thumbnailMealTypeText}>
|
||||
{getMealTypeLabel(mealType)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.thumbnailTitle}>AI 识别中...</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 进度指示卡片 */}
|
||||
<View style={styles.progressCard}>
|
||||
<View style={styles.progressHeader}>
|
||||
<Animated.View style={[styles.statusIconAnimated, { transform: [{ scale: pulseAnim }] }]}>
|
||||
<View style={[styles.statusIcon, {
|
||||
backgroundColor: currentStep === 'uploading' || currentStep === 'recognizing' ? Colors.light.primary :
|
||||
currentStep === 'completed' ? Colors.light.success :
|
||||
currentStep === 'failed' ? Colors.light.danger : Colors.light.neutral200
|
||||
}]}>
|
||||
{currentStep === 'uploading' || currentStep === 'recognizing' ? (
|
||||
<ActivityIndicator size="small" color={Colors.light.onPrimary} />
|
||||
) : currentStep === 'completed' ? (
|
||||
<Ionicons name="checkmark" size={20} color={Colors.light.onPrimary} />
|
||||
) : currentStep === 'failed' ? (
|
||||
<Ionicons name="close" size={20} color={Colors.light.onPrimary} />
|
||||
) : null}
|
||||
</View>
|
||||
</Animated.View>
|
||||
<View style={styles.progressInfo}>
|
||||
<Text style={styles.statusText}>{
|
||||
currentStep === 'idle' ? '准备中' :
|
||||
currentStep === 'uploading' ? '上传图片中' :
|
||||
currentStep === 'recognizing' ? 'AI 分析中' :
|
||||
currentStep === 'completed' ? '识别完成' :
|
||||
currentStep === 'failed' ? '识别失败' : ''
|
||||
}</Text>
|
||||
<Text style={styles.statusSubtext}>{
|
||||
currentStep === 'uploading' ? '正在将图片上传到云端处理...' :
|
||||
currentStep === 'recognizing' ? '智能模型正在分析食物成分...' :
|
||||
currentStep === 'completed' ? '即将跳转到分析结果页面' :
|
||||
currentStep === 'failed' ? '请检查网络连接或重新拍照' : ''
|
||||
}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 进度条 */}
|
||||
{(currentStep === 'uploading' || currentStep === 'recognizing') && (
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View style={styles.progressBarBackground}>
|
||||
<Animated.View
|
||||
style={[styles.progressBarFill, {
|
||||
width: progressAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0%', '100%'],
|
||||
})
|
||||
}]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 识别日志卡片 */}
|
||||
<View style={styles.logCard}>
|
||||
<View style={styles.logHeader}>
|
||||
<Ionicons name="document-text-outline" size={18} color={Colors.light.primary} />
|
||||
<Text style={styles.logTitle}>进度</Text>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.logScrollView}
|
||||
contentContainerStyle={styles.logContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{recognitionLogs.map((log, index) => (
|
||||
<Animated.View
|
||||
key={index}
|
||||
style={[styles.logItem, {
|
||||
opacity: fadeAnim,
|
||||
transform: [{ translateX: slideAnim }]
|
||||
}]}
|
||||
>
|
||||
<Text style={styles.logText}>{log}</Text>
|
||||
</Animated.View>
|
||||
))}
|
||||
{recognitionLogs.length === 0 && (
|
||||
<Text style={styles.logPlaceholder}>等待处理开始...</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 重试按钮 */}
|
||||
{currentStep === 'failed' && (
|
||||
<View style={styles.bottomContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={handleRetry}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="refresh" size={18} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
|
||||
<Text style={styles.retryButtonText}>返回重新拍照</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 获取餐次标签
|
||||
function getMealTypeLabel(mealType: string): string {
|
||||
const mealTypeMap: Record<string, string> = {
|
||||
breakfast: '早餐',
|
||||
lunch: '午餐',
|
||||
dinner: '晚餐',
|
||||
snack: '加餐',
|
||||
};
|
||||
return mealTypeMap[mealType] || '未知餐次';
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: Colors.light.pageBackgroundEmphasis,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 16,
|
||||
},
|
||||
|
||||
// 照片卡片样式
|
||||
photoCard: {
|
||||
backgroundColor: Colors.light.card,
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
photoFrame: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: Colors.light.neutral100,
|
||||
position: 'relative',
|
||||
},
|
||||
photoImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
mealTypeBadge: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: 'rgba(122, 90, 248, 0.95)',
|
||||
borderRadius: 20,
|
||||
backdropFilter: 'blur(10px)',
|
||||
},
|
||||
mealTypeBadgeText: {
|
||||
color: Colors.light.onPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
|
||||
// 信息卡片样式
|
||||
infoCard: {
|
||||
backgroundColor: Colors.light.card,
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginBottom: 24,
|
||||
shadowColor: Colors.light.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 16,
|
||||
elevation: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.light.heroSurfaceTint,
|
||||
},
|
||||
infoHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
aiIconContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: Colors.light.heroSurfaceTint,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: Colors.light.text,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
infoDescription: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textSecondary,
|
||||
lineHeight: 22,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
|
||||
// 按钮样式
|
||||
bottomContainer: {
|
||||
paddingBottom: 40,
|
||||
paddingTop: 8,
|
||||
},
|
||||
confirmButtonContainer: {},
|
||||
confirmButton: {
|
||||
backgroundColor: Colors.light.primary,
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 32,
|
||||
borderRadius: 28,
|
||||
alignItems: 'center',
|
||||
shadowColor: Colors.light.primary,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
confirmButtonContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
confirmButtonText: {
|
||||
color: Colors.light.onPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
|
||||
// 识别过程容器
|
||||
recognitionContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// 缩略图卡片
|
||||
thumbnailCard: {
|
||||
backgroundColor: Colors.light.card,
|
||||
borderRadius: 20,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
thumbnailImage: {
|
||||
width: 70,
|
||||
height: 70,
|
||||
borderRadius: 16,
|
||||
},
|
||||
thumbnailInfo: {
|
||||
flex: 1,
|
||||
marginLeft: 16,
|
||||
},
|
||||
thumbnailMealType: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: Colors.light.primary,
|
||||
borderRadius: 16,
|
||||
marginBottom: 6,
|
||||
},
|
||||
thumbnailMealTypeText: {
|
||||
color: Colors.light.onPrimary,
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
thumbnailTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.text,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
|
||||
// 进度卡片
|
||||
progressCard: {
|
||||
backgroundColor: Colors.light.card,
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 16,
|
||||
elevation: 6,
|
||||
},
|
||||
progressHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
statusIconAnimated: {},
|
||||
statusIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
progressInfo: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: Colors.light.text,
|
||||
marginBottom: 4,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
statusSubtext: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textSecondary,
|
||||
lineHeight: 20,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
|
||||
// 进度条
|
||||
progressBarContainer: {
|
||||
marginTop: 20,
|
||||
},
|
||||
progressBarBackground: {
|
||||
width: '100%',
|
||||
height: 8,
|
||||
backgroundColor: Colors.light.neutral100,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBarFill: {
|
||||
height: '100%',
|
||||
backgroundColor: Colors.light.primary,
|
||||
borderRadius: 4,
|
||||
},
|
||||
|
||||
// 日志卡片
|
||||
logCard: {
|
||||
backgroundColor: Colors.light.card,
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
flex: 1,
|
||||
minHeight: 200,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
logHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
logTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: Colors.light.text,
|
||||
marginLeft: 8,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
logScrollView: {
|
||||
flex: 1,
|
||||
backgroundColor: Colors.light.heroSurfaceTint,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
logContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
logItem: {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
logText: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.text,
|
||||
lineHeight: 22,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
logPlaceholder: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textMuted,
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
marginTop: 40,
|
||||
},
|
||||
|
||||
// 重试按钮
|
||||
retryButton: {
|
||||
backgroundColor: Colors.light.primary,
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 32,
|
||||
borderRadius: 28,
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
shadowColor: Colors.light.primary,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: Colors.light.onPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
|
||||
// 通用样式
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: Colors.light.textMuted,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@@ -27,13 +27,13 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F
|
||||
|
||||
const handlePhotoRecognition = () => {
|
||||
onClose();
|
||||
router.push(`/food-camera?mealType=${mealType}`);
|
||||
router.push(`/food/camera?mealType=${mealType}`);
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'scan',
|
||||
title: '扫描',
|
||||
title: 'AI拍照识别',
|
||||
icon: '📷',
|
||||
backgroundColor: '#4FC3F7',
|
||||
onPress: handlePhotoRecognition,
|
||||
@@ -66,7 +66,7 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F
|
||||
<View style={styles.container}>
|
||||
<BlurView intensity={80} tint="light" style={styles.blurContainer}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>日常记录</Text>
|
||||
<Text style={styles.title}>记录方式</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.menuGrid}>
|
||||
|
||||
@@ -120,7 +120,7 @@ export function NutritionRadarCard({
|
||||
<View style={styles.cardRightContainer}>
|
||||
<Text style={styles.cardSubtitle}>更新: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}</Text>
|
||||
<TouchableOpacity style={styles.addButton} onPress={handleAddFood}>
|
||||
<Ionicons name="add" size={12} color="#514b4bff" />
|
||||
<Ionicons name="add" size={16} color="#514b4bff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -362,10 +362,10 @@ const styles = StyleSheet.create({
|
||||
fontSize: 24,
|
||||
},
|
||||
addButton: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#e5e8ecff',
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#c1c1eeff',
|
||||
marginLeft: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
34
services/foodRecognition.ts
Normal file
34
services/foodRecognition.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { api } from '@/services/api';
|
||||
|
||||
export type FoodNutritionData = {
|
||||
proteinGrams?: number;
|
||||
carbohydrateGrams?: number;
|
||||
fatGrams?: number;
|
||||
fiberGrams?: number;
|
||||
};
|
||||
|
||||
export type FoodConfirmationOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
foodName: string;
|
||||
portion: string;
|
||||
calories: number;
|
||||
mealType: string;
|
||||
nutritionData: FoodNutritionData;
|
||||
};
|
||||
|
||||
export type FoodRecognitionRequest = {
|
||||
imageUrls: string[];
|
||||
};
|
||||
|
||||
export type FoodRecognitionResponse = {
|
||||
items: FoodConfirmationOption[];
|
||||
analysisText: string;
|
||||
confidence: number;
|
||||
isFoodDetected: boolean;
|
||||
nonFoodMessage?: string;
|
||||
};
|
||||
|
||||
export async function recognizeFood(request: FoodRecognitionRequest): Promise<FoodRecognitionResponse> {
|
||||
return api.post<FoodRecognitionResponse>('/ai-coach/food-recognition', request);
|
||||
}
|
||||
111
store/foodRecognitionSlice.ts
Normal file
111
store/foodRecognitionSlice.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { type FoodRecognitionResponse } from '@/services/foodRecognition';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
// 食物识别状态类型定义
|
||||
export interface FoodRecognitionState {
|
||||
// 按ID存储的识别结果
|
||||
recognitionResults: Record<string, FoodRecognitionResponse>;
|
||||
|
||||
// 当前正在处理的识别ID
|
||||
currentRecognitionId: string | null;
|
||||
|
||||
// 加载状态
|
||||
loading: boolean;
|
||||
|
||||
// 错误信息
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// 初始状态
|
||||
const initialState: FoodRecognitionState = {
|
||||
recognitionResults: {},
|
||||
currentRecognitionId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const foodRecognitionSlice = createSlice({
|
||||
name: 'foodRecognition',
|
||||
initialState,
|
||||
reducers: {
|
||||
// 设置加载状态
|
||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.loading = action.payload;
|
||||
if (action.payload) {
|
||||
state.error = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 设置错误信息
|
||||
setError: (state, action: PayloadAction<string | null>) => {
|
||||
state.error = action.payload;
|
||||
state.loading = false;
|
||||
},
|
||||
|
||||
// 保存识别结果
|
||||
saveRecognitionResult: (state, action: PayloadAction<{ id: string; result: FoodRecognitionResponse }>) => {
|
||||
const { id, result } = action.payload;
|
||||
state.recognitionResults[id] = result;
|
||||
state.currentRecognitionId = id;
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
},
|
||||
|
||||
// 设置当前识别ID
|
||||
setCurrentRecognitionId: (state, action: PayloadAction<string>) => {
|
||||
state.currentRecognitionId = action.payload;
|
||||
},
|
||||
|
||||
// 清除指定的识别结果
|
||||
clearRecognitionResult: (state, action: PayloadAction<string>) => {
|
||||
const id = action.payload;
|
||||
delete state.recognitionResults[id];
|
||||
if (state.currentRecognitionId === id) {
|
||||
state.currentRecognitionId = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 清除所有识别结果
|
||||
clearAllRecognitionResults: (state) => {
|
||||
state.recognitionResults = {};
|
||||
state.currentRecognitionId = null;
|
||||
state.error = null;
|
||||
},
|
||||
|
||||
// 清除错误
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Action creators
|
||||
export const {
|
||||
setLoading,
|
||||
setError,
|
||||
saveRecognitionResult,
|
||||
setCurrentRecognitionId,
|
||||
clearRecognitionResult,
|
||||
clearAllRecognitionResults,
|
||||
clearError,
|
||||
} = foodRecognitionSlice.actions;
|
||||
|
||||
// Selectors
|
||||
export const selectFoodRecognitionResult = (id: string) => (state: { foodRecognition: FoodRecognitionState }) =>
|
||||
state.foodRecognition.recognitionResults[id] || null;
|
||||
|
||||
export const selectCurrentFoodRecognitionResult = (state: { foodRecognition: FoodRecognitionState }) => {
|
||||
const currentId = state.foodRecognition.currentRecognitionId;
|
||||
return currentId ? state.foodRecognition.recognitionResults[currentId] || null : null;
|
||||
};
|
||||
|
||||
export const selectFoodRecognitionLoading = (state: { foodRecognition: FoodRecognitionState }) =>
|
||||
state.foodRecognition.loading;
|
||||
|
||||
export const selectFoodRecognitionError = (state: { foodRecognition: FoodRecognitionState }) =>
|
||||
state.foodRecognition.error;
|
||||
|
||||
export const selectCurrentRecognitionId = (state: { foodRecognition: FoodRecognitionState }) =>
|
||||
state.foodRecognition.currentRecognitionId;
|
||||
|
||||
export default foodRecognitionSlice.reducer;
|
||||
@@ -3,6 +3,7 @@ import challengeReducer from './challengeSlice';
|
||||
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
||||
import exerciseLibraryReducer from './exerciseLibrarySlice';
|
||||
import foodLibraryReducer from './foodLibrarySlice';
|
||||
import foodRecognitionReducer from './foodRecognitionSlice';
|
||||
import goalsReducer from './goalsSlice';
|
||||
import healthReducer from './healthSlice';
|
||||
import moodReducer from './moodSlice';
|
||||
@@ -56,6 +57,7 @@ export const store = configureStore({
|
||||
scheduleExercise: scheduleExerciseReducer,
|
||||
exerciseLibrary: exerciseLibraryReducer,
|
||||
foodLibrary: foodLibraryReducer,
|
||||
foodRecognition: foodRecognitionReducer,
|
||||
workout: workoutReducer,
|
||||
water: waterReducer,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user