Files
digital-pilates/app/food/food-recognition.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

631 lines
18 KiB
TypeScript

import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { useI18n } from '@/hooks/useI18n';
import { useVipService } from '@/hooks/useVipService';
import { recognizeFood } from '@/services/foodRecognition';
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import Animated, {
Easing,
FadeIn,
SlideInDown,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function FoodRecognitionScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useI18n();
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
const colors = Colors[scheme];
const params = useLocalSearchParams<{
imageUri?: string;
mealType?: string;
}>();
const { imageUri, mealType } = params;
const { upload } = useCosUpload();
const [showRecognitionProcess, setShowRecognitionProcess] = useState(false);
const [recognitionLogs, setRecognitionLogs] = useState<string[]>([]);
const [currentStep, setCurrentStep] = useState<'idle' | 'uploading' | 'recognizing' | 'completed' | 'failed'>('idle');
const dispatch = useAppDispatch();
// Auth & VIP hooks
const { ensureLoggedIn } = useAuthGuard();
const { handleServiceAccess } = useVipService();
const { openMembershipModal } = useMembershipModal();
// Animation values
const progressValue = useSharedValue(0);
const pulseValue = useSharedValue(1);
useEffect(() => {
if (currentStep === 'uploading') {
progressValue.value = withTiming(0.4, { duration: 2000 });
startPulse();
} else if (currentStep === 'recognizing') {
progressValue.value = withTiming(0.9, { duration: 3000 });
} else if (currentStep === 'completed') {
progressValue.value = withTiming(1, { duration: 500 });
stopPulse();
} else if (currentStep === 'failed') {
stopPulse();
}
}, [currentStep]);
const startPulse = () => {
pulseValue.value = withRepeat(
withSequence(
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
),
-1,
true
);
};
const stopPulse = () => {
pulseValue.value = withTiming(1);
};
const addLog = (message: string) => {
setRecognitionLogs(prev => [...prev, message]);
};
const handleConfirm = async () => {
if (!imageUri) return;
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) return;
const canAccess = handleServiceAccess(
() => {}, // Allowed
() => openMembershipModal() // Denied
);
if (!canAccess) return;
try {
setShowRecognitionProcess(true);
setRecognitionLogs([]);
setCurrentStep('uploading');
dispatch(setLoading(true));
addLog(t('foodRecognition.logs.uploading'));
const { url } = await upload(
{ uri: imageUri, name: 'food-image.jpg', type: 'image/jpeg' },
{ prefix: 'food-images/' }
);
addLog(t('foodRecognition.logs.uploadSuccess'));
addLog(t('foodRecognition.logs.analyzing'));
setCurrentStep('recognizing');
const recognitionResult = await recognizeFood({
imageUrls: [url]
});
console.log('食物识别结果:', recognitionResult);
if (!recognitionResult.isFoodDetected) {
addLog(t('foodRecognition.logs.failed'));
addLog(`💭 ${recognitionResult.nonFoodMessage || recognitionResult.analysisText}`);
setCurrentStep('failed');
return;
}
addLog(t('foodRecognition.logs.analysisSuccess'));
addLog(t('foodRecognition.logs.confidence', { value: recognitionResult.confidence }));
addLog(t('foodRecognition.logs.itemsFound', { count: recognitionResult.items.length }));
setCurrentStep('completed');
const recognitionId = `recognition_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
dispatch(saveRecognitionResult({
id: recognitionId,
result: recognitionResult
}));
setTimeout(() => {
router.replace(`/food/analysis-result?imageUri=${encodeURIComponent(url)}&mealType=${mealType}&recognitionId=${recognitionId}`);
}, 1000);
} catch (error) {
console.warn('食物识别失败', error);
addLog(t('foodRecognition.logs.error'));
addLog(`💥 ${error instanceof Error ? error.message : t('foodRecognition.errors.unknown')}`);
setCurrentStep('failed');
dispatch(setError(t('foodRecognition.errors.generic')));
} finally {
dispatch(setLoading(false));
}
};
const handleRetry = () => {
setShowRecognitionProcess(false);
setCurrentStep('idle');
setRecognitionLogs([]);
dispatch(setError(null));
progressValue.value = 0;
router.back();
};
const handleGoBack = () => {
if (showRecognitionProcess && currentStep !== 'failed' && currentStep !== 'completed') {
Alert.alert(
t('foodRecognition.alerts.recognizing.title'),
t('foodRecognition.alerts.recognizing.message'),
[
{ text: t('foodRecognition.alerts.recognizing.continue'), style: 'cancel' },
{ text: t('foodRecognition.alerts.recognizing.back'), style: 'destructive', onPress: () => router.back() }
]
);
} else {
router.back();
}
};
const pulseStyle = useAnimatedStyle(() => ({
transform: [{ scale: pulseValue.value }]
}));
const progressBarStyle = useAnimatedStyle(() => ({
width: `${progressValue.value * 100}%`
}));
if (!imageUri) {
return (
<View style={styles.container}>
<HeaderBar title={t('foodRecognition.title')} onBack={router.back} />
<View style={[styles.errorContainer, { paddingTop: insets.top + 60 }]}>
<Text style={styles.errorText}>{t('foodRecognition.errors.noImage')}</Text>
</View>
</View>
);
}
return (
<View style={styles.container}>
<LinearGradient
colors={['#fefefe', '#f4f7fb', '#eff6ff']}
style={StyleSheet.absoluteFill}
/>
<HeaderBar
title={showRecognitionProcess ? t('foodRecognition.header.recognizing') : t('foodRecognition.header.confirm')}
onBack={handleGoBack}
transparent
/>
<ScrollView
contentContainerStyle={[
styles.contentContainer,
{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 20 }
]}
showsVerticalScrollIndicator={false}
>
{/* Image Preview Card */}
<View style={styles.imageCard}>
<View style={styles.imageFrame}>
<Image
source={{ uri: imageUri }}
style={styles.mainImage}
contentFit="cover"
/>
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.3)']}
style={StyleSheet.absoluteFill}
/>
{mealType && (
<View style={styles.mealBadge}>
<GlassView
style={styles.mealBadgeGlass}
glassEffectStyle="regular"
tintColor="rgba(0,0,0,0.4)"
>
<Text style={styles.mealBadgeText}>{getMealTypeLabel(mealType, t)}</Text>
</GlassView>
</View>
)}
</View>
</View>
{/* Status / Action Area */}
{!showRecognitionProcess ? (
<Animated.View entering={FadeIn.duration(400).delay(200)}>
<View style={styles.infoSection}>
<View style={styles.iconCircle}>
<Ionicons name="sparkles" size={24} color={colors.primary} />
</View>
<Text style={styles.infoTitle}>{t('foodRecognition.info.title')}</Text>
<Text style={styles.infoDesc}>
{t('foodRecognition.info.description')}
</Text>
</View>
<View style={styles.actionButtons}>
<TouchableOpacity
onPress={handleConfirm}
activeOpacity={0.8}
style={styles.primaryButtonWrapper}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.primaryButton}
glassEffectStyle="clear"
tintColor={colors.primary}
isInteractive
>
<Ionicons name="scan-outline" size={20} color="#fff" style={{ marginRight: 8 }} />
<Text style={styles.primaryButtonText}>{t('foodRecognition.actions.start')}</Text>
</GlassView>
) : (
<View style={[styles.primaryButton, { backgroundColor: colors.primary }]}>
<Ionicons name="scan-outline" size={20} color="#fff" style={{ marginRight: 8 }} />
<Text style={styles.primaryButtonText}>{t('foodRecognition.actions.start')}</Text>
</View>
)}
</TouchableOpacity>
</View>
</Animated.View>
) : (
<Animated.View
entering={SlideInDown.springify().damping(20)}
style={styles.processContainer}
>
{/* Progress Status Card */}
<View style={styles.progressCard}>
<View style={styles.progressHeader}>
<Animated.View style={[styles.statusIconContainer, pulseStyle]}>
<View style={[
styles.statusIcon,
{ backgroundColor: getStatusColor(currentStep, colors) }
]}>
{currentStep === 'completed' ? (
<Ionicons name="checkmark" size={24} color="#fff" />
) : currentStep === 'failed' ? (
<Ionicons name="close" size={24} color="#fff" />
) : (
<ActivityIndicator color="#fff" size="small" />
)}
</View>
</Animated.View>
<View style={styles.statusTextContainer}>
<Text style={styles.statusTitle}>
{getStatusTitle(currentStep, t)}
</Text>
<Text style={styles.statusSubtitle}>
{getStatusSubtitle(currentStep, t)}
</Text>
</View>
</View>
{/* Progress Bar */}
{(currentStep === 'uploading' || currentStep === 'recognizing') && (
<View style={styles.progressBarBg}>
<Animated.View style={[styles.progressBarFill, { backgroundColor: colors.primary }, progressBarStyle]} />
</View>
)}
</View>
{/* Log Console */}
<View style={styles.logsCard}>
<View style={styles.logsHeader}>
<Ionicons name="terminal-outline" size={16} color={colors.textSecondary} />
<Text style={styles.logsTitle}>{t('foodRecognition.actions.logs')}</Text>
</View>
<ScrollView style={styles.logsScroll} nestedScrollEnabled>
{recognitionLogs.map((log, idx) => (
<Animated.View
key={idx}
entering={FadeIn.duration(300)}
style={styles.logRow}
>
<Text style={styles.logText}>{log}</Text>
</Animated.View>
))}
{recognitionLogs.length === 0 && (
<Text style={styles.logPlaceholder}>{t('foodRecognition.actions.logsPlaceholder')}</Text>
)}
</ScrollView>
</View>
{currentStep === 'failed' && (
<TouchableOpacity
onPress={handleRetry}
activeOpacity={0.8}
style={styles.retryButton}
>
<Text style={styles.retryButtonText}>{t('foodRecognition.actions.retry')}</Text>
</TouchableOpacity>
)}
</Animated.View>
)}
</ScrollView>
</View>
);
}
function getMealTypeLabel(type: string, t: any): string {
const map: Record<string, string> = {
breakfast: t('foodRecognition.mealTypes.breakfast'),
lunch: t('foodRecognition.mealTypes.lunch'),
dinner: t('foodRecognition.mealTypes.dinner'),
snack: t('foodRecognition.mealTypes.snack'),
};
return map[type] || t('foodRecognition.mealTypes.unknown');
}
function getStatusColor(step: string, colors: any) {
switch (step) {
case 'completed': return colors.success;
case 'failed': return colors.danger;
default: return colors.primary;
}
}
function getStatusTitle(step: string, t: any) {
switch (step) {
case 'idle': return t('foodRecognition.status.idle.title');
case 'uploading': return t('foodRecognition.status.uploading.title');
case 'recognizing': return t('foodRecognition.status.recognizing.title');
case 'completed': return t('foodRecognition.status.completed.title');
case 'failed': return t('foodRecognition.status.failed.title');
default: return t('foodRecognition.status.processing.title');
}
}
function getStatusSubtitle(step: string, t: any) {
switch (step) {
case 'uploading': return t('foodRecognition.status.uploading.subtitle');
case 'recognizing': return t('foodRecognition.status.recognizing.subtitle');
case 'completed': return t('foodRecognition.status.completed.subtitle');
case 'failed': return t('foodRecognition.status.failed.subtitle');
default: return t('foodRecognition.status.processing.subtitle');
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
errorContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
errorText: {
fontSize: 16,
color: '#64748b',
},
contentContainer: {
paddingHorizontal: 20,
paddingBottom: 40,
},
imageCard: {
borderRadius: 24,
overflow: 'hidden',
backgroundColor: '#fff',
shadowColor: '#0f172a',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.08,
shadowRadius: 24,
elevation: 8,
marginBottom: 24,
},
imageFrame: {
width: '100%',
aspectRatio: 1, // Square image or 4:3
backgroundColor: '#f1f5f9',
position: 'relative',
},
mainImage: {
width: '100%',
height: '100%',
},
mealBadge: {
position: 'absolute',
top: 16,
right: 16,
borderRadius: 16,
overflow: 'hidden',
},
mealBadgeGlass: {
paddingHorizontal: 12,
paddingVertical: 6,
},
mealBadgeText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
},
infoSection: {
alignItems: 'center',
marginBottom: 32,
paddingHorizontal: 10,
},
iconCircle: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#eff6ff',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
infoTitle: {
fontSize: 22,
fontWeight: '700',
color: '#0f172a',
marginBottom: 8,
},
infoDesc: {
fontSize: 15,
color: '#64748b',
textAlign: 'center',
lineHeight: 22,
},
actionButtons: {
width: '100%',
},
primaryButtonWrapper: {
width: '100%',
borderRadius: 20,
overflow: 'hidden',
shadowColor: '#0ea5e9',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.25,
shadowRadius: 16,
elevation: 6,
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 18,
backgroundColor: '#0284c7', // darker sky-600
},
primaryButtonText: {
color: '#fff',
fontSize: 17,
fontWeight: '700',
},
// Process styles
processContainer: {
width: '100%',
},
progressCard: {
backgroundColor: '#fff',
borderRadius: 20,
padding: 20,
marginBottom: 16,
shadowColor: '#0f172a',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 12,
elevation: 3,
},
progressHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
statusIconContainer: {
marginRight: 16,
},
statusIcon: {
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 6,
},
statusTextContainer: {
flex: 1,
},
statusTitle: {
fontSize: 17,
fontWeight: '700',
color: '#0f172a',
marginBottom: 4,
},
statusSubtitle: {
fontSize: 13,
color: '#64748b',
},
progressBarBg: {
height: 6,
backgroundColor: '#f1f5f9',
borderRadius: 3,
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
borderRadius: 3,
},
logsCard: {
backgroundColor: 'rgba(255,255,255,0.6)',
borderRadius: 16,
padding: 16,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.8)',
minHeight: 150,
},
logsHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
opacity: 0.7,
},
logsTitle: {
fontSize: 13,
fontWeight: '600',
color: '#64748b',
marginLeft: 6,
},
logsScroll: {
maxHeight: 200,
},
logRow: {
marginBottom: 6,
},
logText: {
fontSize: 13,
color: '#334155',
lineHeight: 18,
fontFamily: 'Menlo', // Monospace if available
},
logPlaceholder: {
fontSize: 13,
color: '#94a3b8',
fontStyle: 'italic',
textAlign: 'center',
marginTop: 20,
},
retryButton: {
marginTop: 20,
backgroundColor: '#fff',
paddingVertical: 14,
borderRadius: 16,
alignItems: 'center',
borderWidth: 1,
borderColor: '#e2e8f0',
},
retryButtonText: {
color: '#0f172a',
fontWeight: '600',
fontSize: 15,
},
});