diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index e4f8612..fb56769 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -10,11 +10,12 @@ import { Ionicons } from '@expo/vector-icons'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; import React, { useEffect, useMemo, useState } from 'react'; import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; -const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg'; +const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg'; export default function PersonalScreen() { const dispatch = useAppDispatch(); @@ -24,7 +25,6 @@ export default function PersonalScreen() { // 推送通知相关 const { - permissionStatus, requestPermission, sendNotification, } = useNotifications(); @@ -133,35 +133,37 @@ export default function PersonalScreen() { // 用户信息头部 const UserHeader = () => ( - - 个人信息 - - - - - - - {displayName} - - pushIfAuthedElseLogin('/profile/edit')}> - 编辑 - + + + + + + + {displayName} + + pushIfAuthedElseLogin('/profile/edit')}> + 编辑 + + ); // 数据统计部分 const StatsSection = () => ( - 身体数据 - + {formatHeight()} @@ -224,16 +226,6 @@ export default function PersonalScreen() { // 菜单项配置 const menuSections = [ - { - title: '账户', - items: [ - { - icon: 'flag-outline' as const, - title: '目标管理', - onPress: () => pushIfAuthedElseLogin('/profile/goals'), - }, - ], - }, { title: '通知', items: [ @@ -284,30 +276,45 @@ export default function PersonalScreen() { return ( - - - - - - {/* + + {/* 装饰性圆圈 */} + + + + + + + + {/* */} - 鱼干记录 - - - {menuSections.map((section, index) => ( - - ))} - - + 鱼干记录 + + + {menuSections.map((section, index) => ( + + ))} + ); } @@ -315,15 +322,36 @@ export default function PersonalScreen() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#F0F9FF', }, - safeArea: { - flex: 1, + gradientBackground: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + decorativeCircle1: { + position: 'absolute', + top: 40, + right: 20, + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#0EA5E9', + opacity: 0.1, + }, + decorativeCircle2: { + position: 'absolute', + bottom: -15, + left: -15, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#0EA5E9', + opacity: 0.05, }, scrollView: { flex: 1, - paddingHorizontal: 16, - paddingTop: 16, }, // 部分容器 sectionContainer: { @@ -453,3 +481,4 @@ const styles = StyleSheet.create({ marginLeft: 4, }, }); + diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index d0a1696..674a65f 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -34,7 +34,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { AppState, Image, - SafeAreaView, ScrollView, StyleSheet, Text, @@ -366,7 +365,7 @@ export default function ExploreScreen() { // 使用 lodash debounce 防抖的加载所有数据方法 const debouncedLoadAllData = React.useMemo( - () => debounce(executeLoadAllData, 300), // 300ms 防抖延迟 + () => debounce(executeLoadAllData, 500), // 500ms 防抖延迟 [executeLoadAllData] ); @@ -573,7 +572,7 @@ export default function ExploreScreen() { {/* 背景渐变 */} - - - {/* 顶部信息栏 */} - - - {/* 左边logo */} - + {/* 顶部信息栏 */} + + + {/* 左边logo */} + + + {/* 右边文字区域 */} + + 海豹健康 + + + + + + + {/* 日期选择器 */} + + + + + {/* 营养摄入雷达图卡片 */} + { + console.log('选择餐次:', mealType); + // 这里可以导航到营养记录页面 + pushIfAuthedElseLogin('/nutrition/records'); + }} + /> + + + + {/* 真正瀑布流布局 */} + + {/* 左列 */} + + {/* 心情卡片 */} + + pushIfAuthedElseLogin('/mood/calendar')} + isLoading={isMoodLoading} /> + - {/* 右边文字区域 */} - - 海豹健康 + + pushIfAuthedElseLogin('/steps/detail')} + /> + + + {/* 饮水记录卡片 */} + + + + + + + + + {/* 心率卡片 */} + + + + + + {/* 右列 */} + + + + + + + + 睡眠 - + {sleepDuration != null ? ( + + {Math.floor(sleepDuration / 60)}小时{Math.floor(sleepDuration % 60)}分钟 + + ) : ( + —— + )} + + + {/* 基础代谢卡片 */} + + + + + {/* 血氧饱和度卡片 */} + + + + + - - - - {/* 日期选择器 */} - - - - - {/* 营养摄入雷达图卡片 */} - { - console.log('选择餐次:', mealType); - // 这里可以导航到营养记录页面 - pushIfAuthedElseLogin('/nutrition/records'); - }} - /> - - - - {/* 真正瀑布流布局 */} - - {/* 左列 */} - - {/* 心情卡片 */} - - pushIfAuthedElseLogin('/mood/calendar')} - isLoading={isMoodLoading} - /> - - - - pushIfAuthedElseLogin('/steps/detail')} - /> - - - {/* 饮水记录卡片 */} - - - - - - - - - {/* 心率卡片 */} - - - - - - {/* 右列 */} - - - - - - - - 睡眠 - - {sleepDuration != null ? ( - - {Math.floor(sleepDuration / 60)}小时{Math.floor(sleepDuration % 60)}分钟 - - ) : ( - —— - )} - - - {/* 基础代谢卡片 */} - - - - - {/* 血氧饱和度卡片 */} - - - - - - - - - + + ); } @@ -771,12 +772,9 @@ const styles = StyleSheet.create({ backgroundColor: '#0EA5E9', opacity: 0.05, }, - safeArea: { - flex: 1, - }, + scrollView: { flex: 1, - paddingHorizontal: 20, }, headerContainer: { marginBottom: 10, diff --git a/app/food-camera.tsx b/app/food-camera.tsx new file mode 100644 index 0000000..60b340b --- /dev/null +++ b/app/food-camera.tsx @@ -0,0 +1,436 @@ +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { Colors } from '@/constants/Colors'; +import { Ionicons } from '@expo/vector-icons'; +import { CameraType, CameraView, useCameraPermissions } from 'expo-camera'; +import * as ImagePicker from 'expo-image-picker'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useRef, useState } from 'react'; +import { + Alert, + Dimensions, + StatusBar, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack'; + +const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); + +export default function FoodCameraScreen() { + const router = useRouter(); + const params = useLocalSearchParams<{ mealType?: string }>(); + const cameraRef = useRef(null); + + const [currentMealType, setCurrentMealType] = useState( + (params.mealType as MealType) || 'dinner' + ); + const [facing, setFacing] = useState('back'); + const [permission, requestPermission] = useCameraPermissions(); + + // 餐次选择选项 + const mealOptions = [ + { key: 'breakfast' as const, label: '早餐', icon: '☀️' }, + { key: 'lunch' as const, label: '午餐', icon: '🌤️' }, + { key: 'dinner' as const, label: '晚餐', icon: '🌙' }, + { key: 'snack' as const, label: '加餐', icon: '🍎' }, + ]; + + if (!permission) { + // 权限仍在加载中 + return ( + + router.back()} + transparent={true} + /> + + 正在加载相机... + + + ); + } + + if (!permission.granted) { + // 没有相机权限 + return ( + + router.back()} + transparent={true} + /> + + + 需要相机权限 + + 为了拍摄食物,需要访问您的相机 + + + 授权访问 + + + + ); + } + + // 切换相机前后摄像头 + function toggleCameraFacing() { + setFacing(current => (current === 'back' ? 'front' : 'back')); + } + + // 拍摄照片 + const takePicture = async () => { + if (cameraRef.current) { + try { + const photo = await cameraRef.current.takePictureAsync({ + quality: 0.8, + base64: false, + }); + + if (photo) { + // TODO: 处理拍摄的照片,可以传递到下一个页面进行食物识别 + console.log('照片拍摄成功:', photo.uri); + Alert.alert('拍摄成功', '照片已保存,后续会添加食物识别功能'); + // router.push(`/food-recognition?imageUri=${photo.uri}&mealType=${currentMealType}`); + } + } catch (error) { + console.error('拍照失败:', error); + Alert.alert('拍照失败', '请重试'); + } + } + }; + + // 从相册选择照片 + const pickImageFromGallery = async () => { + try { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [4, 3], + quality: 0.8, + }); + + if (!result.canceled && result.assets[0]) { + const imageUri = result.assets[0].uri; + console.log('从相册选择的照片:', imageUri); + Alert.alert('选择成功', '照片已选择,后续会添加食物识别功能'); + // router.push(`/food-recognition?imageUri=${imageUri}&mealType=${currentMealType}`); + } + } catch (error) { + console.error('选择照片失败:', error); + Alert.alert('选择失败', '请重试'); + } + }; + + // AR功能(暂时显示提示) + const handleARPress = () => { + Alert.alert('AR功能', 'AR食物识别功能即将推出'); + }; + + // 餐次选择 + const handleMealTypeChange = (mealType: MealType) => { + setCurrentMealType(mealType); + }; + + return ( + + + + {/* 相机视图 */} + + {/* 头部导航 */} + router.back()} + transparent={true} + backColor={'#fff'} + /> + + {/* 取景框和提示文本 */} + + 确保食物在辅助框内 + + {/* 取景框 */} + + + {/* 四个角的装饰 */} + + + + + + + + + {/* 餐次选择器 */} + + {mealOptions.map((option) => ( + handleMealTypeChange(option.key)} + > + {option.icon} + + {option.label} + + + ))} + + + {/* 底部控制栏 */} + + + {/* 相册按钮 */} + {/* + + + + 相册 + */} + + {/* 拍照按钮 */} + + + + + {/* AR按钮 */} + {/* + + AR + + AR + */} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000', + }, + camera: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#000', + }, + loadingText: { + color: '#FFF', + fontSize: 16, + }, + permissionContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#000', + paddingHorizontal: 40, + }, + permissionTitle: { + color: '#FFF', + fontSize: 20, + fontWeight: '600', + marginTop: 20, + marginBottom: 10, + }, + permissionText: { + color: '#CCC', + fontSize: 16, + textAlign: 'center', + marginBottom: 30, + lineHeight: 22, + }, + permissionButton: { + backgroundColor: Colors.light.primary, + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 24, + }, + permissionButtonText: { + color: '#FFF', + fontSize: 16, + fontWeight: '600', + }, + header: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 10, + }, + overlayContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingTop: 100, + }, + hintText: { + color: '#FFF', + fontSize: 16, + fontWeight: '500', + marginBottom: 30, + textAlign: 'center', + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 3, + }, + viewfinderContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + viewfinder: { + width: 280, + height: 280, + position: 'relative', + }, + corner: { + position: 'absolute', + width: 30, + height: 30, + borderColor: '#FFF', + borderWidth: 3, + }, + topLeft: { + top: 0, + left: 0, + borderRightWidth: 0, + borderBottomWidth: 0, + }, + topRight: { + top: 0, + right: 0, + borderLeftWidth: 0, + borderBottomWidth: 0, + }, + bottomLeft: { + bottom: 0, + left: 0, + borderRightWidth: 0, + borderTopWidth: 0, + }, + bottomRight: { + bottom: 0, + right: 0, + borderLeftWidth: 0, + borderTopWidth: 0, + }, + mealTypeContainer: { + flexDirection: 'row', + justifyContent: 'center', + paddingHorizontal: 20, + marginBottom: 20, + }, + mealTypeButton: { + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + marginHorizontal: 8, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + minWidth: 70, + }, + mealTypeButtonActive: { + backgroundColor: 'rgba(255, 255, 255, 0.9)', + }, + mealTypeIcon: { + fontSize: 20, + marginBottom: 2, + }, + mealTypeText: { + color: '#FFF', + fontSize: 12, + fontWeight: '500', + }, + mealTypeTextActive: { + color: '#333', + }, + bottomContainer: { + backgroundColor: 'rgba(0, 0, 0, 0.3)', + }, + controlsContainer: { + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + paddingVertical: 20, + paddingHorizontal: 40, + }, + controlButton: { + alignItems: 'center', + }, + controlButtonText: { + color: '#FFF', + fontSize: 12, + marginTop: 8, + fontWeight: '500', + }, + albumButton: { + width: 50, + height: 50, + borderRadius: 10, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: '#FFF', + }, + captureButton: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: '#FFF', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 4, + borderColor: 'rgba(255, 255, 255, 0.3)', + }, + captureButtonInner: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#FFF', + borderWidth: 2, + borderColor: '#333', + }, + arButton: { + width: 50, + height: 50, + borderRadius: 25, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: '#FFF', + }, + arButtonText: { + color: '#FFF', + fontSize: 14, + fontWeight: 'bold', + }, +}); \ No newline at end of file diff --git a/app/profile/edit.tsx b/app/profile/edit.tsx index ded429a..88fdd15 100644 --- a/app/profile/edit.tsx +++ b/app/profile/edit.tsx @@ -70,9 +70,6 @@ export default function EditProfileScreen() { activityLevel: undefined, }); - const [weightInput, setWeightInput] = useState(''); - const [heightInput, setHeightInput] = useState(''); - // 出生日期选择器 const [datePickerVisible, setDatePickerVisible] = useState(false); const [pickerDate, setPickerDate] = useState(new Date()); @@ -115,8 +112,7 @@ export default function EditProfileScreen() { } console.log('loadLocalProfile', next); setProfile((prev) => ({ ...next, avatarUri: prev.avatarUri ?? next.avatarUri ?? null })); - setWeightInput(next.weight != null ? String(round(next.weight, 1)) : ''); - setHeightInput(next.height != null ? String(Math.round(next.height)) : ''); + } catch (e) { console.warn('读取资料失败', e); } @@ -276,7 +272,7 @@ export default function EditProfileScreen() { - + @@ -394,11 +390,11 @@ export default function EditProfileScreen() { } else if (field === 'height') { updatedProfile.height = parseFloat(value) || undefined; setProfile(p => ({ ...p, height: parseFloat(value) || undefined })); - setHeightInput(value); + } else if (field === 'weight') { updatedProfile.weight = parseFloat(value) || undefined; setProfile(p => ({ ...p, weight: parseFloat(value) || undefined })); - setWeightInput(value); + } else if (field === 'activity') { const activityLevel = parseInt(value) as number; updatedProfile.activityLevel = activityLevel; diff --git a/components/FloatingFoodOverlay.tsx b/components/FloatingFoodOverlay.tsx new file mode 100644 index 0000000..ab187ad --- /dev/null +++ b/components/FloatingFoodOverlay.tsx @@ -0,0 +1,192 @@ +import { ROUTES } from '@/constants/Routes'; +import { Ionicons } from '@expo/vector-icons'; +import { BlurView } from 'expo-blur'; +import { useRouter } from 'expo-router'; +import React from 'react'; +import { + Modal, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +interface FloatingFoodOverlayProps { + visible: boolean; + onClose: () => void; + mealType?: string; +} + +export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: FloatingFoodOverlayProps) { + const router = useRouter(); + + const handleFoodLibrary = () => { + onClose(); + router.push(`${ROUTES.FOOD_LIBRARY}?mealType=${mealType}`); + }; + + const handlePhotoRecognition = () => { + onClose(); + router.push(`/food-camera?mealType=${mealType}`); + }; + + const menuItems = [ + { + id: 'scan', + title: '扫描', + icon: '📷', + backgroundColor: '#4FC3F7', + onPress: handlePhotoRecognition, + }, + { + id: 'food-library', + title: '食物库', + icon: '🍎', + backgroundColor: '#FF9500', + onPress: handleFoodLibrary, + }, + ]; + + return ( + + + + + + + + 日常记录 + + + + {menuItems.map((item) => ( + + + {item.icon} + + {item.title} + + ))} + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.4)', + justifyContent: 'center', + alignItems: 'center', + }, + backdrop: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + container: { + alignItems: 'center', + }, + blurContainer: { + borderRadius: 20, + overflow: 'hidden', + backgroundColor: 'rgba(255, 255, 255, 0.95)', + minWidth: 340, + paddingVertical: 20, + paddingHorizontal: 16, + minHeight: 100 + }, + header: { + paddingBottom: 20, + alignItems: 'center', + }, + title: { + fontSize: 16, + fontWeight: '600', + color: '#333', + }, + menuGrid: { + flexDirection: 'row', + justifyContent: 'space-around', + gap: 32, + }, + menuItem: { + alignItems: 'center', + flex: 1, + }, + iconContainer: { + width: 48, + height: 48, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 8, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.15, + shadowRadius: 4, + elevation: 4, + }, + iconText: { + fontSize: 22, + }, + menuText: { + fontSize: 13, + fontWeight: '500', + color: '#333', + textAlign: 'center', + }, + closeButton: { + marginTop: 20, + }, + closeButtonInner: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(255, 255, 255, 0.9)', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, +}); \ No newline at end of file diff --git a/components/NutritionRadarCard.tsx b/components/NutritionRadarCard.tsx index 69db314..aa05d7c 100644 --- a/components/NutritionRadarCard.tsx +++ b/components/NutritionRadarCard.tsx @@ -1,4 +1,5 @@ import { AnimatedNumber } from '@/components/AnimatedNumber'; +import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay'; import { ROUTES } from '@/constants/Routes'; import { NutritionSummary } from '@/services/dietRecords'; import { NutritionGoals, calculateRemainingCalories } from '@/utils/nutrition'; @@ -48,6 +49,7 @@ export function NutritionRadarCard({ onMealPress }: NutritionRadarCardProps) { const [currentMealType, setCurrentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast'); + const [showFoodOverlay, setShowFoodOverlay] = useState(false); const radarValues = useMemo(() => { // 基于动态计算的营养目标或默认推荐值 const recommendations = { @@ -108,7 +110,7 @@ export function NutritionRadarCard({ }; const handleAddFood = () => { - router.push(`/food-library?mealType=${currentMealType}`); + setShowFoodOverlay(true); }; return ( @@ -192,6 +194,12 @@ export function NutritionRadarCard({ + {/* 食物添加悬浮窗 */} + setShowFoodOverlay(false)} + mealType={currentMealType} + /> ); diff --git a/components/ui/HeaderBar.tsx b/components/ui/HeaderBar.tsx index d763d81..3bf7dff 100644 --- a/components/ui/HeaderBar.tsx +++ b/components/ui/HeaderBar.tsx @@ -9,6 +9,7 @@ import { useColorScheme } from '@/hooks/useColorScheme'; export type HeaderBarProps = { title: string | React.ReactNode; onBack?: () => void; + backColor?: string; right?: React.ReactNode; tone?: 'light' | 'dark'; showBottomBorder?: boolean; @@ -20,6 +21,7 @@ export type HeaderBarProps = { export function HeaderBar({ title, onBack, + backColor, right, tone, showBottomBorder = false, @@ -34,7 +36,7 @@ export function HeaderBar({ // 根据变体确定背景色和样式 const getBackgroundColor = () => { if (transparent) return 'transparent'; - + switch (variant) { case 'elevated': return theme.background; @@ -47,11 +49,11 @@ export function HeaderBar({ const getBorderStyle = () => { if (!showBottomBorder) return {}; - + return { borderBottomWidth: 1, - borderBottomColor: variant === 'elevated' - ? theme.border + borderBottomColor: variant === 'elevated' + ? theme.border : `${theme.border}40`, // 40% 透明度 }; }; @@ -75,16 +77,16 @@ export function HeaderBar({ ]} > {onBack ? ( - - ) : ( @@ -94,8 +96,8 @@ export function HeaderBar({ {typeof title === 'string' ? (