feat: 支持食物详情弹窗
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import type { FoodItem } from '@/components/model/food/FoodDetailModal';
|
||||
import { FoodDetailModal } from '@/components/model/food/FoodDetailModal';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
@@ -12,15 +14,6 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
// 食物数据类型
|
||||
export interface FoodItem {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
calories: number;
|
||||
unit: string; // 单位,如 "100克"
|
||||
}
|
||||
|
||||
// 食物分类类型
|
||||
export interface FoodCategory {
|
||||
id: string;
|
||||
@@ -28,14 +21,6 @@ export interface FoodCategory {
|
||||
foods: FoodItem[];
|
||||
}
|
||||
|
||||
// 食物库弹窗属性
|
||||
export interface FoodLibraryModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSelectFood: (food: FoodItem) => void;
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
}
|
||||
|
||||
// 模拟食物数据
|
||||
const FOOD_DATA: FoodCategory[] = [
|
||||
{
|
||||
@@ -118,14 +103,15 @@ const MEAL_TYPE_MAP = {
|
||||
snack: '加餐'
|
||||
};
|
||||
|
||||
export function FoodLibraryModal({
|
||||
visible,
|
||||
onClose,
|
||||
onSelectFood,
|
||||
mealType = 'breakfast'
|
||||
}: FoodLibraryModalProps) {
|
||||
export default function FoodLibraryScreen() {
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{ mealType?: string }>();
|
||||
const mealType = (params.mealType as 'breakfast' | 'lunch' | 'dinner' | 'snack') || 'breakfast';
|
||||
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState('common');
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedFood, setSelectedFood] = useState<FoodItem | null>(null);
|
||||
const [showFoodDetail, setShowFoodDetail] = useState(false);
|
||||
|
||||
// 获取当前选中的分类
|
||||
const selectedCategory = FOOD_DATA.find(cat => cat.id === selectedCategoryId);
|
||||
@@ -135,118 +121,152 @@ export function FoodLibraryModal({
|
||||
food.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
) || [];
|
||||
|
||||
// 处理食物选择
|
||||
// 处理食物选择 - 显示详情弹窗
|
||||
const handleSelectFood = (food: FoodItem) => {
|
||||
onSelectFood(food);
|
||||
onClose();
|
||||
console.log('选择食物:', food);
|
||||
setSelectedFood(food);
|
||||
setShowFoodDetail(true);
|
||||
console.log('设置弹窗状态:', {
|
||||
showFoodDetail: true,
|
||||
selectedFood: food,
|
||||
foodName: food.name,
|
||||
foodId: food.id
|
||||
});
|
||||
};
|
||||
|
||||
// 处理食物保存
|
||||
const handleSaveFood = (food: FoodItem, amount: number, unit: string) => {
|
||||
// 这里可以处理保存逻辑,比如添加到营养记录
|
||||
console.log('保存食物:', food, amount, unit);
|
||||
setShowFoodDetail(false);
|
||||
router.back(); // 返回上一页
|
||||
};
|
||||
|
||||
// 关闭详情弹窗
|
||||
const handleCloseFoodDetail = () => {
|
||||
setShowFoodDetail(false);
|
||||
setSelectedFood(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="#F8F9FA" />
|
||||
<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.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color="#333" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>食物库</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.customButton}
|
||||
onPress={() => {
|
||||
const testFood: FoodItem = {
|
||||
id: 'test',
|
||||
name: '测试食物',
|
||||
emoji: '🍎',
|
||||
calories: 100,
|
||||
unit: '100克'
|
||||
};
|
||||
handleSelectFood(testFood);
|
||||
}}
|
||||
>
|
||||
<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.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.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 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>
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={() => handleSelectFood(food)}
|
||||
>
|
||||
<Ionicons name="add" size={20} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</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>
|
||||
{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 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>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
|
||||
<TouchableOpacity style={styles.recordButton}>
|
||||
<Text style={styles.recordButtonText}>记录</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 食物详情弹窗 */}
|
||||
<FoodDetailModal
|
||||
visible={showFoodDetail}
|
||||
food={selectedFood}
|
||||
onClose={handleCloseFoodDetail}
|
||||
onSave={handleSaveFood}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { router } from 'expo-router';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { RadarCategory, RadarChart } from './RadarChart';
|
||||
import { FoodItem, FoodLibraryModal } from './model/food/FoodLibraryModal';
|
||||
|
||||
|
||||
export type NutritionRadarCardProps = {
|
||||
nutritionSummary: NutritionSummary | null;
|
||||
@@ -38,7 +38,6 @@ export function NutritionRadarCard({
|
||||
resetToken,
|
||||
onMealPress
|
||||
}: NutritionRadarCardProps) {
|
||||
const [showFoodLibrary, setShowFoodLibrary] = useState(false);
|
||||
const [currentMealType, setCurrentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
||||
const radarValues = useMemo(() => {
|
||||
// 基于推荐日摄入量计算分数
|
||||
@@ -115,12 +114,7 @@ export function NutritionRadarCard({
|
||||
};
|
||||
|
||||
const handleAddFood = () => {
|
||||
setShowFoodLibrary(true);
|
||||
};
|
||||
|
||||
const handleSelectFood = (food: FoodItem) => {
|
||||
console.log('选择了食物:', food);
|
||||
// 这里可以添加将食物添加到营养记录的逻辑
|
||||
router.push(`/food-library?mealType=${currentMealType}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -193,13 +187,7 @@ export function NutritionRadarCard({
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 食物库弹窗 */}
|
||||
<FoodLibraryModal
|
||||
visible={showFoodLibrary}
|
||||
onClose={() => setShowFoodLibrary(false)}
|
||||
onSelectFood={handleSelectFood}
|
||||
mealType={currentMealType}
|
||||
/>
|
||||
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
37
components/model/food/DEBUG.md
Normal file
37
components/model/food/DEBUG.md
Normal 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. 确认弹窗显示完整内容
|
||||
|
||||
## 预期结果
|
||||
- 弹窗正常显示食物信息
|
||||
- 营养数据正确计算和显示
|
||||
- 单位选择功能正常
|
||||
- 保存功能正常工作
|
||||
425
components/model/food/FoodDetailModal.tsx
Normal file
425
components/model/food/FoodDetailModal.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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样式和交互逻辑都得到了保留
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export const ROUTES = {
|
||||
|
||||
// 营养相关路由
|
||||
NUTRITION_RECORDS: '/nutrition/records',
|
||||
FOOD_LIBRARY: '/food-library',
|
||||
|
||||
// 体重记录相关路由
|
||||
WEIGHT_RECORDS: '/weight-records',
|
||||
|
||||
Reference in New Issue
Block a user