Files
digital-pilates/app/food/camera.tsx
richarjiang bca6670390 Add Chinese translations for medication management and personal settings
- Introduced new translation files for medication, personal, and weight management in Chinese.
- Updated the main index file to include the new translation modules.
- Enhanced the medication type definitions to include 'ointment'.
- Refactored workout type labels to utilize i18n for better localization support.
- Improved sleep quality descriptions and recommendations with i18n integration.
2025-11-28 17:29:51 +08:00

763 lines
22 KiB
TypeScript
Raw 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 { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons';
import { CameraType, CameraView, useCameraPermissions } from 'expo-camera';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
Dimensions,
Modal,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
export default function FoodCameraScreen() {
const { t } = useI18n();
const insets = useSafeAreaInsets();
const router = useRouter();
const params = useLocalSearchParams<{ mealType?: string }>();
const cameraRef = useRef<CameraView>(null);
const { ensureLoggedIn } = useAuthGuard();
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
const colors = Colors[scheme];
const [currentMealType, setCurrentMealType] = useState<MealType>(
(params.mealType as MealType) || 'dinner'
);
const [facing, setFacing] = useState<CameraType>('back');
const [permission, requestPermission] = useCameraPermissions();
const [showInstructionModal, setShowInstructionModal] = useState(false);
const [isCapturing, setIsCapturing] = useState(false);
// 餐次选择选项
const mealOptions = [
{ key: 'breakfast' as const, label: t('nutritionRecords.mealTypes.breakfast'), icon: '☀️' },
{ key: 'lunch' as const, label: t('nutritionRecords.mealTypes.lunch'), icon: '🌤️' },
{ key: 'dinner' as const, label: t('nutritionRecords.mealTypes.dinner'), icon: '🌙' },
{ key: 'snack' as const, label: t('nutritionRecords.mealTypes.snack'), icon: '🍎' },
];
// 计算固定的相机高度
const cameraHeight = useMemo(() => {
const { height: screenHeight } = Dimensions.get('window');
// 计算固定占用的高度
const headerHeight = insets.top + 40; // HeaderBar 高度
const topMetaHeight = 12 + 28 + 26 + 16 + 6; // topMeta 区域
const shotsRowHeight = 12 + 88; // MealType 区域
const bottomBarHeight = 12 + 86 + 10 + Math.max(insets.bottom, 20); // bottomBar 区域
const margins = 12 + 12; // cameraCard 的上下边距
// 可用于相机的高度
const availableHeight = screenHeight - headerHeight - topMetaHeight - shotsRowHeight - bottomBarHeight - margins;
// 确保最小高度为 300最大不超过屏幕的 55%
return Math.max(300, Math.min(availableHeight, screenHeight * 0.55));
}, [insets.top, insets.bottom]);
if (!permission) {
return (
<View style={styles.container}>
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
<HeaderBar
title={t('foodCamera.title')}
onBack={() => router.back()}
transparent={true}
/>
<View style={[styles.loadingContainer, { paddingTop: insets.top + 40 }]}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</View>
);
}
if (!permission.granted) {
return (
<View style={[styles.container, { backgroundColor: '#f8fafc' }]}>
<HeaderBar
title={t('foodCamera.title')}
onBack={() => router.back()}
transparent
/>
<View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}>
<Ionicons name="camera-outline" size={64} color="#94a3b8" style={{ marginBottom: 20 }} />
<Text style={styles.permissionTitle}>
{t('foodCamera.permission.title')}
</Text>
<Text style={styles.permissionTip}>
{t('foodCamera.permission.description')}
</Text>
<TouchableOpacity
style={[styles.permissionBtn, { backgroundColor: colors.primary }]}
onPress={requestPermission}
>
<Text style={styles.permissionBtnText}>
{t('foodCamera.permission.button')}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
// 切换相机前后摄像头
function toggleCameraFacing() {
setFacing(current => (current === 'back' ? 'front' : 'back'));
}
// 拍摄照片
const takePicture = async () => {
if (cameraRef.current && !isCapturing) {
setIsCapturing(true);
try {
const photo = await cameraRef.current.takePictureAsync({
quality: 0.8,
base64: false,
});
if (photo) {
console.log('照片拍摄成功:', photo.uri);
const isLoggedIn = await ensureLoggedIn();
if (isLoggedIn) {
router.replace(`/food/food-recognition?imageUri=${encodeURIComponent(photo.uri)}&mealType=${currentMealType}`);
}
}
} catch (error) {
console.error('拍照失败:', error);
Alert.alert(t('foodCamera.alerts.captureFailed.title'), t('foodCamera.alerts.captureFailed.message'));
} finally {
setIsCapturing(false);
}
}
};
// 从相册选择照片
const pickImageFromGallery = async () => {
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
const imageUri = result.assets[0].uri;
console.log('从相册选择的照片:', imageUri);
const isLoggedIn = await ensureLoggedIn();
if (isLoggedIn) {
router.push(`/food/food-recognition?imageUri=${encodeURIComponent(imageUri)}&mealType=${currentMealType}`);
}
}
} catch (error) {
console.error('选择照片失败:', error);
Alert.alert(t('foodCamera.alerts.pickFailed.title'), t('foodCamera.alerts.pickFailed.message'));
}
};
// 餐次选择
const handleMealTypeChange = (mealType: MealType) => {
setCurrentMealType(mealType);
};
return (
<View style={styles.container}>
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
<HeaderBar
title={t('foodCamera.title')}
onBack={() => router.back()}
transparent={true}
right={
<TouchableOpacity
onPress={() => setShowInstructionModal(true)}
activeOpacity={0.7}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.infoButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="help-circle-outline" size={24} color="#333" />
</GlassView>
) : (
<View style={[styles.infoButton, styles.fallbackInfoButton]}>
<Ionicons name="help-circle-outline" size={24} color="#333" />
</View>
)}
</TouchableOpacity>
}
/>
<View style={{ height: insets.top + 40 }} />
{/* Top Meta Info */}
<View style={styles.topMeta}>
<View style={styles.metaBadge}>
<Text style={styles.metaBadgeText}>{t('foodCamera.hint')}</Text>
</View>
<Text style={styles.metaTitle}>
{t('nutritionRecords.listTitle')}
</Text>
<Text style={styles.metaSubtitle}>
{t('foodCamera.guide.description')}
</Text>
</View>
{/* Camera Card */}
<View style={styles.cameraCard}>
<View style={[styles.cameraFrame, { height: cameraHeight }]}>
<CameraView
ref={cameraRef}
style={styles.cameraView}
facing={facing}
/>
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.1)']}
style={styles.cameraOverlay}
/>
{/* Viewfinder Overlay */}
<View style={styles.viewfinderOverlay}>
<View style={[styles.corner, styles.topLeft]} />
<View style={[styles.corner, styles.topRight]} />
<View style={[styles.corner, styles.bottomLeft]} />
<View style={[styles.corner, styles.bottomRight]} />
</View>
</View>
</View>
{/* Meal Type Selector (Replacing Shots Row) */}
<View style={styles.shotsRow}>
{mealOptions.map((option) => {
const active = currentMealType === option.key;
return (
<TouchableOpacity
key={option.key}
onPress={() => handleMealTypeChange(option.key)}
activeOpacity={0.7}
style={[styles.shotCard, active && styles.shotCardActive]}
>
<Text style={styles.mealTypeIcon}>{option.icon}</Text>
<Text style={[styles.shotLabel, active && styles.shotLabelActive]}>
{option.label}
</Text>
</TouchableOpacity>
);
})}
</View>
{/* Bottom Actions */}
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 20) }]}>
<View style={styles.bottomActions}>
{/* Album Button */}
<TouchableOpacity
onPress={pickImageFromGallery}
disabled={isCapturing}
activeOpacity={0.7}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.secondaryBtn}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.6)"
isInteractive={true}
>
<Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}>
{t('foodCamera.buttons.album')}
</Text>
</GlassView>
) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}>
{t('foodCamera.buttons.album')}
</Text>
</View>
)}
</TouchableOpacity>
{/* Capture Button */}
<TouchableOpacity
onPress={takePicture}
disabled={isCapturing}
activeOpacity={0.8}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.captureBtn}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.8)"
isInteractive={true}
>
<View style={styles.captureOuterRing}>
{isCapturing ? (
<ActivityIndicator color={colors.primary} />
) : (
<View style={styles.captureInner} />
)}
</View>
</GlassView>
) : (
<View style={[styles.captureBtn, styles.fallbackCaptureBtn]}>
<View style={styles.captureOuterRing}>
{isCapturing ? (
<ActivityIndicator color={colors.primary} />
) : (
<View style={styles.captureInner} />
)}
</View>
</View>
)}
</TouchableOpacity>
{/* Flip Button */}
<TouchableOpacity
onPress={toggleCameraFacing}
disabled={isCapturing}
activeOpacity={0.7}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.secondaryBtn}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.6)"
isInteractive={true}
>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}>
{t('foodCamera.buttons.capture')}
</Text>
</GlassView>
) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}>
{t('foodCamera.buttons.capture')}
</Text>
</View>
)}
</TouchableOpacity>
</View>
</View>
{/* Instruction Modal */}
<Modal
visible={showInstructionModal}
animationType="fade"
transparent={true}
onRequestClose={() => setShowInstructionModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.instructionModal}>
<Text style={styles.instructionTitle}>{t('foodCamera.guide.title')}</Text>
<View style={styles.exampleContainer}>
{/* Good Example */}
<View style={styles.exampleItem}>
<View style={styles.exampleImagePlaceholder}>
<View style={styles.checkmarkContainer}>
<Ionicons name="checkmark" size={20} color="#FFF" />
</View>
<Image
style={styles.exampleImage}
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/function/food-right.jpeg' }}
contentFit="cover"
cachePolicy={'memory-disk'}
/>
</View>
<Text style={styles.exampleText}>{t('foodCamera.guide.good')}</Text>
</View>
{/* Bad Example */}
<View style={styles.exampleItem}>
<View style={styles.exampleImagePlaceholder}>
<View style={styles.crossContainer}>
<Ionicons name="close" size={20} color="#FFF" />
</View>
<Image
style={styles.exampleImage}
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/function/food-wrong.jpeg' }}
contentFit="cover"
cachePolicy={'memory-disk'}
/>
</View>
<Text style={styles.exampleText}>{t('foodCamera.guide.bad')}</Text>
</View>
</View>
<Text style={styles.instructionDescription}>
{t('foodCamera.guide.description')}
</Text>
<TouchableOpacity
style={styles.knowButton}
onPress={() => setShowInstructionModal(false)}
>
<Text style={styles.knowButtonText}>{t('foodCamera.guide.button')}</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
topMeta: {
paddingHorizontal: 20,
paddingTop: 12,
gap: 6,
},
metaBadge: {
alignSelf: 'flex-start',
backgroundColor: '#e0f2fe',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
},
metaBadgeText: {
color: '#0369a1',
fontWeight: '700',
fontSize: 12,
},
metaTitle: {
fontSize: 22,
fontWeight: '700',
color: '#0f172a',
},
metaSubtitle: {
fontSize: 14,
color: '#475569',
},
cameraCard: {
marginHorizontal: 20,
marginTop: 12,
borderRadius: 24,
overflow: 'hidden',
shadowColor: '#0f172a',
shadowOpacity: 0.12,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
},
cameraFrame: {
borderRadius: 24,
overflow: 'hidden',
backgroundColor: '#0b172a',
position: 'relative',
},
cameraView: {
flex: 1,
},
cameraOverlay: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 80,
},
viewfinderOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
margin: 20,
},
corner: {
position: 'absolute',
width: 30,
height: 30,
borderColor: 'rgba(255, 255, 255, 0.6)',
borderWidth: 4,
borderRadius: 2,
},
topLeft: {
top: 0,
left: 0,
borderRightWidth: 0,
borderBottomWidth: 0,
},
topRight: {
top: 0,
right: 0,
borderLeftWidth: 0,
borderBottomWidth: 0,
},
bottomLeft: {
bottom: 0,
left: 0,
borderRightWidth: 0,
borderTopWidth: 0,
},
bottomRight: {
bottom: 0,
right: 0,
borderLeftWidth: 0,
borderTopWidth: 0,
},
shotsRow: {
flexDirection: 'row',
paddingHorizontal: 20,
paddingTop: 12,
gap: 8,
justifyContent: 'space-between',
},
shotCard: {
flex: 1,
borderRadius: 14,
backgroundColor: '#f8fafc',
paddingVertical: 12,
gap: 6,
borderWidth: 1,
borderColor: '#e2e8f0',
alignItems: 'center',
justifyContent: 'center',
},
shotCardActive: {
borderColor: '#38bdf8',
backgroundColor: '#ecfeff',
},
mealTypeIcon: {
fontSize: 20,
},
shotLabel: {
fontSize: 12,
color: '#475569',
fontWeight: '600',
},
shotLabelActive: {
color: '#0ea5e9',
},
bottomBar: {
paddingHorizontal: 20,
paddingTop: 12,
gap: 10,
},
bottomActions: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
captureBtn: {
width: 72,
height: 72,
borderRadius: 36,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
shadowColor: '#0ea5e9',
shadowOpacity: 0.25,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
},
fallbackCaptureBtn: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderWidth: 2,
borderColor: 'rgba(14, 165, 233, 0.2)',
},
captureOuterRing: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: 'rgba(255, 255, 255, 0.15)',
justifyContent: 'center',
alignItems: 'center',
},
captureInner: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#fff',
shadowColor: '#0ea5e9',
shadowOpacity: 0.4,
shadowRadius: 6,
shadowOffset: { width: 0, height: 2 },
},
secondaryBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#0f172a',
shadowOpacity: 0.08,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
minWidth: 88,
justifyContent: 'center',
},
fallbackSecondaryBtn: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderWidth: 1,
borderColor: 'rgba(15, 23, 42, 0.1)',
},
secondaryBtnText: {
color: '#0f172a',
fontWeight: '600',
fontSize: 14,
},
infoButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackInfoButton: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
permissionCard: {
marginHorizontal: 24,
borderRadius: 18,
padding: 24,
backgroundColor: '#fff',
shadowColor: '#0f172a',
shadowOpacity: 0.08,
shadowRadius: 12,
shadowOffset: { width: 0, height: 10 },
alignItems: 'center',
gap: 10,
},
permissionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#0f172a',
marginBottom: 4,
},
permissionTip: {
fontSize: 14,
color: '#475569',
textAlign: 'center',
lineHeight: 20,
marginBottom: 16,
},
permissionBtn: {
borderRadius: 14,
paddingHorizontal: 24,
paddingVertical: 14,
width: '100%',
alignItems: 'center',
},
permissionBtnText: {
color: '#fff',
fontWeight: '700',
fontSize: 16,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
justifyContent: 'center',
paddingHorizontal: 20,
},
instructionModal: {
backgroundColor: '#FFF',
borderRadius: 24,
padding: 24,
alignItems: 'center',
},
instructionTitle: {
fontSize: 20,
fontWeight: '700',
color: '#0f172a',
marginBottom: 24,
},
exampleContainer: {
flexDirection: 'row',
gap: 16,
marginBottom: 24,
},
exampleItem: {
flex: 1,
alignItems: 'center',
gap: 8,
},
exampleImagePlaceholder: {
width: '100%',
aspectRatio: 1,
backgroundColor: '#F1F5F9',
borderRadius: 16,
overflow: 'hidden',
position: 'relative',
},
exampleImage: {
width: '100%',
height: '100%',
},
exampleText: {
fontSize: 13,
color: '#64748b',
fontWeight: '500',
},
checkmarkContainer: {
position: 'absolute',
top: 8,
right: 8,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: '#22c55e',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
crossContainer: {
position: 'absolute',
top: 8,
right: 8,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: '#ef4444',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
instructionDescription: {
fontSize: 15,
textAlign: 'center',
color: '#334155',
marginBottom: 24,
lineHeight: 22,
},
knowButton: {
backgroundColor: '#0f172a',
borderRadius: 16,
paddingVertical: 14,
paddingHorizontal: 32,
width: '100%',
},
knowButtonText: {
color: '#FFF',
fontSize: 16,
fontWeight: '600',
textAlign: 'center',
},
});