Files
digital-pilates/components/model/food/CreateCustomFoodModal.tsx

738 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Dimensions,
Keyboard,
KeyboardAvoidingView,
Modal,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View
} from 'react-native';
import { useAppSelector } from '@/hooks/redux';
import { useCosUpload } from '@/hooks/useCosUpload';
import { Colors } from '@/constants/Colors';
export interface CreateCustomFoodModalProps {
visible: boolean;
onClose: () => void;
onSave: (foodData: CustomFoodData) => void;
}
export interface CustomFoodData {
name: string;
defaultAmount: number;
caloriesUnit: string;
calories: number;
imageUrl?: string;
protein?: number;
fat?: number;
carbohydrate?: number;
}
export function CreateCustomFoodModal({
visible,
onClose,
onSave
}: CreateCustomFoodModalProps) {
const [foodName, setFoodName] = useState('');
const [defaultAmount, setDefaultAmount] = useState('100');
const [caloriesUnit, setCaloriesUnit] = useState('千卡');
const [calories, setCalories] = useState('100');
const [imageUrl, setImageUrl] = useState<string>('');
const [protein, setProtein] = useState('0');
const [fat, setFat] = useState('0');
const [carbohydrate, setCarbohydrate] = useState('0');
const [keyboardHeight, setKeyboardHeight] = useState(0);
// 获取用户ID和上传功能
const accountProfile = useAppSelector((s) => (s as any)?.user?.profile as any);
const userId: string | undefined = useMemo(() => {
return (
accountProfile?.userId ||
accountProfile?.id ||
accountProfile?._id ||
accountProfile?.uid ||
undefined
) as string | undefined;
}, [accountProfile]);
const { upload, uploading } = useCosUpload();
// 键盘监听
useEffect(() => {
const keyboardDidShowListener = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
(e) => {
setKeyboardHeight(e.endCoordinates.height);
}
);
const keyboardDidHideListener = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
() => {
setKeyboardHeight(0);
}
);
return () => {
keyboardDidShowListener?.remove();
keyboardDidHideListener?.remove();
};
}, []);
// 重置表单
useEffect(() => {
if (visible) {
setFoodName('');
setDefaultAmount('100');
setCaloriesUnit('千卡');
setCalories('100');
setImageUrl('');
setProtein('0');
setFat('0');
setCarbohydrate('0');
}
}, [visible]);
// 选择热量单位
// 选择图片
const handleSelectImage = async () => {
try {
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
if (!libGranted) {
Alert.alert('权限不足', '需要相册权限以选择照片');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: true,
quality: 0.9,
aspect: [1, 1],
mediaTypes: ['images'],
base64: false,
});
if (!result.canceled) {
const asset = result.assets?.[0];
if (!asset?.uri) return;
// 直接上传到 COS成功后写入 URL
try {
const { url } = await upload(
{ uri: asset.uri, name: asset.fileName || 'food.jpg', type: asset.mimeType || 'image/jpeg' },
{ prefix: 'foods/', userId }
);
setImageUrl(url);
} catch (e) {
console.warn('上传照片失败', e);
Alert.alert('上传失败', '照片上传失败,请重试');
}
}
} catch (e) {
Alert.alert('发生错误', '选择照片失败,请重试');
}
};
// 计算实际热量预览
const actualCalories = Math.round((parseFloat(calories) || 0) * (parseFloat(defaultAmount) || 0) / 100);
// 保存自定义食物
const handleSave = () => {
if (!foodName.trim()) {
Alert.alert('提示', '请输入食物名称');
return;
}
if (!calories.trim() || parseFloat(calories) <= 0) {
Alert.alert('提示', '请输入有效的热量值');
return;
}
const foodData: CustomFoodData = {
name: foodName.trim(),
defaultAmount: parseFloat(defaultAmount) || 100,
caloriesUnit,
calories: parseFloat(calories) || 0,
imageUrl: imageUrl || undefined,
protein: parseFloat(protein) || undefined,
fat: parseFloat(fat) || undefined,
carbohydrate: parseFloat(carbohydrate) || undefined,
};
onSave(foodData);
onClose();
};
return (
<Modal
visible={visible}
animationType="fade"
transparent={true}
onRequestClose={onClose}
presentationStyle="overFullScreen"
>
<View style={styles.overlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardAvoidingView}
>
<View style={[
styles.modalContainer,
keyboardHeight > 0 && {
height: screenHeight - keyboardHeight,
maxHeight: screenHeight - keyboardHeight,
}
]}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
flexGrow: 1,
paddingBottom: keyboardHeight > 0 ? 20 : 0
}}
>
{/* 头部 */}
<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.saveButton,
(!foodName.trim() || !calories.trim()) && styles.saveButtonDisabled
]}
onPress={handleSave}
disabled={!foodName.trim() || !calories.trim()}
>
<Text style={[
styles.saveButtonText,
(!foodName.trim() || !calories.trim()) && styles.saveButtonTextDisabled
]}></Text>
</TouchableOpacity>
</View>
{/* 效果预览区域 */}
<View style={styles.previewSection}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.previewCard}>
<View style={styles.previewContent}>
{imageUrl ? (
<Image style={styles.previewImage} source={{ uri: imageUrl }} />
) : (
<View style={styles.previewImagePlaceholder}>
<Ionicons name="restaurant" size={20} color="#999" />
</View>
)}
<View style={styles.previewInfo}>
<Text style={styles.previewName}>
{foodName || '食物名称'}
</Text>
<Text style={styles.previewCalories}>
{actualCalories}{caloriesUnit}/{defaultAmount}g
</Text>
</View>
</View>
</View>
</View>
{/* 基本信息 */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.requiredIndicator}>*</Text>
</View>
<View style={styles.sectionCard}>
{/* 食物名称和单位 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={foodName}
onChangeText={setFoodName}
placeholder="例如,汉堡"
placeholderTextColor="#A0A0A0"
/>
</View>
</View>
</View>
{/* 默认数量 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={defaultAmount}
onChangeText={setDefaultAmount}
keyboardType="numeric"
placeholder="100"
placeholderTextColor="#A0A0A0"
/>
<Text style={styles.unitText}>g</Text>
</View>
</View>
</View>
{/* 食物热量 */}
<View style={[styles.inputRowContainer, { marginBottom: 0 }]}>
<Text style={styles.inputRowLabel}></Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={calories}
onChangeText={setCalories}
keyboardType="numeric"
placeholder="100"
placeholderTextColor="#A0A0A0"
/>
<Text style={styles.unitText}></Text>
</View>
</View>
</View>
</View>
</View>
{/* 可选信息 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.sectionCard}>
{/* 照片 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<View style={styles.inputRowContent}>
<TouchableOpacity
style={styles.modernImageSelector}
onPress={handleSelectImage}
disabled={uploading}
>
{imageUrl ? (
<Image style={styles.selectedImage} source={{ uri: imageUrl }} />
) : (
<View style={styles.modernImagePlaceholder}>
<Ionicons name="camera" size={28} color="#A0A0A0" />
<Text style={styles.imagePlaceholderText}></Text>
</View>
)}
{uploading && (
<View style={styles.imageLoadingOverlay}>
<ActivityIndicator size="large" color="#FFFFFF" />
</View>
)}
</TouchableOpacity>
</View>
</View>
{/* 蛋白质 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={protein}
onChangeText={setProtein}
keyboardType="numeric"
placeholder="0"
placeholderTextColor="#A0A0A0"
/>
<Text style={styles.unitText}></Text>
</View>
</View>
</View>
{/* 脂肪 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={fat}
onChangeText={setFat}
keyboardType="numeric"
placeholder="0"
placeholderTextColor="#A0A0A0"
/>
<Text style={styles.unitText}></Text>
</View>
</View>
</View>
{/* 碳水化合物 */}
<View style={[styles.inputRowContainer, { marginBottom: 0 }]}>
<Text style={styles.inputRowLabel}></Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={carbohydrate}
onChangeText={setCarbohydrate}
keyboardType="numeric"
placeholder="0"
placeholderTextColor="#A0A0A0"
/>
<Text style={styles.unitText}></Text>
</View>
</View>
</View>
</View>
</View>
</ScrollView>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
);
}
const { height: screenHeight } = Dimensions.get('window');
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
keyboardAvoidingView: {
flex: 1,
},
modalContainer: {
flex: 1,
backgroundColor: '#FFFFFF',
marginTop: 50,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
},
scrollView: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 16,
},
backButton: {
padding: 4,
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: '#333',
flex: 1,
textAlign: 'center',
marginHorizontal: 20,
},
saveButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
saveButtonDisabled: {
opacity: 0.5,
},
saveButtonText: {
fontSize: 16,
color: Colors.light.primary,
fontWeight: '500',
},
saveButtonTextDisabled: {
color: Colors.light.textMuted,
},
previewSection: {
paddingHorizontal: 16,
paddingBottom: 16,
},
previewCard: {
backgroundColor: '#F8F9FA',
borderRadius: 12,
padding: 16,
marginTop: 8,
},
previewContent: {
flexDirection: 'row',
alignItems: 'center',
},
previewImage: {
width: 32,
height: 32,
borderRadius: 4,
},
previewImagePlaceholder: {
width: 32,
height: 32,
borderRadius: 4,
backgroundColor: '#E5E5E5',
alignItems: 'center',
justifyContent: 'center',
},
previewInfo: {
flex: 1,
marginLeft: 12,
},
previewName: {
fontSize: 16,
fontWeight: '500',
color: '#333',
marginBottom: 2,
},
previewCalories: {
fontSize: 14,
color: '#666',
},
section: {
paddingHorizontal: 16,
paddingVertical: 12,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
},
sectionTitle: {
fontSize: 14,
color: '#333',
marginLeft: 8
},
requiredIndicator: {
fontSize: 16,
color: '#FF4444',
marginLeft: 4,
},
inputGroup: {
marginBottom: 20,
},
inputRowGroup: {
flexDirection: 'row',
gap: 12,
marginBottom: 20,
},
inputRowItem: {
flex: 1,
},
inputLabel: {
fontSize: 14,
color: '#666',
fontWeight: '500',
},
modernTextInput: {
flex: 1,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 16,
marginLeft: 20,
color: '#333',
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
numberInputContainer: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 12,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
modernNumberInput: {
flex: 1,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 16,
color: '#333',
textAlign: 'right',
},
unitText: {
fontSize: 14,
color: '#666',
paddingRight: 16,
minWidth: 40,
textAlign: 'center',
},
modernSelectButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1.5,
borderColor: '#E8E8E8',
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
selectButtonText: {
fontSize: 14,
color: 'gray',
fontWeight: '500',
},
modernImageSelector: {
alignSelf: 'flex-end',
borderRadius: 16,
overflow: 'hidden',
},
selectedImage: {
width: 80,
height: 80,
borderRadius: 16,
},
modernImagePlaceholder: {
width: 80,
height: 80,
borderRadius: 16,
backgroundColor: '#F8F8F8',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1.5,
borderColor: '#E8E8E8',
borderStyle: 'dashed',
},
imagePlaceholderText: {
fontSize: 12,
color: '#A0A0A0',
marginTop: 4,
fontWeight: '500',
},
nutritionRow: {
flexDirection: 'row',
gap: 12,
marginBottom: 20,
},
nutritionItem: {
flex: 1,
},
// 保留旧样式以防兼容性问题
textInput: {
borderWidth: 1,
borderColor: '#E5E5E5',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
color: '#333',
backgroundColor: '#FFFFFF',
},
numberInput: {
flex: 1,
borderWidth: 1,
borderColor: '#E5E5E5',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
color: '#333',
backgroundColor: '#FFFFFF',
textAlign: 'right',
},
inputWithUnit: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
inputUnit: {
fontSize: 16,
color: '#666',
minWidth: 30,
},
selectButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderColor: '#E5E5E5',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
backgroundColor: '#FFFFFF',
},
imageSelector: {
alignSelf: 'flex-end',
borderRadius: 12,
overflow: 'hidden',
},
imagePlaceholder: {
width: 60,
height: 60,
borderRadius: 12,
backgroundColor: '#F0F0F0',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: '#E5E5E5',
},
disclaimer: {
paddingHorizontal: 16,
paddingVertical: 20,
paddingBottom: 40,
},
disclaimerText: {
fontSize: 12,
color: '#999',
lineHeight: 18,
},
sectionCard: {
backgroundColor: '#F8F9FA',
borderRadius: 12,
padding: 16,
marginTop: 8,
},
// 新增行布局样式
inputRowContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
inputRowLabel: {
fontSize: 14,
color: '#666',
fontWeight: '500',
width: 80,
marginRight: 12,
},
inputRowContent: {
flex: 1,
},
imageLoadingOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 16,
},
});