feat(nutrition): 添加营养成分表拍照分析功能
新增营养成分表拍照识别功能,用户可通过拍摄食物包装上的成分表自动解析营养信息: - 创建成分表分析页面,支持拍照/选择图片和结果展示 - 集成新的营养成分分析API,支持图片上传和流式分析 - 在营养雷达卡片中添加成分表分析入口 - 更新应用版本至1.0.19
This commit is contained in:
843
app/food/nutrition-label-analysis.tsx
Normal file
843
app/food/nutrition-label-analysis.tsx
Normal file
@@ -0,0 +1,843 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
analyzeNutritionImage,
|
||||
analyzeNutritionLabelStream,
|
||||
convertNewApiResultToOldFormat,
|
||||
type NutritionAnalysisResponse,
|
||||
type NutritionItem,
|
||||
type NutritionLabelAnalysisResult
|
||||
} from '@/services/nutritionLabelAnalysis';
|
||||
import { triggerLightHaptic } from '@/utils/haptics';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
BackHandler,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import ImageViewing from 'react-native-image-viewing';
|
||||
|
||||
export default function NutritionLabelAnalysisScreen() {
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
const router = useRouter();
|
||||
const { upload, uploading: uploadingToCos, progress: uploadProgress } = useCosUpload({
|
||||
prefix: 'nutrition-labels'
|
||||
});
|
||||
|
||||
const [imageUri, setImageUri] = useState<string | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||
const [analysisText, setAnalysisText] = useState<string>('');
|
||||
const [analysisResult, setAnalysisResult] = useState<NutritionLabelAnalysisResult | null>(null);
|
||||
const [newAnalysisResult, setNewAnalysisResult] = useState<NutritionAnalysisResponse | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// 流式请求相关引用
|
||||
const streamAbortRef = useRef<{ abort: () => void } | null>(null);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
// 处理Android返回键关闭图片预览
|
||||
React.useEffect(() => {
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
if (showImagePreview) {
|
||||
setShowImagePreview(false);
|
||||
return true; // 阻止默认返回行为
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => backHandler.remove();
|
||||
}, [showImagePreview]);
|
||||
|
||||
// 组件卸载时清理流式请求
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
try {
|
||||
if (streamAbortRef.current) {
|
||||
streamAbortRef.current.abort();
|
||||
streamAbortRef.current = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[NUTRITION_ANALYSIS] Error aborting stream on unmount:', error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 请求相机权限
|
||||
const requestCameraPermission = async () => {
|
||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('权限不足', '需要相机权限才能拍摄成分表');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 拍照
|
||||
const takePhoto = async () => {
|
||||
const hasPermission = await requestCameraPermission();
|
||||
if (!hasPermission) return;
|
||||
|
||||
triggerLightHaptic();
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
aspect: [4, 3],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setImageUri(result.assets[0].uri);
|
||||
setAnalysisText(''); // 清除之前的分析文本
|
||||
setAnalysisResult(null); // 清除之前的分析结果
|
||||
setNewAnalysisResult(null); // 清除新的分析结果
|
||||
}
|
||||
};
|
||||
|
||||
// 从相册选择
|
||||
const pickImage = async () => {
|
||||
triggerLightHaptic();
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
aspect: [4, 3],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setImageUri(result.assets[0].uri);
|
||||
setAnalysisText(''); // 清除之前的分析文本
|
||||
setAnalysisResult(null); // 清除之前的分析结果
|
||||
setNewAnalysisResult(null); // 清除新的分析结果
|
||||
}
|
||||
};
|
||||
|
||||
// 取消当前分析
|
||||
const cancelAnalysis = useCallback(() => {
|
||||
try {
|
||||
console.log('[NUTRITION_ANALYSIS] User cancelled analysis');
|
||||
|
||||
// 中断网络请求
|
||||
if (streamAbortRef.current) {
|
||||
streamAbortRef.current.abort();
|
||||
streamAbortRef.current = null;
|
||||
}
|
||||
|
||||
// 清理状态
|
||||
setIsAnalyzing(false);
|
||||
setAnalysisText('');
|
||||
|
||||
// 触觉反馈
|
||||
triggerLightHaptic();
|
||||
} catch (error) {
|
||||
console.warn('[NUTRITION_ANALYSIS] Error cancelling analysis:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 开始流式分析图片
|
||||
const startAnalysis = useCallback(async (uri: string) => {
|
||||
if (isAnalyzing) return;
|
||||
|
||||
setIsAnalyzing(true);
|
||||
setAnalysisText('');
|
||||
setAnalysisResult(null);
|
||||
|
||||
// 延迟滚动到分析结果区域,给UI一些时间更新
|
||||
setTimeout(() => {
|
||||
scrollViewRef.current?.scrollTo({ y: 350, animated: true });
|
||||
}, 300);
|
||||
|
||||
let isFirstChunk = true;
|
||||
|
||||
try {
|
||||
await analyzeNutritionLabelStream(
|
||||
{ imageUri: uri },
|
||||
{
|
||||
onChunk: (chunk: string) => {
|
||||
setAnalysisText(prev => {
|
||||
const newText = prev + chunk;
|
||||
// 在接收到第一个文本块时滚动到分析结果区域
|
||||
if (isFirstChunk && newText.length > 0) {
|
||||
isFirstChunk = false;
|
||||
setTimeout(() => {
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||
}, 100);
|
||||
}
|
||||
return newText;
|
||||
});
|
||||
},
|
||||
onEnd: () => {
|
||||
setIsAnalyzing(false);
|
||||
streamAbortRef.current = null;
|
||||
console.log('[NUTRITION_ANALYSIS] Analysis completed');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('[NUTRITION_ANALYSIS] Analysis failed:', error);
|
||||
setIsAnalyzing(false);
|
||||
setAnalysisText('');
|
||||
streamAbortRef.current = null;
|
||||
|
||||
// 如果是用户主动取消,不显示错误提示
|
||||
if (error?.name !== 'AbortError' && !error?.message?.includes('abort')) {
|
||||
Alert.alert('分析失败', '无法识别成分表,请尝试拍摄更清晰的照片');
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[NUTRITION_ANALYSIS] Analysis error:', error);
|
||||
setIsAnalyzing(false);
|
||||
setAnalysisText('');
|
||||
Alert.alert('分析失败', '无法识别成分表,请尝试拍摄更清晰的照片');
|
||||
}
|
||||
}, [isAnalyzing]);
|
||||
|
||||
// 新的分析函数:先上传图片到COS,然后调用新API
|
||||
const startNewAnalysis = useCallback(async (uri: string) => {
|
||||
if (isAnalyzing || isUploading) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setAnalysisText('');
|
||||
setAnalysisResult(null);
|
||||
setNewAnalysisResult(null);
|
||||
|
||||
// 延迟滚动到分析结果区域,给UI一些时间更新
|
||||
setTimeout(() => {
|
||||
scrollViewRef.current?.scrollTo({ y: 350, animated: true });
|
||||
}, 300);
|
||||
|
||||
try {
|
||||
// 第一步:上传图片到COS
|
||||
console.log('[NUTRITION_ANALYSIS] 开始上传图片到COS...');
|
||||
const uploadResult = await upload(uri);
|
||||
console.log('[NUTRITION_ANALYSIS] 图片上传成功:', uploadResult.url);
|
||||
|
||||
setIsUploading(false);
|
||||
setIsAnalyzing(true);
|
||||
|
||||
// 第二步:调用新的营养成分分析API
|
||||
console.log('[NUTRITION_ANALYSIS] 开始调用营养成分分析API...');
|
||||
const analysisResponse = await analyzeNutritionImage({
|
||||
imageUrl: uploadResult.url
|
||||
});
|
||||
|
||||
console.log('[NUTRITION_ANALYSIS] API响应:', analysisResponse);
|
||||
|
||||
if (analysisResponse.success && analysisResponse.data) {
|
||||
// 转换为旧格式以便与现有UI兼容
|
||||
const oldFormatResult = convertNewApiResultToOldFormat(analysisResponse, uploadResult.url);
|
||||
|
||||
if (oldFormatResult) {
|
||||
setAnalysisResult(oldFormatResult);
|
||||
setNewAnalysisResult(analysisResponse);
|
||||
|
||||
// 生成分析文本
|
||||
const analysisText = analysisResponse.data
|
||||
.map((item: NutritionItem) => `**${item.name}**: ${item.value}\n${item.analysis}`)
|
||||
.join('\n\n');
|
||||
setAnalysisText(analysisText);
|
||||
} else {
|
||||
throw new Error('无法解析API返回结果');
|
||||
}
|
||||
} else {
|
||||
throw new Error(analysisResponse.message || '分析失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[NUTRITION_ANALYSIS] 新API分析失败:', error);
|
||||
setIsUploading(false);
|
||||
setIsAnalyzing(false);
|
||||
setAnalysisText('');
|
||||
|
||||
// 显示错误提示
|
||||
Alert.alert(
|
||||
'分析失败',
|
||||
error.message || '无法识别成分表,请尝试拍摄更清晰的照片'
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [isAnalyzing, isUploading, upload]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#ffffffff']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
|
||||
<HeaderBar
|
||||
title="成分表分析"
|
||||
onBack={() => router.back()}
|
||||
transparent={true}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.scrollContainer}
|
||||
contentContainerStyle={{
|
||||
paddingTop: safeAreaTop
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 图片区域 */}
|
||||
<View style={styles.imageContainer}>
|
||||
{imageUri ? (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowImagePreview(true)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={styles.foodImage}
|
||||
cachePolicy={'memory-disk'}
|
||||
/>
|
||||
{/* 预览提示图标 */}
|
||||
<View style={styles.previewHint}>
|
||||
<Ionicons name="expand-outline" size={20} color="#FFF" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 开始分析按钮 */}
|
||||
{!isAnalyzing && !isUploading && !analysisText && (
|
||||
<TouchableOpacity
|
||||
style={styles.analyzeButton}
|
||||
onPress={() => startNewAnalysis(imageUri)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="search-outline" size={20} color="#FFF" />
|
||||
<Text style={styles.analyzeButtonText}>开始分析</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 删除图片按钮 */}
|
||||
<TouchableOpacity
|
||||
style={styles.deleteImageButton}
|
||||
onPress={() => {
|
||||
setImageUri(null);
|
||||
setAnalysisText('');
|
||||
setAnalysisResult(null);
|
||||
setNewAnalysisResult(null);
|
||||
triggerLightHaptic();
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={16} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.placeholderContainer}>
|
||||
<View style={styles.placeholderContent}>
|
||||
<Ionicons name="document-text-outline" size={48} color="#666" />
|
||||
<Text style={styles.placeholderText}>拍摄或选择成分表照片</Text>
|
||||
</View>
|
||||
{/* 操作按钮区域 */}
|
||||
<View style={styles.imageActionButtonsContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.imageActionButton}
|
||||
onPress={takePhoto}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} />
|
||||
<Text style={styles.imageActionButtonText}>拍摄</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.imageActionButton, styles.imageActionButtonSecondary]}
|
||||
onPress={pickImage}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="image-outline" size={20} color={Colors.light.primary} />
|
||||
<Text style={[styles.imageActionButtonText, { color: Colors.light.primary }]}>相册</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 流式分析文本区域 */}
|
||||
{analysisText && (
|
||||
<View style={styles.analysisTextCard}>
|
||||
<View style={styles.analysisTextHeader}>
|
||||
<Text style={styles.analysisTextTitle}>分析结果</Text>
|
||||
{isAnalyzing && (
|
||||
<TouchableOpacity
|
||||
style={styles.cancelAnalysisButton}
|
||||
onPress={cancelAnalysis}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="close-outline" size={16} color="#FF4444" />
|
||||
<Text style={styles.cancelAnalysisText}>停止</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.analysisTextContent}>{analysisText}</Text>
|
||||
{isAnalyzing && (
|
||||
<View style={styles.streamingIndicator}>
|
||||
<ActivityIndicator size="small" color={Colors.light.primary} />
|
||||
<Text style={styles.streamingText}>正在分析中...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 新API营养成分详细分析结果 */}
|
||||
{newAnalysisResult && newAnalysisResult.success && newAnalysisResult.data && (
|
||||
<View style={styles.nutritionDetailsCard}>
|
||||
<View style={styles.nutritionDetailsHeader}>
|
||||
<Text style={styles.nutritionDetailsTitle}>营养成分详细分析</Text>
|
||||
</View>
|
||||
{newAnalysisResult.data.map((item, index) => (
|
||||
<View key={item.key || index} style={styles.nutritionDetailItem}>
|
||||
<View style={styles.nutritionDetailHeader}>
|
||||
<Text style={styles.nutritionDetailName}>{item.name}</Text>
|
||||
<Text style={styles.nutritionDetailValue}>{item.value}</Text>
|
||||
</View>
|
||||
<Text style={styles.nutritionDetailAnalysis}>{item.analysis}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 上传状态 */}
|
||||
{isUploading && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={Colors.light.primary} />
|
||||
<Text style={styles.loadingText}>
|
||||
正在上传图片... {uploadProgress > 0 ? `${Math.round(uploadProgress)}%` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{isAnalyzing && !analysisText && !isUploading && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={Colors.light.primary} />
|
||||
<Text style={styles.loadingText}>正在分析成分表...</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 图片预览 */}
|
||||
<ImageViewing
|
||||
images={imageUri ? [{ uri: imageUri }] : []}
|
||||
imageIndex={0}
|
||||
visible={showImagePreview}
|
||||
onRequestClose={() => setShowImagePreview(false)}
|
||||
swipeToCloseEnabled={true}
|
||||
doubleTapToZoomEnabled={true}
|
||||
HeaderComponent={() => (
|
||||
<View style={styles.imageViewerHeader}>
|
||||
<Text style={styles.imageViewerHeaderText}>
|
||||
{dayjs().format('YYYY年M月D日 HH:mm')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
FooterComponent={() => (
|
||||
<View style={styles.imageViewerFooter}>
|
||||
<TouchableOpacity
|
||||
style={styles.imageViewerFooterButton}
|
||||
onPress={() => setShowImagePreview(false)}
|
||||
>
|
||||
<Text style={styles.imageViewerFooterButtonText}>关闭</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5e5fbff',
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
imageContainer: {
|
||||
position: 'relative',
|
||||
height: 300,
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
foodImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 20,
|
||||
},
|
||||
previewHint: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: 20,
|
||||
padding: 8,
|
||||
},
|
||||
deleteImageButton: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
left: 16,
|
||||
backgroundColor: 'rgba(255, 59, 48, 0.9)',
|
||||
borderRadius: 20,
|
||||
padding: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
placeholderContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#F8F9FA',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 20,
|
||||
borderWidth: 2,
|
||||
borderColor: '#E9ECEF',
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
placeholderContent: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
fontWeight: '500',
|
||||
marginTop: 8,
|
||||
},
|
||||
imageActionButtonsContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
imageActionButton: {
|
||||
flex: 1,
|
||||
backgroundColor: Colors.light.primary,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
shadowColor: Colors.light.primary,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
imageActionButtonSecondary: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1.5,
|
||||
borderColor: Colors.light.primary,
|
||||
shadowOpacity: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
imageActionButtonText: {
|
||||
color: Colors.light.onPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
},
|
||||
resultCard: {
|
||||
backgroundColor: Colors.light.background,
|
||||
margin: 16,
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
resultHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
resultTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.text,
|
||||
},
|
||||
confidenceContainer: {
|
||||
backgroundColor: '#E8F5E8',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
},
|
||||
confidenceText: {
|
||||
fontSize: 12,
|
||||
color: '#4CAF50',
|
||||
fontWeight: '500',
|
||||
},
|
||||
foodInfoContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
foodNameLabel: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textSecondary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
foodNameValue: {
|
||||
fontSize: 16,
|
||||
color: Colors.light.text,
|
||||
fontWeight: '500',
|
||||
},
|
||||
nutritionGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 8,
|
||||
},
|
||||
nutritionItem: {
|
||||
width: '50%',
|
||||
marginBottom: 12,
|
||||
paddingRight: 8,
|
||||
},
|
||||
nutritionLabel: {
|
||||
fontSize: 12,
|
||||
color: Colors.light.textSecondary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
nutritionValue: {
|
||||
fontSize: 16,
|
||||
color: Colors.light.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 40,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
color: Colors.light.textSecondary,
|
||||
marginTop: 12,
|
||||
},
|
||||
// ImageViewing 组件样式
|
||||
imageViewerHeader: {
|
||||
position: 'absolute',
|
||||
top: 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
zIndex: 1,
|
||||
},
|
||||
imageViewerHeaderText: {
|
||||
color: '#FFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
},
|
||||
imageViewerFooter: {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
alignItems: 'center',
|
||||
zIndex: 1,
|
||||
},
|
||||
imageViewerFooterButton: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
imageViewerFooterButtonText: {
|
||||
color: '#FFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
// 开始分析按钮样式
|
||||
analyzeButton: {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
backgroundColor: Colors.light.primary,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
analyzeButtonText: {
|
||||
color: Colors.light.onPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
},
|
||||
// 分析文本卡片样式
|
||||
analysisTextCard: {
|
||||
backgroundColor: Colors.light.background,
|
||||
margin: 16,
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
analysisTextHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
analysisTextTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.text,
|
||||
},
|
||||
cancelAnalysisButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(255,68,68,0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,68,68,0.3)',
|
||||
},
|
||||
cancelAnalysisText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#FF4444',
|
||||
},
|
||||
analysisTextContent: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
color: Colors.light.text,
|
||||
marginBottom: 12,
|
||||
},
|
||||
streamingIndicator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
streamingText: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textSecondary,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
// 营养成分详细分析卡片样式
|
||||
nutritionDetailsCard: {
|
||||
backgroundColor: Colors.light.background,
|
||||
margin: 16,
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
nutritionDetailsHeader: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
nutritionDetailsTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.text,
|
||||
},
|
||||
nutritionDetailItem: {
|
||||
marginBottom: 20,
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F0F0',
|
||||
},
|
||||
nutritionDetailHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
nutritionDetailName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.text,
|
||||
flex: 1,
|
||||
},
|
||||
nutritionDetailValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.primary,
|
||||
backgroundColor: 'rgba(74, 144, 226, 0.1)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
},
|
||||
nutritionDetailAnalysis: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: Colors.light.textSecondary,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user