feat: 支持食物详情弹窗

This commit is contained in:
richarjiang
2025-08-28 19:24:22 +08:00
parent 6551757ca8
commit c15a9176f4
7 changed files with 664 additions and 233 deletions

View File

@@ -0,0 +1,37 @@
# FoodDetailModal 弹窗问题修复记录
## 问题描述
FoodDetailModal弹窗打开后没有内容显示
## 问题分析
1. **导入路径问题**: 原来FoodDetailModal试图从已删除的FoodLibraryModal导入FoodItem类型
2. **类型定义冲突**: FoodItem类型在多个文件中重复定义
3. **Modal嵌套问题**: 已通过将食物库改为页面解决
## 修复步骤
### 1. 修复类型导入问题
- 在FoodDetailModal.tsx中重新定义FoodItem接口
- 在food-library.tsx中从FoodDetailModal导入FoodItem类型
- 更新food模块的导出文件
### 2. 添加调试信息
- 在FoodDetailModal中添加console.log来跟踪数据传递
- 在food-library.tsx中添加调试信息来确认食物选择逻辑
### 3. 确保正确的数据流
```
用户点击食物 -> handleSelectFood -> setSelectedFood + setShowFoodDetail -> FoodDetailModal接收props
```
## 测试方法
1. 打开食物库页面
2. 点击任意食物的加号按钮
3. 检查控制台输出,确认数据传递正确
4. 确认弹窗显示完整内容
## 预期结果
- 弹窗正常显示食物信息
- 营养数据正确计算和显示
- 单位选择功能正常
- 保存功能正常工作

View File

@@ -0,0 +1,425 @@
import { Ionicons } from '@expo/vector-icons';
import React, { useEffect, useState } from 'react';
import {
Dimensions,
Keyboard,
KeyboardAvoidingView,
Modal,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
// 食物数据类型定义
export interface FoodItem {
id: string;
name: string;
emoji: string;
calories: number;
unit: string; // 单位,如 "100克"
}
// 营养信息接口
interface NutritionInfo {
protein: number; // 蛋白质(克)
fat: number; // 脂肪(克)
carbs: number; // 碳水化合物(克)
}
// 单位选项
const UNIT_OPTIONS = [
{ id: 'gram', name: '克', ratio: 1 },
{ id: 'small', name: '小份', ratio: 0.8 },
{ id: 'medium', name: '中份', ratio: 1.2 },
{ id: 'large', name: '大份', ratio: 1.5 },
];
// 食物详情弹窗属性
export interface FoodDetailModalProps {
visible: boolean;
food: FoodItem | null;
onClose: () => void;
onSave: (food: FoodItem, amount: number, unit: string) => void;
}
// 模拟营养数据
const getNutritionInfo = (foodId: string): NutritionInfo => {
const nutritionData: Record<string, NutritionInfo> = {
'5': { protein: 0.8, fat: 0.6, carbs: 14.5 }, // 猕猴桃
'1': { protein: 0.2, fat: 0.0, carbs: 0.1 }, // 咖啡
'2': { protein: 12.8, fat: 11.1, carbs: 0.7 }, // 荷包蛋
'3': { protein: 13.3, fat: 8.8, carbs: 2.8 }, // 鸡蛋
'4': { protein: 1.4, fat: 0.2, carbs: 22.8 }, // 香蕉
'6': { protein: 0.2, fat: 0.2, carbs: 13.8 }, // 苹果
'7': { protein: 0.7, fat: 0.3, carbs: 7.7 }, // 草莓
'8': { protein: 6.6, fat: 4.6, carbs: 22.7 }, // 蛋烧麦
'9': { protein: 2.6, fat: 0.3, carbs: 25.9 }, // 米饭
'10': { protein: 4.0, fat: 1.2, carbs: 22.8 }, // 玉米
};
return nutritionData[foodId] || { protein: 0, fat: 0, carbs: 0 };
};
export function FoodDetailModal({
visible,
food,
onClose,
onSave
}: FoodDetailModalProps) {
const [amount, setAmount] = useState('100');
const [selectedUnit, setSelectedUnit] = useState(UNIT_OPTIONS[0]);
const [isFavorite, setIsFavorite] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
// 键盘监听
useEffect(() => {
const keyboardDidShowListener = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
(e) => {
console.log('键盘显示,高度:', e.endCoordinates.height);
setKeyboardHeight(e.endCoordinates.height);
}
);
const keyboardDidHideListener = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
() => {
console.log('键盘隐藏');
setKeyboardHeight(0);
}
);
return () => {
keyboardDidShowListener?.remove();
keyboardDidHideListener?.remove();
};
}, []);
// 重置状态
useEffect(() => {
if (visible && food) {
console.log('FoodDetailModal: 接收到食物数据:', food);
setAmount('100');
setSelectedUnit(UNIT_OPTIONS[0]);
setIsFavorite(false);
}
}, [visible, food]);
// 调试信息
console.log('FoodDetailModal render:', {
visible,
food: food?.name,
foodId: food?.id,
hasFood: !!food
});
if (!food) {
console.log('FoodDetailModal: food为空不渲染内容');
return null;
}
const nutrition = getNutritionInfo(food.id);
const amountNum = parseFloat(amount) || 0;
const unitRatio = selectedUnit.ratio;
const finalAmount = amountNum * unitRatio;
// 计算实际营养值
const actualCalories = Math.round((food.calories * finalAmount) / 100);
const actualProtein = ((nutrition.protein * finalAmount) / 100).toFixed(1);
const actualFat = ((nutrition.fat * finalAmount) / 100).toFixed(1);
const actualCarbs = ((nutrition.carbs * finalAmount) / 100).toFixed(1);
const handleSave = () => {
onSave(food, finalAmount, selectedUnit.name);
onClose();
};
console.log('FoodDetailModal 即将渲染 Modal:', { visible, food: food.name });
return (
<Modal
visible={visible}
animationType="fade"
transparent={true}
onRequestClose={onClose}
presentationStyle="overFullScreen"
>
<View style={styles.overlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'position' : 'height'}
style={styles.keyboardAvoidingView}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}
>
<View style={[
styles.modalContainer,
keyboardHeight > 0 && {
height: screenHeight - keyboardHeight - 100, // 减去键盘高度和更多安全距离
maxHeight: screenHeight - keyboardHeight - 100,
minHeight: Math.min(400, screenHeight * 0.4) // 确保最小高度但不超过屏幕40%
}
]}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
flexGrow: 1,
paddingBottom: keyboardHeight > 0 ? 20 : 0 // 键盘弹出时增加底部间距
}}
scrollEnabled={true}
bounces={false}
>
{/* 头部 */}
<View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
<TouchableOpacity style={styles.correctButton}>
<Text style={styles.correctButtonText}></Text>
</TouchableOpacity>
</View>
{/* 食物信息 */}
<View style={styles.foodHeader}>
<View style={styles.foodInfo}>
<Text style={styles.foodEmoji}>{food.emoji}</Text>
<Text style={styles.foodName}>{food.name}</Text>
<Ionicons name="chevron-forward" size={20} color="#999" />
</View>
<TouchableOpacity
onPress={() => setIsFavorite(!isFavorite)}
style={styles.favoriteButton}
>
<Ionicons
name={isFavorite ? "star" : "star-outline"}
size={24}
color={isFavorite ? "#FFD700" : "#CCC"}
/>
</TouchableOpacity>
</View>
{/* 营养信息 */}
<View style={styles.nutritionContainer}>
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionValue}>{actualCalories}</Text>
</View>
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionValue}>{actualProtein}</Text>
</View>
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionValue}>{actualFat}</Text>
</View>
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionValue}>{actualCarbs}</Text>
</View>
</View>
{/* 数量输入 */}
<View style={styles.amountContainer}>
<TextInput
style={styles.amountInput}
value={amount}
onChangeText={setAmount}
keyboardType="numeric"
selectTextOnFocus
/>
</View>
{/* 单位选择 */}
<View style={styles.unitContainer}>
{UNIT_OPTIONS.map((unit) => (
<TouchableOpacity
key={unit.id}
style={[
styles.unitOption,
selectedUnit.id === unit.id && styles.unitOptionActive
]}
onPress={() => setSelectedUnit(unit)}
>
<Text style={[
styles.unitText,
selectedUnit.id === unit.id && styles.unitTextActive
]}>
{unit.name}
</Text>
</TouchableOpacity>
))}
</View>
{/* 保存按钮 */}
<TouchableOpacity style={styles.saveButton} onPress={handleSave}>
<Text style={styles.saveButtonText}></Text>
</TouchableOpacity>
</ScrollView>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
);
}
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
alignItems: 'center',
},
keyboardAvoidingView: {
width: '100%',
justifyContent: 'flex-end',
alignItems: 'center',
},
modalContainer: {
width: screenWidth,
backgroundColor: '#FFFFFF',
borderRadius: 20, // 四周都设置圆角
// maxHeight: screenHeight * 0.75, // 最大高度为屏幕高度的75%
// minHeight: screenHeight * 0.5, // 最小高度为屏幕高度的50%
shadowColor: '#000',
shadowOffset: {
width: 0,
height: -5,
},
shadowOpacity: 0.25,
shadowRadius: 20,
elevation: 10,
overflow: 'hidden', // 确保内容不会溢出
},
scrollView: {
flexGrow: 1,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: screenWidth > 400 ? 24 : 16, // 根据屏幕宽度调整内边距
paddingTop: 16,
paddingBottom: 8,
},
closeButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#F5F5F5',
justifyContent: 'center',
alignItems: 'center',
},
correctButton: {
paddingHorizontal: 16,
paddingVertical: 8,
},
correctButtonText: {
fontSize: 16,
color: '#4CAF50',
fontWeight: '500',
},
foodHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: screenWidth > 400 ? 24 : 16,
paddingVertical: 12,
},
foodInfo: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
foodEmoji: {
fontSize: 40,
marginRight: 12,
},
foodName: {
fontSize: 20,
fontWeight: '600',
color: '#333',
marginRight: 8,
},
favoriteButton: {
padding: 8,
},
nutritionContainer: {
flexDirection: 'row',
paddingHorizontal: screenWidth > 400 ? 24 : 16,
paddingVertical: 16,
justifyContent: 'space-between',
},
nutritionItem: {
alignItems: 'center',
flex: 1,
},
nutritionLabel: {
fontSize: 14,
color: '#666',
marginBottom: 8,
},
nutritionValue: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
amountContainer: {
alignItems: 'center',
paddingVertical: 16,
},
amountInput: {
backgroundColor: '#E8F5E8',
borderRadius: 12,
paddingHorizontal: 24,
paddingVertical: 16,
fontSize: 24,
fontWeight: '600',
color: '#4CAF50',
textAlign: 'center',
minWidth: 120,
},
unitContainer: {
flexDirection: 'row',
justifyContent: 'center',
paddingHorizontal: screenWidth > 400 ? 24 : 16,
paddingBottom: 20,
gap: screenWidth > 400 ? 16 : 12, // 根据屏幕宽度调整间距
},
unitOption: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: '#F5F5F5',
minWidth: 60,
alignItems: 'center',
},
unitOptionActive: {
backgroundColor: '#4CAF50',
},
unitText: {
fontSize: 14,
color: '#666',
fontWeight: '500',
},
unitTextActive: {
color: '#FFFFFF',
},
saveButton: {
backgroundColor: '#4CAF50',
marginHorizontal: screenWidth > 400 ? 24 : 16,
marginBottom: 16,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
saveButtonText: {
fontSize: 18,
fontWeight: '600',
color: '#FFFFFF',
},
});

View File

@@ -1,445 +0,0 @@
import { Ionicons } from '@expo/vector-icons';
import React, { useState } from 'react';
import {
Modal,
SafeAreaView,
ScrollView,
StatusBar,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
// 食物数据类型
export interface FoodItem {
id: string;
name: string;
emoji: string;
calories: number;
unit: string; // 单位,如 "100克"
}
// 食物分类类型
export interface FoodCategory {
id: string;
name: string;
foods: FoodItem[];
}
// 食物库弹窗属性
export interface FoodLibraryModalProps {
visible: boolean;
onClose: () => void;
onSelectFood: (food: FoodItem) => void;
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
}
// 模拟食物数据
const FOOD_DATA: FoodCategory[] = [
{
id: 'common',
name: '常见',
foods: [
{ id: '1', name: '无糖美式咖啡', emoji: '☕', calories: 1, unit: '100克' },
{ id: '2', name: '荷包蛋(油煎)', emoji: '🍳', calories: 195, unit: '100克' },
{ id: '3', name: '鸡蛋', emoji: '🥚', calories: 139, unit: '100克' },
{ id: '4', name: '香蕉', emoji: '🍌', calories: 93, unit: '100克' },
{ id: '5', name: '猕猴桃', emoji: '🥝', calories: 61, unit: '100克' },
{ id: '6', name: '苹果', emoji: '🍎', calories: 53, unit: '100克' },
{ id: '7', name: '草莓', emoji: '🍓', calories: 32, unit: '100克' },
{ id: '8', name: '蛋烧麦', emoji: '🥟', calories: 157, unit: '100克' },
{ id: '9', name: '米饭', emoji: '🍚', calories: 116, unit: '100克' },
{ id: '10', name: '鲜玉米', emoji: '🌽', calories: 112, unit: '100克' },
]
},
{
id: 'custom',
name: '自定义',
foods: []
},
{
id: 'favorite',
name: '收藏',
foods: []
},
{
id: 'fruits',
name: '水果蔬菜',
foods: [
{ id: '11', name: '苹果', emoji: '🍎', calories: 53, unit: '100克' },
{ id: '12', name: '香蕉', emoji: '🍌', calories: 93, unit: '100克' },
{ id: '13', name: '草莓', emoji: '🍓', calories: 32, unit: '100克' },
{ id: '14', name: '猕猴桃', emoji: '🥝', calories: 61, unit: '100克' },
]
},
{
id: 'meat',
name: '肉蛋奶',
foods: [
{ id: '15', name: '鸡蛋', emoji: '🥚', calories: 139, unit: '100克' },
{ id: '16', name: '荷包蛋(油煎)', emoji: '🍳', calories: 195, unit: '100克' },
]
},
{
id: 'beans',
name: '豆类坚果',
foods: []
},
{
id: 'drinks',
name: '零食饮料',
foods: [
{ id: '17', name: '无糖美式咖啡', emoji: '☕', calories: 1, unit: '100克' },
]
},
{
id: 'staple',
name: '主食',
foods: [
{ id: '18', name: '米饭', emoji: '🍚', calories: 116, unit: '100克' },
{ id: '19', name: '鲜玉米', emoji: '🌽', calories: 112, unit: '100克' },
{ id: '20', name: '蛋烧麦', emoji: '🥟', calories: 157, unit: '100克' },
]
},
{
id: 'vegetables',
name: '菜肴',
foods: []
}
];
// 餐次映射
const MEAL_TYPE_MAP = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐'
};
export function FoodLibraryModal({
visible,
onClose,
onSelectFood,
mealType = 'breakfast'
}: FoodLibraryModalProps) {
const [selectedCategoryId, setSelectedCategoryId] = useState('common');
const [searchText, setSearchText] = useState('');
// 获取当前选中的分类
const selectedCategory = FOOD_DATA.find(cat => cat.id === selectedCategoryId);
// 过滤食物列表
const filteredFoods = selectedCategory?.foods.filter(food =>
food.name.toLowerCase().includes(searchText.toLowerCase())
) || [];
// 处理食物选择
const handleSelectFood = (food: FoodItem) => {
onSelectFood(food);
onClose();
};
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="#F8F9FA" />
{/* 头部 */}
<View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color="#333" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<TouchableOpacity style={styles.customButton}>
<Text style={styles.customButtonText}></Text>
</TouchableOpacity>
</View>
{/* 搜索框 */}
<View style={styles.searchContainer}>
<Ionicons name="search" size={20} color="#999" style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
placeholder="搜索食物"
value={searchText}
onChangeText={setSearchText}
placeholderTextColor="#999"
/>
</View>
{/* 主要内容区域 - 卡片样式 */}
<View style={styles.mainContentCard}>
<View style={styles.mainContent}>
{/* 左侧分类导航 */}
<View style={styles.categoryContainer}>
<ScrollView showsVerticalScrollIndicator={false}>
{FOOD_DATA.map((category) => (
<TouchableOpacity
key={category.id}
style={[
styles.categoryItem,
selectedCategoryId === category.id && styles.categoryItemActive
]}
onPress={() => setSelectedCategoryId(category.id)}
>
<Text style={[
styles.categoryText,
selectedCategoryId === category.id && styles.categoryTextActive
]}>
{category.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
{/* 右侧食物列表 */}
<View style={styles.foodContainer}>
<ScrollView showsVerticalScrollIndicator={false}>
{filteredFoods.map((food) => (
<View key={food.id} style={styles.foodItem}>
<View style={styles.foodInfo}>
<Text style={styles.foodEmoji}>{food.emoji}</Text>
<View style={styles.foodDetails}>
<Text style={styles.foodName}>{food.name}</Text>
<Text style={styles.foodCalories}>
{food.calories}/{food.unit}
</Text>
</View>
</View>
<TouchableOpacity
style={styles.addButton}
onPress={() => handleSelectFood(food)}
>
<Ionicons name="add" size={20} color="#666" />
</TouchableOpacity>
</View>
))}
{filteredFoods.length === 0 && (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}></Text>
</View>
)}
</ScrollView>
</View>
</View>
</View>
{/* 底部餐次选择和记录按钮 */}
<View style={styles.bottomContainer}>
<View style={styles.mealSelector}>
<View style={styles.mealIndicator} />
<Text style={styles.mealText}>{MEAL_TYPE_MAP[mealType]}</Text>
<Ionicons name="chevron-down" size={16} color="#333" />
</View>
<TouchableOpacity style={styles.recordButton}>
<Text style={styles.recordButtonText}></Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F8F9FA',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#F8F9FA',
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
},
backButton: {
padding: 4,
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: '#333',
},
customButton: {
paddingHorizontal: 8,
paddingVertical: 4,
},
customButtonText: {
fontSize: 16,
color: '#4CAF50',
fontWeight: '500',
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFF',
marginHorizontal: 16,
marginVertical: 12,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
searchIcon: {
marginRight: 8,
},
searchInput: {
flex: 1,
fontSize: 16,
color: '#333',
},
mainContentCard: {
flex: 1,
marginHorizontal: 16,
marginBottom: 16,
backgroundColor: '#FFFFFF',
borderRadius: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
overflow: 'hidden',
},
mainContent: {
flex: 1,
flexDirection: 'row',
},
categoryContainer: {
width: 100,
backgroundColor: 'transparent',
borderRightWidth: 1,
borderRightColor: '#E5E5E5',
},
categoryItem: {
paddingVertical: 16,
paddingHorizontal: 12,
alignItems: 'center',
},
categoryItemActive: {
backgroundColor: '#F0F9FF',
borderRightWidth: 2,
borderRightColor: '#4CAF50',
},
categoryText: {
fontSize: 14,
color: '#666',
textAlign: 'center',
},
categoryTextActive: {
color: '#4CAF50',
fontWeight: '500',
},
foodContainer: {
flex: 1,
backgroundColor: 'transparent',
},
foodItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
},
foodInfo: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
foodEmoji: {
fontSize: 32,
marginRight: 12,
},
foodDetails: {
flex: 1,
},
foodName: {
fontSize: 16,
color: '#333',
fontWeight: '500',
marginBottom: 2,
},
foodCalories: {
fontSize: 14,
color: '#999',
},
addButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F5F5F5',
alignItems: 'center',
justifyContent: 'center',
},
emptyContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
},
emptyText: {
fontSize: 16,
color: '#999',
},
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: '#4CAF50',
paddingHorizontal: 24,
paddingVertical: 10,
borderRadius: 20,
},
recordButtonText: {
fontSize: 16,
color: '#FFF',
fontWeight: '500',
},
});

View File

@@ -1,109 +1,68 @@
# 食物库弹窗组件
# 食物库页面实现说明
## 功能概述
## 概述
食物库弹窗组件 (`FoodLibraryModal`) 是一个完整的食物选择界面,用户可以通过分类浏览和搜索来选择食物
已成功将食物库弹窗模式改造为页面模式解决了React Native中Modal嵌套的问题
## 主要特性
## 实现内容
- ✅ 完全按照设计图还原的UI界
- ✅ 左侧分类导航,右侧食物列表
- ✅ 搜索功能
- ✅ 食物信息显示(名称、卡路里、单位)
- ✅ 餐次选择(早餐、午餐、晚餐、加餐)
- ✅ 自定义和收藏功能预留
### 1. 创建食物库页
- **文件位置**: `app/food-library.tsx`
- **功能**: 完整的食物库界面,包含搜索、分类导航、食物列表等功能
- **特点**:
- 支持URL参数传递餐次类型 (`mealType`)
- 使用Expo Router进行页面导航
- 在页面中可以正常使用FoodDetailModal弹窗
### 2. 修改营养卡片组件
- **文件**: `components/NutritionRadarCard.tsx`
- **修改内容**:
- 移除了FoodLibraryModal的导入和使用
- 将加号按钮的点击事件改为页面导航
- 使用 `router.push('/food-library?mealType=${currentMealType}')` 进行导航
### 3. 更新路由配置
- **文件**: `constants/Routes.ts`
- **新增**: `FOOD_LIBRARY: '/food-library'` 路由常量
### 4. 清理旧文件
- 删除了 `components/model/food/FoodLibraryModal.tsx`
- 更新了 `components/model/food/index.ts` 导出文件
## 使用方式
### 1. 在营养卡片中使用
1.statistics页面的营养卡片中点击右上角的加号按钮
2. 会导航到食物库页面 (`/food-library`)
3. 在食物库页面中点击食物项的加号按钮
4. 会弹出食物详情弹窗 (FoodDetailModal)
5. 在详情弹窗中可以选择单位和数量点击保存后返回statistics页面
`statistics.tsx` 页面的营养摄入分析卡片右上角,点击绿色的加号按钮即可打开食物库弹窗。
## 技术优势
### 2. 组件集成
1. **解决Modal嵌套问题**: 避免了React Native中Modal嵌套的兼容性问题
2. **更好的用户体验**: 页面导航比弹窗更符合移动端的交互习惯
3. **键盘适配**: FoodDetailModal中的键盘适配功能可以正常工作
4. **代码结构清晰**: 页面和弹窗职责分离,代码更易维护
```tsx
import { FoodLibraryModal, FoodItem } from '@/components/model/food/FoodLibraryModal';
## 页面功能
// 在组件中使用
const [showFoodLibrary, setShowFoodLibrary] = useState(false);
### 食物库页面功能
- ✅ 搜索食物
- ✅ 分类导航(常见、自定义、收藏等)
- ✅ 食物列表展示
- ✅ 餐次显示
- ✅ 返回导航
const handleSelectFood = (food: FoodItem) => {
console.log('选择了食物:', food);
// 处理食物选择逻辑
};
### 食物详情弹窗功能
- ✅ 食物信息展示
- ✅ 营养成分显示
- ✅ 单位选择(克、小份、中份、大份)
- ✅ 数量输入
- ✅ 键盘适配
- ✅ 保存功能
<FoodLibraryModal
visible={showFoodLibrary}
onClose={() => setShowFoodLibrary(false)}
onSelectFood={handleSelectFood}
mealType="breakfast"
/>
```
## 注意事项
## 组件结构
```
components/
model/
food/
├── FoodLibraryModal.tsx # 主要弹窗组件
└── index.ts # 导出文件
```
## 数据结构
### FoodItem
```tsx
interface FoodItem {
id: string;
name: string;
emoji: string;
calories: number;
unit: string; // 如 "100克"
}
```
### FoodCategory
```tsx
interface FoodCategory {
id: string;
name: string;
foods: FoodItem[];
}
```
## 食物分类
- 常见
- 自定义
- 收藏
- 水果蔬菜
- 肉蛋奶
- 豆类坚果
- 零食饮料
- 主食
- 菜肴
## 已实现的功能
1. **分类浏览** - 左侧导航可切换不同食物分类
2. **搜索功能** - 顶部搜索框支持食物名称搜索
3. **食物选择** - 点击右侧加号按钮选择食物
4. **餐次指示** - 底部显示当前餐次(早餐、午餐、晚餐、加餐)
5. **UI动画** - 模态弹窗的滑入滑出动画
## 待扩展功能
1. **自定义食物** - 用户可以添加自定义食物
2. **收藏功能** - 用户可以收藏常用食物
3. **营养详情** - 显示更详细的营养成分信息
4. **数据持久化** - 将选择的食物保存到营养记录中
5. **餐次切换** - 底部餐次选择器的交互功能
## 技术实现
- 使用 React Native Modal 实现全屏弹窗
- 使用 ScrollView 实现分类和食物列表的滚动
- 使用 TouchableOpacity 实现交互反馈
- 使用 Ionicons 图标库
- 完全响应式设计,适配不同屏幕尺寸
1. 食物库页面使用URL参数传递餐次类型确保从不同餐次点击加号时能正确显示对应的餐次
2. FoodDetailModal仍然是弹窗形式但现在是在页面中使用避免了嵌套问题
3. 所有原有的UI样式和交互逻辑都得到了保留

View File

@@ -1,2 +1,3 @@
export { FoodLibraryModal } from './FoodLibraryModal';
export type { FoodCategory, FoodItem, FoodLibraryModalProps } from './FoodLibraryModal';
export { FoodDetailModal } from './FoodDetailModal';
export type { FoodDetailModalProps, FoodItem } from './FoodDetailModal';