Files
digital-pilates/app/food/nutrition-label-analysis.tsx
richarjiang c6084fe702 feat(nutrition): 添加营养分析历史记录删除和图片预览功能
- 新增删除营养分析记录功能,支持本地状态更新和API调用
- 添加图片全屏预览功能,支持缩放和手势操作
- 实现Liquid Glass风格的删除按钮,包含兼容性处理
- 优化历史记录页面布局和交互体验
- 更新Memory Bank文档,添加Liquid Glass按钮实现指南
2025-10-16 16:43:45 +08:00

774 lines
21 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 { useCosUpload } from '@/hooks/useCosUpload';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
analyzeNutritionImage,
type NutritionAnalysisResponse
} from '@/services/nutritionLabelAnalysis';
import { triggerLightHaptic } from '@/utils/haptics';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
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 { 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 [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: ['images'],
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
setNewAnalysisResult(null); // 清除之前的分析结果
}
};
// 从相册选择
const pickImage = async () => {
triggerLightHaptic();
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
setNewAnalysisResult(null); // 清除之前的分析结果
}
};
// 新的分析函数先上传图片到COS然后调用新API
const startNewAnalysis = useCallback(async (uri: string) => {
if (isAnalyzing || isUploading) return;
setIsUploading(true);
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) {
// 直接使用服务端返回的数据,不做任何转换
setNewAnalysisResult(analysisResponse);
} else {
throw new Error(analysisResponse.message || '分析失败');
}
} catch (error: any) {
console.error('[NUTRITION_ANALYSIS] 新API分析失败:', error);
setIsUploading(false);
setIsAnalyzing(false);
// 显示错误提示
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}
right={
isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={() => router.push('/food/nutrition-analysis-history')}
activeOpacity={0.7}
>
<GlassView
style={styles.historyButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.2)"
isInteractive={true}
>
<Ionicons name="time-outline" size={24} color="#333" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => router.push('/food/nutrition-analysis-history')}
style={[styles.historyButton, styles.fallbackBackground]}
activeOpacity={0.7}
>
<Ionicons name="time-outline" size={24} color="#333" />
</TouchableOpacity>
)
}
/>
<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 && !newAnalysisResult && (
<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);
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>
{/* 新API营养成分详细分析结果 */}
{newAnalysisResult && newAnalysisResult.success && newAnalysisResult.data && (
<View style={styles.analysisSection}>
<View style={styles.analysisSectionHeader}>
<View style={styles.analysisSectionHeaderIcon}>
<Ionicons name="document-text-outline" size={18} color="#6B6ED6" />
</View>
<Text style={styles.analysisSectionTitle}></Text>
</View>
<View style={styles.analysisCardsWrapper}>
{newAnalysisResult.data.map((item, index) => (
<View
key={item.key || index}
style={[
styles.analysisCardItem,
index === newAnalysisResult.data.length - 1 && styles.analysisCardItemLast
]}
>
<LinearGradient
colors={['#7F77FF', '#9B7CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.analysisItemIconGradient}
>
<Ionicons name="nutrition-outline" size={24} color="#FFFFFF" />
</LinearGradient>
<View style={styles.analysisItemContent}>
<View style={styles.analysisItemHeader}>
<Text style={styles.analysisItemName}>{item.name}</Text>
<Text style={styles.analysisItemValue}>{item.value}</Text>
</View>
<View style={styles.analysisItemDescriptionRow}>
<Ionicons
name="information-circle-outline"
size={16}
color="rgba(107, 110, 214, 0.8)"
style={styles.analysisItemDescriptionIcon}
/>
<Text style={styles.analysisItemDescription}>{item.analysis}</Text>
</View>
</View>
</View>
))}
</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 && !newAnalysisResult && !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: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
shadowColor: 'rgba(0, 0, 0, 0.1)',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
},
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,
},
// 营养成分详细分析卡片样式
analysisSection: {
marginHorizontal: 16,
marginTop: 28,
marginBottom: 20,
},
analysisSectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 14,
},
analysisSectionHeaderIcon: {
width: 32,
height: 32,
borderRadius: 12,
backgroundColor: 'rgba(107, 110, 214, 0.12)',
alignItems: 'center',
justifyContent: 'center',
},
analysisSectionTitle: {
marginLeft: 10,
fontSize: 18,
fontWeight: '600',
color: Colors.light.text,
},
analysisCardsWrapper: {
backgroundColor: '#FFFFFF',
borderRadius: 28,
padding: 18,
shadowColor: 'rgba(107, 110, 214, 0.25)',
shadowOffset: {
width: 0,
height: 10,
},
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 6,
},
analysisCardItem: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 16,
backgroundColor: 'rgba(127, 119, 255, 0.1)',
borderRadius: 22,
shadowColor: 'rgba(127, 119, 255, 0.28)',
shadowOffset: {
width: 0,
height: 6,
},
shadowOpacity: 0.16,
shadowRadius: 12,
elevation: 4,
marginBottom: 12,
},
analysisCardItemLast: {
marginBottom: 0,
},
analysisItemIconGradient: {
width: 52,
height: 52,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
},
analysisItemContent: {
flex: 1,
},
analysisItemHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
analysisItemName: {
flex: 1,
fontSize: 16,
fontWeight: '600',
color: '#272753',
},
analysisItemValue: {
fontSize: 16,
fontWeight: '700',
color: Colors.light.primary,
},
analysisItemDescriptionRow: {
flexDirection: 'row',
alignItems: 'flex-start',
},
analysisItemDescriptionIcon: {
marginRight: 6,
marginTop: 2,
},
analysisItemDescription: {
flex: 1,
fontSize: 13,
lineHeight: 18,
color: 'rgba(39, 39, 83, 0.72)',
},
// 历史记录按钮样式
historyButton: {
width: 38,
height: 38,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 19,
overflow: 'hidden',
},
fallbackBackground: {
backgroundColor: 'rgba(255, 255, 255, 0.7)',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
});