feat: 添加食物分析结果页面的图片预览功能,优化记录栏显示逻辑
This commit is contained in:
@@ -6,11 +6,13 @@ import { useAppSelector } from '@/hooks/redux';
|
|||||||
import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/services/dietRecords';
|
import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/services/dietRecords';
|
||||||
import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice';
|
import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
BackHandler,
|
||||||
Image,
|
Image,
|
||||||
Modal,
|
Modal,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -19,7 +21,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import ImageViewing from 'react-native-image-viewing';
|
||||||
|
|
||||||
|
|
||||||
// 模拟食物摄入列表数据
|
// 模拟食物摄入列表数据
|
||||||
@@ -73,17 +75,36 @@ export default function FoodAnalysisResultScreen() {
|
|||||||
imageUri?: string;
|
imageUri?: string;
|
||||||
mealType?: string;
|
mealType?: string;
|
||||||
recognitionId?: string;
|
recognitionId?: string;
|
||||||
|
hideRecordBar?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const [foodItems, setFoodItems] = useState(mockFoodItems);
|
const [foodItems, setFoodItems] = useState(mockFoodItems);
|
||||||
const [currentMealType, setCurrentMealType] = useState<MealType>((params.mealType as MealType) || 'breakfast');
|
const [currentMealType, setCurrentMealType] = useState<MealType>((params.mealType as MealType) || 'breakfast');
|
||||||
const [showMealSelector, setShowMealSelector] = useState(false);
|
const [showMealSelector, setShowMealSelector] = useState(false);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const { imageUri, recognitionId } = params;
|
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||||
|
const [animationTrigger, setAnimationTrigger] = useState(0);
|
||||||
|
const { imageUri, recognitionId, hideRecordBar } = params;
|
||||||
|
|
||||||
|
// 判断是否隐藏记录栏(默认显示)
|
||||||
|
const shouldHideRecordBar = hideRecordBar === 'true';
|
||||||
|
|
||||||
// 从 Redux 获取识别结果
|
// 从 Redux 获取识别结果
|
||||||
const recognitionResult = useAppSelector(recognitionId ? selectFoodRecognitionResult(recognitionId) : () => null);
|
const recognitionResult = useAppSelector(recognitionId ? selectFoodRecognitionResult(recognitionId) : () => null);
|
||||||
|
|
||||||
|
// 处理Android返回键关闭图片预览
|
||||||
|
useEffect(() => {
|
||||||
|
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||||
|
if (showImagePreview) {
|
||||||
|
setShowImagePreview(false);
|
||||||
|
return true; // 阻止默认返回行为
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => backHandler.remove();
|
||||||
|
}, [showImagePreview]);
|
||||||
|
|
||||||
// 处理识别结果数据
|
// 处理识别结果数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (recognitionResult) {
|
if (recognitionResult) {
|
||||||
@@ -116,9 +137,19 @@ export default function FoodAnalysisResultScreen() {
|
|||||||
setCurrentMealType(mealTypeFromApi);
|
setCurrentMealType(mealTypeFromApi);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 触发营养圆环动画
|
||||||
|
setAnimationTrigger(prev => prev + 1);
|
||||||
}
|
}
|
||||||
}, [recognitionResult]);
|
}, [recognitionResult]);
|
||||||
|
|
||||||
|
// 当食物项发生变化时也触发动画
|
||||||
|
useEffect(() => {
|
||||||
|
if (foodItems.length > 0) {
|
||||||
|
setAnimationTrigger(prev => prev + 1);
|
||||||
|
}
|
||||||
|
}, [foodItems]);
|
||||||
|
|
||||||
const handleSaveToDiary = async () => {
|
const handleSaveToDiary = async () => {
|
||||||
if (isRecording || foodItems.length === 0) return;
|
if (isRecording || foodItems.length === 0) return;
|
||||||
|
|
||||||
@@ -188,17 +219,17 @@ export default function FoodAnalysisResultScreen() {
|
|||||||
{ key: 'snack' as const, label: '加餐', color: '#FF9800' },
|
{ key: 'snack' as const, label: '加餐', color: '#FF9800' },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!imageUri) {
|
if (!imageUri && !recognitionResult) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container}>
|
<View style={styles.container}>
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
title="分析结果"
|
title="分析结果"
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
/>
|
/>
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<Text style={styles.errorText}>未找到图片</Text>
|
<Text style={styles.errorText}>未找到图片或识别结果</Text>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,17 +237,12 @@ export default function FoodAnalysisResultScreen() {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* 背景渐变 */}
|
{/* 背景渐变 */}
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#ffffffff']}
|
||||||
style={styles.gradientBackground}
|
style={styles.gradientBackground}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 0, y: 1 }}
|
end={{ x: 0, y: 1 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 装饰性圆圈 */}
|
|
||||||
<View style={styles.decorativeCircle1} />
|
|
||||||
<View style={styles.decorativeCircle2} />
|
|
||||||
<View style={styles.decorativeCircle3} />
|
|
||||||
|
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
title="分析结果"
|
title="分析结果"
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
@@ -226,18 +252,36 @@ export default function FoodAnalysisResultScreen() {
|
|||||||
<ScrollView style={styles.scrollContainer} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.scrollContainer} showsVerticalScrollIndicator={false}>
|
||||||
{/* 食物主图 */}
|
{/* 食物主图 */}
|
||||||
<View style={styles.imageContainer}>
|
<View style={styles.imageContainer}>
|
||||||
<Image
|
{imageUri ? (
|
||||||
source={{ uri: imageUri }}
|
<TouchableOpacity
|
||||||
style={styles.foodImage}
|
onPress={() => setShowImagePreview(true)}
|
||||||
resizeMode="cover"
|
activeOpacity={0.9}
|
||||||
/>
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUri }}
|
||||||
|
style={styles.foodImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
{/* 预览提示图标 */}
|
||||||
|
<View style={styles.previewHint}>
|
||||||
|
<Ionicons name="expand-outline" size={20} color="#FFF" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View style={styles.placeholderContainer}>
|
||||||
|
<View style={styles.placeholderContent}>
|
||||||
|
<Ionicons name="restaurant-outline" size={48} color="#666" />
|
||||||
|
<Text style={styles.placeholderText}>营养记录</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 识别信息气泡 */}
|
{/* 识别信息气泡 */}
|
||||||
<View style={styles.descriptionBubble}>
|
<View style={styles.descriptionBubble}>
|
||||||
<Text style={styles.descriptionText}>
|
<Text style={styles.descriptionText}>
|
||||||
{recognitionResult ?
|
{recognitionResult ?
|
||||||
`置信度: ${recognitionResult.confidence}%` :
|
`置信度: ${recognitionResult.confidence}%` :
|
||||||
'2025年9月4日'
|
dayjs().format('YYYY年M月D日')
|
||||||
}
|
}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -259,6 +303,7 @@ export default function FoodAnalysisResultScreen() {
|
|||||||
unit="克"
|
unit="克"
|
||||||
percentage={Math.min(100, proteinPercentage)}
|
percentage={Math.min(100, proteinPercentage)}
|
||||||
color="#4CAF50"
|
color="#4CAF50"
|
||||||
|
resetToken={animationTrigger}
|
||||||
/>
|
/>
|
||||||
<NutritionRing
|
<NutritionRing
|
||||||
label="脂肪"
|
label="脂肪"
|
||||||
@@ -266,6 +311,7 @@ export default function FoodAnalysisResultScreen() {
|
|||||||
unit="克"
|
unit="克"
|
||||||
percentage={Math.min(100, fatPercentage)}
|
percentage={Math.min(100, fatPercentage)}
|
||||||
color="#FF9800"
|
color="#FF9800"
|
||||||
|
resetToken={animationTrigger}
|
||||||
/>
|
/>
|
||||||
<NutritionRing
|
<NutritionRing
|
||||||
label="碳水"
|
label="碳水"
|
||||||
@@ -273,6 +319,7 @@ export default function FoodAnalysisResultScreen() {
|
|||||||
unit="克"
|
unit="克"
|
||||||
percentage={Math.min(100, carbohydratePercentage)}
|
percentage={Math.min(100, carbohydratePercentage)}
|
||||||
color="#2196F3"
|
color="#2196F3"
|
||||||
|
resetToken={animationTrigger}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -320,15 +367,15 @@ export default function FoodAnalysisResultScreen() {
|
|||||||
|
|
||||||
<View style={styles.foodIntakeCalories}>
|
<View style={styles.foodIntakeCalories}>
|
||||||
<Text style={styles.foodIntakeCaloriesValue}>{item.calories}千卡</Text>
|
<Text style={styles.foodIntakeCaloriesValue}>{item.calories}千卡</Text>
|
||||||
<TouchableOpacity style={styles.editButton}>
|
{shouldHideRecordBar ? null : <TouchableOpacity style={styles.editButton}>
|
||||||
<Ionicons name="create-outline" size={16} color="#666" />
|
<Ionicons name="create-outline" size={16} color="#666" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>}
|
||||||
<TouchableOpacity
|
{shouldHideRecordBar ? null : <TouchableOpacity
|
||||||
style={styles.deleteButton}
|
style={styles.deleteButton}
|
||||||
onPress={() => handleRemoveFood(item.id)}
|
onPress={() => handleRemoveFood(item.id)}
|
||||||
>
|
>
|
||||||
<Ionicons name="trash-outline" size={16} color="#666" />
|
<Ionicons name="trash-outline" size={16} color="#666" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -337,48 +384,50 @@ export default function FoodAnalysisResultScreen() {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* 底部餐次选择和记录按钮 */}
|
{/* 底部餐次选择和记录按钮 */}
|
||||||
{recognitionResult && !recognitionResult.isFoodDetected ? (
|
{!shouldHideRecordBar && (
|
||||||
// 非食物检测情况显示重新拍照按钮
|
recognitionResult && !recognitionResult.isFoodDetected ? (
|
||||||
<View style={styles.bottomContainer}>
|
// 非食物检测情况显示重新拍照按钮
|
||||||
<TouchableOpacity
|
<View style={styles.bottomContainer}>
|
||||||
style={styles.retakePhotoButton}
|
<TouchableOpacity
|
||||||
onPress={() => router.back()}
|
style={styles.retakePhotoButton}
|
||||||
activeOpacity={0.8}
|
onPress={() => router.back()}
|
||||||
>
|
activeOpacity={0.8}
|
||||||
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
|
>
|
||||||
<Text style={styles.retakePhotoButtonText}>重新拍照</Text>
|
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
|
||||||
</TouchableOpacity>
|
<Text style={styles.retakePhotoButtonText}>重新拍照</Text>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
) : (
|
</View>
|
||||||
// 正常食物识别情况
|
) : (
|
||||||
<View style={styles.bottomContainer}>
|
// 正常食物识别情况
|
||||||
<TouchableOpacity
|
<View style={styles.bottomContainer}>
|
||||||
style={styles.mealSelector}
|
<TouchableOpacity
|
||||||
onPress={() => setShowMealSelector(true)}
|
style={styles.mealSelector}
|
||||||
>
|
onPress={() => setShowMealSelector(true)}
|
||||||
<View style={[
|
>
|
||||||
styles.mealIndicator,
|
<View style={[
|
||||||
{ backgroundColor: mealOptions.find(option => option.key === currentMealType)?.color || '#FF6B35' }
|
styles.mealIndicator,
|
||||||
]} />
|
{ backgroundColor: mealOptions.find(option => option.key === currentMealType)?.color || '#FF6B35' }
|
||||||
<Text style={styles.mealText}>{MEAL_TYPE_MAP[currentMealType as keyof typeof MEAL_TYPE_MAP]}</Text>
|
]} />
|
||||||
<Ionicons name="chevron-down" size={16} color="#333" />
|
<Text style={styles.mealText}>{MEAL_TYPE_MAP[currentMealType as keyof typeof MEAL_TYPE_MAP]}</Text>
|
||||||
</TouchableOpacity>
|
<Ionicons name="chevron-down" size={16} color="#333" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.recordButton,
|
styles.recordButton,
|
||||||
(isRecording || foodItems.length === 0) && styles.recordButtonDisabled
|
(isRecording || foodItems.length === 0) && styles.recordButtonDisabled
|
||||||
]}
|
]}
|
||||||
disabled={isRecording || foodItems.length === 0}
|
disabled={isRecording || foodItems.length === 0}
|
||||||
onPress={handleSaveToDiary}
|
onPress={handleSaveToDiary}
|
||||||
>
|
>
|
||||||
{isRecording ? (
|
{isRecording ? (
|
||||||
<ActivityIndicator size="small" color="#FFF" />
|
<ActivityIndicator size="small" color="#FFF" />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.recordButtonText}>记录</Text>
|
<Text style={styles.recordButtonText}>记录</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 餐次选择弹窗 */}
|
{/* 餐次选择弹窗 */}
|
||||||
@@ -426,6 +475,39 @@ export default function FoodAnalysisResultScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 图片预览 */}
|
||||||
|
<ImageViewing
|
||||||
|
images={imageUri ? [{ uri: imageUri }] : []}
|
||||||
|
imageIndex={0}
|
||||||
|
visible={showImagePreview}
|
||||||
|
onRequestClose={() => {
|
||||||
|
console.log('ImageViewing onRequestClose called');
|
||||||
|
setShowImagePreview(false);
|
||||||
|
}}
|
||||||
|
swipeToCloseEnabled={true}
|
||||||
|
doubleTapToZoomEnabled={true}
|
||||||
|
HeaderComponent={() => (
|
||||||
|
<View style={styles.imageViewerHeader}>
|
||||||
|
<Text style={styles.imageViewerHeaderText}>
|
||||||
|
{recognitionResult ?
|
||||||
|
`置信度: ${recognitionResult.confidence}%` :
|
||||||
|
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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -436,24 +518,28 @@ function NutritionRing({
|
|||||||
value,
|
value,
|
||||||
unit,
|
unit,
|
||||||
percentage,
|
percentage,
|
||||||
color
|
color,
|
||||||
|
resetToken
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
color: string;
|
color: string;
|
||||||
|
resetToken?: unknown;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.nutritionRingContainer}>
|
<View style={styles.nutritionRingContainer}>
|
||||||
<View style={styles.ringWrapper}>
|
<View style={styles.ringWrapper}>
|
||||||
<CircularRing
|
<CircularRing
|
||||||
size={60}
|
size={60}
|
||||||
strokeWidth={4}
|
strokeWidth={6}
|
||||||
trackColor="#E2E8F0"
|
trackColor="#E2E8F0"
|
||||||
progressColor={color}
|
progressColor={color}
|
||||||
progress={percentage / 100}
|
progress={percentage / 100}
|
||||||
showCenterText={false}
|
showCenterText={false}
|
||||||
|
resetToken={resetToken}
|
||||||
|
durationMs={1200}
|
||||||
/>
|
/>
|
||||||
<View style={styles.ringCenter}>
|
<View style={styles.ringCenter}>
|
||||||
<Text style={styles.ringCenterText}>{percentage}%</Text>
|
<Text style={styles.ringCenterText}>{percentage}%</Text>
|
||||||
@@ -547,7 +633,8 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: Colors.light.background,
|
backgroundColor: Colors.light.background,
|
||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
marginTop: -60,
|
height: '100%',
|
||||||
|
marginTop: -24,
|
||||||
paddingTop: 24,
|
paddingTop: 24,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingBottom: 40,
|
paddingBottom: 40,
|
||||||
@@ -882,4 +969,74 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
|
placeholderContainer: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
placeholderContent: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
placeholderText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
// 预览提示图标样式
|
||||||
|
previewHint: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
// 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,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
imageViewerCloseButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
imageViewerHeaderText: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginLeft: -28, // 补偿关闭按钮的宽度,保持文字居中
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -159,21 +159,23 @@ export default function FoodCameraScreen() {
|
|||||||
<View style={styles.cameraFrameContainer}>
|
<View style={styles.cameraFrameContainer}>
|
||||||
<Text style={styles.hintText}>确保食物在取景框内</Text>
|
<Text style={styles.hintText}>确保食物在取景框内</Text>
|
||||||
|
|
||||||
{/* 相机取景框 */}
|
{/* 相机取景框包装器 */}
|
||||||
<View style={styles.cameraFrame}>
|
<View style={styles.cameraWrapper}>
|
||||||
<CameraView
|
{/* 相机取景框 */}
|
||||||
ref={cameraRef}
|
<View style={styles.cameraFrame}>
|
||||||
style={styles.cameraView}
|
<CameraView
|
||||||
facing={facing}
|
ref={cameraRef}
|
||||||
>
|
style={styles.cameraView}
|
||||||
{/* 取景框装饰 */}
|
facing={facing}
|
||||||
<View style={styles.viewfinderOverlay}>
|
/>
|
||||||
<View style={[styles.corner, styles.topLeft]} />
|
</View>
|
||||||
<View style={[styles.corner, styles.topRight]} />
|
{/* 取景框装饰 - 放在外层避免被截断 */}
|
||||||
<View style={[styles.corner, styles.bottomLeft]} />
|
<View style={styles.viewfinderOverlay}>
|
||||||
<View style={[styles.corner, styles.bottomRight]} />
|
<View style={[styles.corner, styles.topLeft]} />
|
||||||
</View>
|
<View style={[styles.corner, styles.topRight]} />
|
||||||
</CameraView>
|
<View style={[styles.corner, styles.bottomLeft]} />
|
||||||
|
<View style={[styles.corner, styles.bottomRight]} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -294,21 +296,29 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
},
|
},
|
||||||
|
cameraWrapper: {
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
cameraFrame: {
|
cameraFrame: {
|
||||||
width: 300,
|
width: 300,
|
||||||
height: 300,
|
height: 300,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
borderWidth: 3,
|
|
||||||
borderColor: '#FFF',
|
|
||||||
backgroundColor: '#000',
|
backgroundColor: '#000',
|
||||||
},
|
},
|
||||||
cameraView: {
|
cameraView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
viewfinderOverlay: {
|
viewfinderOverlay: {
|
||||||
flex: 1,
|
position: 'absolute',
|
||||||
position: 'relative',
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
pointerEvents: 'none',
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { CalorieRingChart } from '@/components/CalorieRingChart';
|
import { CalorieRingChart } from '@/components/CalorieRingChart';
|
||||||
import { DateSelector } from '@/components/DateSelector';
|
import { DateSelector } from '@/components/DateSelector';
|
||||||
|
import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay';
|
||||||
import { NutritionRecordCard } from '@/components/NutritionRecordCard';
|
import { NutritionRecordCard } from '@/components/NutritionRecordCard';
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { DietRecord } from '@/services/dietRecords';
|
import { DietRecord } from '@/services/dietRecords';
|
||||||
|
import { type FoodRecognitionResponse } from '@/services/foodRecognition';
|
||||||
|
import { saveRecognitionResult } from '@/store/foodRecognitionSlice';
|
||||||
import { selectHealthDataByDate } from '@/store/healthSlice';
|
import { selectHealthDataByDate } from '@/store/healthSlice';
|
||||||
import {
|
import {
|
||||||
deleteNutritionRecord,
|
deleteNutritionRecord,
|
||||||
@@ -70,6 +73,9 @@ export default function NutritionRecordsScreen() {
|
|||||||
const [hasMoreData, setHasMoreData] = useState(true);
|
const [hasMoreData, setHasMoreData] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
// 食物添加弹窗状态
|
||||||
|
const [showFoodOverlay, setShowFoodOverlay] = useState(false);
|
||||||
|
|
||||||
// 根据视图模式选择使用的数据
|
// 根据视图模式选择使用的数据
|
||||||
const displayRecords = viewMode === 'daily' ? nutritionRecords : allRecords;
|
const displayRecords = viewMode === 'daily' ? nutritionRecords : allRecords;
|
||||||
const loading = viewMode === 'daily' ? nutritionLoading.records : allRecordsLoading;
|
const loading = viewMode === 'daily' ? nutritionLoading.records : allRecordsLoading;
|
||||||
@@ -249,6 +255,51 @@ export default function NutritionRecordsScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理营养记录卡片点击
|
||||||
|
const handleRecordPress = (record: DietRecord) => {
|
||||||
|
// 将 DietRecord 转换为 FoodRecognitionResponse 格式
|
||||||
|
const recognitionResult: FoodRecognitionResponse = {
|
||||||
|
items: [{
|
||||||
|
id: record.id.toString(),
|
||||||
|
label: record.foodName,
|
||||||
|
foodName: record.foodName,
|
||||||
|
portion: record.portionDescription || `${record.estimatedCalories || 0}g`,
|
||||||
|
calories: record.estimatedCalories || 0,
|
||||||
|
mealType: record.mealType,
|
||||||
|
nutritionData: {
|
||||||
|
proteinGrams: record.proteinGrams || 0,
|
||||||
|
carbohydrateGrams: record.carbohydrateGrams || 0,
|
||||||
|
fatGrams: record.fatGrams || 0,
|
||||||
|
fiberGrams: 0, // DietRecord 中没有纤维数据,设为0
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
analysisText: record.foodDescription || `${record.foodName} - ${record.portionDescription}`,
|
||||||
|
confidence: 95, // 设置一个默认置信度
|
||||||
|
isFoodDetected: true,
|
||||||
|
nonFoodMessage: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成唯一的识别ID
|
||||||
|
const recognitionId = `record-${record.id}-${Date.now()}`;
|
||||||
|
|
||||||
|
// 保存到 Redux
|
||||||
|
dispatch(saveRecognitionResult({
|
||||||
|
id: recognitionId,
|
||||||
|
result: recognitionResult
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 跳转到分析结果页面
|
||||||
|
router.push({
|
||||||
|
pathname: '/food/analysis-result',
|
||||||
|
params: {
|
||||||
|
imageUri: record.imageUrl || '',
|
||||||
|
mealType: record.mealType,
|
||||||
|
recognitionId: recognitionId,
|
||||||
|
hideRecordBar: 'true'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 渲染视图模式切换器
|
// 渲染视图模式切换器
|
||||||
const renderViewModeToggle = () => (
|
const renderViewModeToggle = () => (
|
||||||
<View style={[styles.viewModeContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
<View style={[styles.viewModeContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||||
@@ -294,8 +345,12 @@ export default function NutritionRecordsScreen() {
|
|||||||
<DateSelector
|
<DateSelector
|
||||||
selectedIndex={selectedIndex}
|
selectedIndex={selectedIndex}
|
||||||
onDateSelect={(index, date) => setSelectedIndex(index)}
|
onDateSelect={(index, date) => setSelectedIndex(index)}
|
||||||
showMonthTitle={false}
|
showMonthTitle={true}
|
||||||
disableFutureDates={true}
|
disableFutureDates={true}
|
||||||
|
showCalendarIcon={true}
|
||||||
|
containerStyle={{
|
||||||
|
paddingHorizontal: 16
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -317,6 +372,7 @@ export default function NutritionRecordsScreen() {
|
|||||||
const renderRecord = ({ item, index }: { item: DietRecord; index: number }) => (
|
const renderRecord = ({ item, index }: { item: DietRecord; index: number }) => (
|
||||||
<NutritionRecordCard
|
<NutritionRecordCard
|
||||||
record={item}
|
record={item}
|
||||||
|
onPress={() => handleRecordPress(item)}
|
||||||
onDelete={() => handleDeleteRecord(item.id)}
|
onDelete={() => handleDeleteRecord(item.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -362,8 +418,7 @@ export default function NutritionRecordsScreen() {
|
|||||||
|
|
||||||
// 添加食物的处理函数
|
// 添加食物的处理函数
|
||||||
const handleAddFood = () => {
|
const handleAddFood = () => {
|
||||||
const mealType = getCurrentMealType();
|
setShowFoodOverlay(true);
|
||||||
router.push(`/food-library?mealType=${mealType}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染右侧添加按钮
|
// 渲染右侧添加按钮
|
||||||
@@ -385,7 +440,7 @@ export default function NutritionRecordsScreen() {
|
|||||||
right={renderRightButton()}
|
right={renderRightButton()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{renderViewModeToggle()}
|
{/* {renderViewModeToggle()} */}
|
||||||
{renderDateSelector()}
|
{renderDateSelector()}
|
||||||
|
|
||||||
{/* Calorie Ring Chart */}
|
{/* Calorie Ring Chart */}
|
||||||
@@ -425,6 +480,13 @@ export default function NutritionRecordsScreen() {
|
|||||||
onEndReachedThreshold={0.1}
|
onEndReachedThreshold={0.1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 食物添加悬浮窗 */}
|
||||||
|
<FloatingFoodOverlay
|
||||||
|
visible={showFoodOverlay}
|
||||||
|
onClose={() => setShowFoodOverlay(false)}
|
||||||
|
mealType={getCurrentMealType()}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,9 +115,6 @@ export function CalorieRingChart({
|
|||||||
<ThemedText style={[styles.centerValue, { color: textColor }]}>
|
<ThemedText style={[styles.centerValue, { color: textColor }]}>
|
||||||
{Math.round(canEat)}千卡
|
{Math.round(canEat)}千卡
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
<ThemedText style={[styles.centerPercentage, { color: textSecondaryColor }]}>
|
|
||||||
{Math.round(progressPercentage)}%
|
|
||||||
</ThemedText>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -187,7 +184,7 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
marginHorizontal: 16,
|
marginHorizontal: 16,
|
||||||
marginBottom: 16,
|
marginBottom: 8,
|
||||||
shadowColor: '#000000',
|
shadowColor: '#000000',
|
||||||
shadowOffset: { width: 0, height: 1 },
|
shadowOffset: { width: 0, height: 1 },
|
||||||
shadowOpacity: 0.04,
|
shadowOpacity: 0.04,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
@@ -28,6 +33,8 @@ export interface DateSelectorProps {
|
|||||||
dayItemStyle?: any;
|
dayItemStyle?: any;
|
||||||
/** 是否自动滚动到选中项 */
|
/** 是否自动滚动到选中项 */
|
||||||
autoScrollToSelected?: boolean;
|
autoScrollToSelected?: boolean;
|
||||||
|
/** 是否显示日历图标 */
|
||||||
|
showCalendarIcon?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DateSelector: React.FC<DateSelectorProps> = ({
|
export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||||
@@ -40,14 +47,16 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
|||||||
containerStyle,
|
containerStyle,
|
||||||
dayItemStyle,
|
dayItemStyle,
|
||||||
autoScrollToSelected = true,
|
autoScrollToSelected = true,
|
||||||
|
showCalendarIcon = true,
|
||||||
}) => {
|
}) => {
|
||||||
// 内部状态管理
|
// 内部状态管理
|
||||||
const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth());
|
const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份
|
||||||
const selectedIndex = externalSelectedIndex ?? internalSelectedIndex;
|
const selectedIndex = externalSelectedIndex ?? internalSelectedIndex;
|
||||||
|
|
||||||
// 获取日期数据
|
// 获取日期数据
|
||||||
const days = getMonthDaysZh();
|
const days = getMonthDaysZh(currentMonth);
|
||||||
const monthTitle = externalMonthTitle ?? getMonthTitleZh();
|
const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth);
|
||||||
|
|
||||||
// 滚动相关
|
// 滚动相关
|
||||||
const daysScrollRef = useRef<ScrollView | null>(null);
|
const daysScrollRef = useRef<ScrollView | null>(null);
|
||||||
@@ -55,6 +64,10 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
|||||||
const DAY_PILL_WIDTH = 48;
|
const DAY_PILL_WIDTH = 48;
|
||||||
const DAY_PILL_SPACING = 8;
|
const DAY_PILL_SPACING = 8;
|
||||||
|
|
||||||
|
// 日历弹窗相关
|
||||||
|
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||||
|
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||||||
|
|
||||||
// 滚动到指定索引
|
// 滚动到指定索引
|
||||||
const scrollToIndex = (index: number, animated = true) => {
|
const scrollToIndex = (index: number, animated = true) => {
|
||||||
if (!daysScrollRef.current || scrollWidth === 0) return;
|
if (!daysScrollRef.current || scrollWidth === 0) return;
|
||||||
@@ -103,6 +116,17 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
|||||||
}
|
}
|
||||||
}, [selectedIndex, autoScrollToSelected]);
|
}, [selectedIndex, autoScrollToSelected]);
|
||||||
|
|
||||||
|
// 当月份变化时,重新滚动到选中位置
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollWidth > 0 && autoScrollToSelected) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
scrollToIndex(selectedIndex, true);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [currentMonth, scrollWidth, autoScrollToSelected]);
|
||||||
|
|
||||||
// 处理日期选择
|
// 处理日期选择
|
||||||
const handleDateSelect = (index: number) => {
|
const handleDateSelect = (index: number) => {
|
||||||
const targetDate = days[index]?.date?.toDate();
|
const targetDate = days[index]?.date?.toDate();
|
||||||
@@ -127,10 +151,61 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
|||||||
onDateSelect?.(index, targetDate);
|
onDateSelect?.(index, targetDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 日历弹窗相关函数
|
||||||
|
const openDatePicker = () => {
|
||||||
|
const currentSelectedDate = days[selectedIndex]?.date?.toDate() || new Date();
|
||||||
|
setPickerDate(currentSelectedDate);
|
||||||
|
setDatePickerVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDatePicker = () => setDatePickerVisible(false);
|
||||||
|
|
||||||
|
const onConfirmDate = (date: Date) => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const picked = new Date(date);
|
||||||
|
picked.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// 如果禁用未来日期,则限制选择
|
||||||
|
const finalDate = (disableFutureDates && picked > today) ? today : picked;
|
||||||
|
|
||||||
|
closeDatePicker();
|
||||||
|
|
||||||
|
// 更新当前月份为选中日期的月份
|
||||||
|
const selectedMonth = dayjs(finalDate);
|
||||||
|
setCurrentMonth(selectedMonth);
|
||||||
|
|
||||||
|
// 计算选中日期在新月份中的索引
|
||||||
|
const newMonthDays = getMonthDaysZh(selectedMonth);
|
||||||
|
const selectedDay = selectedMonth.date();
|
||||||
|
const newSelectedIndex = newMonthDays.findIndex(day => day.dayOfMonth === selectedDay);
|
||||||
|
|
||||||
|
// 更新内部状态(如果使用外部控制则不更新)
|
||||||
|
if (externalSelectedIndex === undefined && newSelectedIndex !== -1) {
|
||||||
|
setInternalSelectedIndex(newSelectedIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用统一的日期选择回调
|
||||||
|
if (newSelectedIndex !== -1) {
|
||||||
|
onDateSelect?.(newSelectedIndex, finalDate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, containerStyle]}>
|
<View style={[styles.container, containerStyle]}>
|
||||||
{showMonthTitle && (
|
{showMonthTitle && (
|
||||||
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
<View style={styles.monthTitleContainer}>
|
||||||
|
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
||||||
|
{showCalendarIcon && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={openDatePicker}
|
||||||
|
style={styles.calendarIconButton}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="calendar-outline" size={14} color="#666666" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -176,6 +251,49 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 日历选择弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={datePickerVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={closeDatePicker}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
|
||||||
|
<View style={styles.modalSheet}>
|
||||||
|
<DateTimePicker
|
||||||
|
value={pickerDate}
|
||||||
|
mode="date"
|
||||||
|
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||||
|
minimumDate={dayjs().subtract(6, 'month').toDate()}
|
||||||
|
maximumDate={disableFutureDates ? new Date() : undefined}
|
||||||
|
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||||||
|
onChange={(event, date) => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
if (date) setPickerDate(date);
|
||||||
|
} else {
|
||||||
|
if (event.type === 'set' && date) {
|
||||||
|
onConfirmDate(date);
|
||||||
|
} else {
|
||||||
|
closeDatePicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Platform.OS === 'ios' && (
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||||
|
<Text style={styles.modalBtnText}>取消</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => {
|
||||||
|
onConfirmDate(pickerDate);
|
||||||
|
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||||
|
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -183,12 +301,22 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
|
||||||
|
},
|
||||||
|
monthTitleContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
monthTitle: {
|
monthTitle: {
|
||||||
fontSize: 18,
|
fontSize: 20,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
color: '#192126',
|
color: '#192126',
|
||||||
marginBottom: 14,
|
},
|
||||||
|
calendarIconButton: {
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginLeft: 4
|
||||||
},
|
},
|
||||||
daysContainer: {
|
daysContainer: {
|
||||||
paddingBottom: 8,
|
paddingBottom: 8,
|
||||||
@@ -243,4 +371,41 @@ const styles = StyleSheet.create({
|
|||||||
dayDateDisabled: {
|
dayDateDisabled: {
|
||||||
color: 'gray',
|
color: 'gray',
|
||||||
},
|
},
|
||||||
|
modalBackdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
},
|
||||||
|
modalSheet: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginTop: 8,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
modalBtn: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
},
|
||||||
|
modalBtnPrimary: {
|
||||||
|
backgroundColor: '#7a5af8',
|
||||||
|
},
|
||||||
|
modalBtnText: {
|
||||||
|
color: '#334155',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
modalBtnTextPrimary: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { NutritionSummary } from '@/services/dietRecords';
|
|||||||
import { NutritionGoals, calculateRemainingCalories } from '@/utils/nutrition';
|
import { NutritionGoals, calculateRemainingCalories } from '@/utils/nutrition';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { Animated, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import Svg, { Circle } from 'react-native-svg';
|
import Svg, { Circle } from 'react-native-svg';
|
||||||
|
|
||||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||||
@@ -137,6 +138,10 @@ export function NutritionRadarCard({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleNavigateToRecords = () => {
|
const handleNavigateToRecords = () => {
|
||||||
|
// ios 下震动反馈
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}
|
||||||
router.push(ROUTES.NUTRITION_RECORDS);
|
router.push(ROUTES.NUTRITION_RECORDS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user