This commit is contained in:
richarjiang
2025-08-28 17:42:57 +08:00
parent 5a59508b88
commit 6551757ca8
7 changed files with 586 additions and 18 deletions

View File

@@ -4,9 +4,10 @@ import { NutritionSummary } from '@/services/dietRecords';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useMemo } from 'react'; import React, { useMemo, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { RadarCategory, RadarChart } from './RadarChart'; import { RadarCategory, RadarChart } from './RadarChart';
import { FoodItem, FoodLibraryModal } from './model/food/FoodLibraryModal';
export type NutritionRadarCardProps = { export type NutritionRadarCardProps = {
nutritionSummary: NutritionSummary | null; nutritionSummary: NutritionSummary | null;
@@ -37,6 +38,8 @@ export function NutritionRadarCard({
resetToken, resetToken,
onMealPress onMealPress
}: NutritionRadarCardProps) { }: NutritionRadarCardProps) {
const [showFoodLibrary, setShowFoodLibrary] = useState(false);
const [currentMealType, setCurrentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
const radarValues = useMemo(() => { const radarValues = useMemo(() => {
// 基于推荐日摄入量计算分数 // 基于推荐日摄入量计算分数
const recommendations = { const recommendations = {
@@ -111,15 +114,24 @@ export function NutritionRadarCard({
router.push(ROUTES.NUTRITION_RECORDS); router.push(ROUTES.NUTRITION_RECORDS);
}; };
const handleAddFood = () => {
setShowFoodLibrary(true);
};
const handleSelectFood = (food: FoodItem) => {
console.log('选择了食物:', food);
// 这里可以添加将食物添加到营养记录的逻辑
};
return ( return (
<TouchableOpacity style={styles.card} onPress={handleNavigateToRecords} activeOpacity={0.8}> <TouchableOpacity style={styles.card} onPress={handleNavigateToRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}></Text>
<View style={styles.cardRightContainer}> <View style={styles.cardRightContainer}>
<Text style={styles.cardSubtitle}>: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}</Text> <Text style={styles.cardSubtitle}>: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}</Text>
<View style={styles.addButton}> <TouchableOpacity style={styles.addButton} onPress={handleAddFood}>
<Ionicons name="add" size={12} color="#FFFFFF" /> <Ionicons name="add" size={12} color="#FFFFFF" />
</View> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -180,6 +192,14 @@ export function NutritionRadarCard({
</View> </View>
</View> </View>
</View> </View>
{/* 食物库弹窗 */}
<FoodLibraryModal
visible={showFoodLibrary}
onClose={() => setShowFoodLibrary(false)}
onSelectFood={handleSelectFood}
mealType={currentMealType}
/>
</TouchableOpacity> </TouchableOpacity>
); );
} }

View File

@@ -0,0 +1,445 @@
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

@@ -0,0 +1,109 @@
# 食物库弹窗组件
## 功能概述
食物库弹窗组件 (`FoodLibraryModal`) 是一个完整的食物选择界面,用户可以通过分类浏览和搜索来选择食物。
## 主要特性
- ✅ 完全按照设计图还原的UI界面
- ✅ 左侧分类导航,右侧食物列表
- ✅ 搜索功能
- ✅ 食物信息显示(名称、卡路里、单位)
- ✅ 餐次选择(早餐、午餐、晚餐、加餐)
- ✅ 自定义和收藏功能预留
## 使用方式
### 1. 在营养卡片中使用
`statistics.tsx` 页面的营养摄入分析卡片右上角,点击绿色的加号按钮即可打开食物库弹窗。
### 2. 组件集成
```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 图标库
- 完全响应式设计,适配不同屏幕尺寸

View File

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

View File

@@ -65,10 +65,8 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
rightThreshold={40} rightThreshold={40}
overshootRight={false} overshootRight={false}
> >
<TouchableOpacity <View
style={styles.recordCard} style={styles.recordCard}
onPress={() => onPress?.(record)}
activeOpacity={0.7}
> >
<View style={styles.recordHeader}> <View style={styles.recordHeader}>
<Text style={styles.recordDateTime}> <Text style={styles.recordDateTime}>
@@ -103,7 +101,7 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
</View> </View>
)} )}
</View> </View>
</TouchableOpacity> </View>
</Swipeable> </Swipeable>
); );
}; };

View File

@@ -350,7 +350,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.5; MARKETING_VERSION = 1.0.6;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -388,7 +388,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.5; MARKETING_VERSION = 1.0.6;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -463,10 +463,7 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = "$(inherited) ";
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -521,10 +518,7 @@
); );
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = "$(inherited) ";
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
USE_HERMES = false; USE_HERMES = false;

View File

@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.5</string> <string>1.0.6</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>