- 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.
631 lines
18 KiB
TypeScript
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,
|
|
},
|
|
}); |