From 5a59508b884120b9a98719584d8779fe6eb96e3c Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 28 Aug 2025 09:46:14 +0800 Subject: [PATCH] =?UTF-8?q?refactor(coach):=20=E9=87=8D=E6=9E=84=E6=95=99?= =?UTF-8?q?=E7=BB=83=E7=BB=84=E4=BB=B6=EF=BC=8C=E7=BB=9F=E4=B8=80=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=B9=B6=E7=AE=80=E5=8C=96UI=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=B8=8E=E7=B1=BB=E5=9E=8B=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/coach.tsx | 797 ++++----------------- app/weight-records.tsx | 335 ++++----- components/NumberKeyboard.tsx | 147 ++++ components/coach/ChatComposer.tsx | 246 +++++++ components/coach/ChatMessage.tsx | 450 ++++++++++++ components/coach/DietInputCard.tsx | 128 ++++ components/coach/DietOptionsCard.tsx | 123 ++++ components/coach/DietPlanCard.tsx | 425 +++++++++++ components/coach/QuickChips.tsx | 66 ++ components/coach/WeightInputCard.tsx | 83 +++ components/coach/index.ts | 11 + components/coach/types.ts | 88 +++ components/weight/WeightHistoryCard.tsx | 10 - components/weight/WeightRecordCard.tsx | 183 +++++ docs/weight-records-edit-delete.md | 75 ++ ios/digitalpilates/SplashScreen.storyboard | 24 +- store/userSlice.ts | 75 +- 17 files changed, 2400 insertions(+), 866 deletions(-) create mode 100644 components/NumberKeyboard.tsx create mode 100644 components/coach/ChatComposer.tsx create mode 100644 components/coach/ChatMessage.tsx create mode 100644 components/coach/DietInputCard.tsx create mode 100644 components/coach/DietOptionsCard.tsx create mode 100644 components/coach/DietPlanCard.tsx create mode 100644 components/coach/QuickChips.tsx create mode 100644 components/coach/WeightInputCard.tsx create mode 100644 components/coach/index.ts create mode 100644 components/coach/types.ts create mode 100644 components/weight/WeightRecordCard.tsx create mode 100644 docs/weight-records-edit-delete.md diff --git a/app/(tabs)/coach.tsx b/app/(tabs)/coach.tsx index d89f287..a6f6366 100644 --- a/app/(tabs)/coach.tsx +++ b/app/(tabs)/coach.tsx @@ -1,25 +1,20 @@ import { Ionicons } from '@expo/vector-icons'; -import { BlurView } from 'expo-blur'; import * as Haptics from 'expo-haptics'; import * as ImagePicker from 'expo-image-picker'; import { LinearGradient } from 'expo-linear-gradient'; import { useLocalSearchParams } from 'expo-router'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - ActivityIndicator, Alert, FlatList, Keyboard, Modal, Platform, - ScrollView, StyleSheet, Text, - TextInput, TouchableOpacity, View } from 'react-native'; -import Markdown from 'react-native-markdown-display'; import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -38,86 +33,29 @@ import { Image } from 'expo-image'; import { HistoryModal } from '../../components/model/HistoryModal'; import { ActionSheet } from '../../components/ui/ActionSheet'; -type Role = 'user' | 'assistant'; - -// 附件类型枚举 -type AttachmentType = 'image' | 'video' | 'file'; - -// 附件数据结构 -type MessageAttachment = { - id: string; - type: AttachmentType; - url: string; - localUri?: string; // 本地URI,用于上传中的显示 - filename?: string; - size?: number; - duration?: number; // 视频时长(秒) - thumbnail?: string; // 视频缩略图 - width?: number; - height?: number; - uploadProgress?: number; // 上传进度 0-1 - uploadError?: string; // 上传错误信息 -}; - -// AI选择选项数据结构 -type AiChoiceOption = { - id: string; - label: string; - value: any; - recommended?: boolean; - emoji?: string; -}; - -// 餐次类型 -type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack'; - -// 食物确认选项数据结构(暂未使用,预留给未来功能扩展) -// type FoodConfirmationOption = { -// id: string; -// label: string; -// foodName: string; -// portion: string; -// calories: number; -// mealType: MealType; -// nutritionData: { -// proteinGrams?: number; -// carbohydrateGrams?: number; -// fatGrams?: number; -// fiberGrams?: number; -// }; -// }; +// 导入新的 coach 组件 +import { + CardType, + ChatComposer, + ChatMessage as ChatMessageComponent, + DietInputCard, + DietOptionsCard, + DietPlanCard, + WeightInputCard, + type QuickChip, + type SelectedImage +} from '@/components/coach'; +import { AiChoiceOption, AttachmentType, ChatMessage, Role } from '@/components/coach/types'; // AI响应数据结构 type AiResponseData = { content: string; - choices?: AiChoiceOption[]; + choices?: any[]; interactionType?: 'text' | 'food_confirmation' | 'selection'; pendingData?: any; context?: any; }; -// 重构后的消息数据结构 -type ChatMessage = { - id: string; - role: Role; - content: string; // 文本内容 - attachments?: MessageAttachment[]; // 附件列表 - choices?: AiChoiceOption[]; // 选择选项(仅用于assistant消息) - interactionType?: string; // 交互类型 - pendingData?: any; // 待确认数据 - context?: any; // 上下文信息 -}; - -// 卡片类型常量定义 -const CardType = { - WEIGHT_INPUT: '__WEIGHT_INPUT_CARD__', - DIET_INPUT: '__DIET_INPUT_CARD__', - DIET_TEXT_INPUT: '__DIET_TEXT_INPUT__', - DIET_PLAN: '__DIET_PLAN_CARD__', -} as const; - -type CardType = typeof CardType[keyof typeof CardType]; - // 定义路由参数类型 type CoachScreenParams = { @@ -136,7 +74,6 @@ export default function CoachScreen() { // 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色 const colorScheme = useColorScheme(); const theme = Colors[colorScheme ?? 'light']; - const botName = (params?.name || 'Seal').toString(); const [input, setInput] = useState(''); const [isSending, setIsSending] = useState(false); const [isStreaming, setIsStreaming] = useState(false); @@ -158,16 +95,7 @@ export default function CoachScreen() { const [keyboardOffset, setKeyboardOffset] = useState(0); const [headerHeight, setHeaderHeight] = useState(60); const pendingAssistantIdRef = useRef(null); - const [selectedImages, setSelectedImages] = useState<{ - id: string; - localUri: string; - width?: number; - height?: number; - progress: number; - uploadedKey?: string; - uploadedUrl?: string; - error?: string; - }[]>([]); + const [selectedImages, setSelectedImages] = useState([]); const [previewImageUri, setPreviewImageUri] = useState(null); const [dietTextInputs, setDietTextInputs] = useState>({}); const [weightInputs, setWeightInputs] = useState>({}); @@ -195,23 +123,23 @@ export default function CoachScreen() { pilatesPurposes: userProfile.pilatesPurposes } : undefined; - const chips = useMemo(() => [ + const chips: QuickChip[] = useMemo(() => [ // { key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') }, // { key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() }, // { key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() }, { key: 'weight', label: '#记体重', action: () => insertWeightInputCard() }, { key: 'diet', label: '#记饮食', action: () => insertDietInputCard() }, { key: 'dietPlan', label: '#饮食方案', action: () => insertDietPlanCard() }, - { - key: 'mood', - label: '#记心情', - action: () => { - if (Platform.OS === 'ios') { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - pushIfAuthedElseLogin('/mood/calendar'); - } - }, + // { + // key: 'mood', + // label: '#记心情', + // action: () => { + // if (Platform.OS === 'ios') { + // Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + // } + // pushIfAuthedElseLogin('/mood/calendar'); + // } + // }, ], [planDraft, checkin]); const scrollToEnd = useCallback(() => { @@ -1082,132 +1010,12 @@ export default function CoachScreen() { setSelectedImages((prev) => prev.filter((it) => it.id !== id)); }, []); - // 渲染单个附件 - function renderAttachment(attachment: MessageAttachment, isUser: boolean) { - const { type, url, localUri, uploadProgress, uploadError, width, height, filename } = attachment; - if (type === 'image') { - const imageUri = url || localUri; - if (!imageUri) return null; - - return ( - - setPreviewImageUri(imageUri)} - style={styles.imageAttachment} - > - - {uploadProgress !== undefined && uploadProgress < 1 && ( - - - {Math.round(uploadProgress * 100)}% - - - )} - {uploadError && ( - - 上传失败 - - )} - - - ); - } - - if (type === 'video') { - // 视频附件的实现 - return ( - - - - - {filename || '视频文件'} - - - - ); - } - - if (type === 'file') { - // 文件附件的实现 - return ( - - - - - {filename || 'unknown_file'} - - - - ); - } - - return null; - } - - // 渲染所有附件 - function renderAttachments(attachments: MessageAttachment[], isUser: boolean) { - if (!attachments || attachments.length === 0) return null; - - return ( - - {attachments.map(attachment => renderAttachment(attachment, isUser))} - - ); - } function renderItem({ item }: { item: ChatMessage }) { const isUser = item.role === 'user'; - return ( - - - {renderBubbleContent(item)} - {renderAttachments(item.attachments || [], isUser)} - - - - ); - } - - function renderBubbleContent(item: ChatMessage) { - if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) { - return ( - - 正在思考… - - - 取消 - - - ); - } + // 处理特殊卡片类型 if (item.content?.startsWith(CardType.WEIGHT_INPUT)) { const cardId = item.id; const preset = (() => { @@ -1220,378 +1028,110 @@ export default function CoachScreen() { } })(); - // 初始化输入值(如果还没有的话) - const currentValue = weightInputs[cardId] ?? preset; return ( - - 记录今日体重 - - setWeightInputs(prev => ({ ...prev, [cardId]: text }))} - onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text, cardId)} - returnKeyType="done" - submitBehavior="blurAndSubmit" - /> - kg - handleSubmitWeight(currentValue, cardId)} - > - 记录 - - - 按回车或点击保存,即可将该体重同步到账户并发送到对话。 - + + setWeightInputs(prev => ({ ...prev, [id]: value }))} + onSaveWeight={(id) => handleSubmitWeight(weightInputs[id], id)} + /> + ); } if (item.content?.startsWith(CardType.DIET_INPUT)) { return ( - - 记录今日饮食 - 请选择记录方式: - - - handleDietTextInput(item.id)} - > - - - - - 文字记录 - 输入吃了什么、大概多少克 - - - - handleDietPhotoInput(item.id)} - > - - - - - 拍照识别 - 拍摄食物照片进行AI分析 - - - - - 选择合适的方式记录您的饮食,Seal会根据您的饮食情况给出专业的营养建议。 - + + { + if (optionId === 'text') { + handleDietTextInput(cardId); + } else if (optionId === 'photo') { + handleDietPhotoInput(cardId); + } + }} + /> + ); } if (item.content?.startsWith(CardType.DIET_TEXT_INPUT)) { const cardId = item.content.split('\n')?.[1] || ''; - const currentText = dietTextInputs[cardId] || ''; return ( - - - 文字记录饮食 - handleBackToDietOptions(cardId)} - style={styles.dietBackBtn} - > - - - - - setDietTextInputs(prev => ({ ...prev, [cardId]: text }))} - returnKeyType="done" + + setDietTextInputs(prev => ({ ...prev, [id]: value }))} + onSubmitDietText={(id) => handleSubmitDietText(dietTextInputs[id], id)} + onBackToDietOptions={handleBackToDietOptions} + onShowDietPhotoActionSheet={(id) => { + setCurrentCardId(id); + setShowDietPhotoActionSheet(true); + }} /> - - handleSubmitDietText(currentText, cardId)} - > - 发送记录 - - - 详细描述您的饮食内容和分量,有助于Seal给出更精准的营养分析和建议。 - + ); } if (item.content?.startsWith(CardType.DIET_PLAN)) { - const cardId = item.content.split('\n')?.[1] || ''; - - // 获取用户数据 - const weight = userProfile?.weight ? Number(userProfile.weight) : 58; - const height = userProfile?.height ? Number(userProfile.height) : 160; - - // 计算年龄 - const calculateAge = (birthday?: string): number => { - if (!birthday) return 25; // 默认年龄 - try { - const birthDate = new Date(birthday); - const today = new Date(); - let age = today.getFullYear() - birthDate.getFullYear(); - const monthDiff = today.getMonth() - birthDate.getMonth(); - if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { - age--; - } - return age > 0 ? age : 25; - } catch { - return 25; - } - }; - - const age = calculateAge(userProfile?.birthDate); - const gender = userProfile?.gender || 'female'; - const name = userProfile?.name || '用户'; - - // 计算相关数据 - const bmi = calculateBMI(weight, height); - const bmiStatus = getBMIStatus(bmi); - const dailyCalories = calculateDailyCalories(weight, height, age, gender); - const nutrition = calculateNutritionDistribution(dailyCalories); - return ( - - {/* 标题部分 */} - - - - 我的饮食方案 - - MY DIET PLAN - - - {/* 我的档案数据 */} - - 我的档案数据 - - - - {name.charAt(0)} - - - - - {age} - 年龄/岁 - - - {height.toFixed(1)} - 身高/CM - - - {weight.toFixed(1)} - 体重/KG - - - - - - {/* BMI部分 */} - - - 当前BMI - - {bmiStatus.status} - - - {bmi.toFixed(1)} - - - - - - - - 偏瘦 - 正常 - 偏胖 - 肥胖 - - - - {/* 饮食目标 */} - - - 饮食目标 - - - - - {/* 目标体重 */} - - - 目标体重 - - - - - {/* 每日推荐摄入 */} - - - 每日推荐摄入 - {dailyCalories}千卡 - - - - - {nutrition.carbs}g - - - 碳水 - - - - {nutrition.protein}g - - - 蛋白质 - - - - {nutrition.fat}g - - - 脂肪 - - - - - - 根据您的基础代谢率,这到理想体重所需要的热量缺口计算得出每日的热量推荐值。 - - - - {/* 底部按钮 */} - { - // 这里可以添加跳转到详细饮食方案页面的逻辑 + + { console.log('跳转到饮食方案详情'); }} - > - - 饮食方案 - - - ); - } - - // 在流式回复过程中显示取消按钮 - if (isStreaming && pendingAssistantIdRef.current === item.id && item.content?.trim()) { - return ( - - - {item.content} - - - - 停止生成 - - - ); - } - - // 检查是否有选择选项需要显示 - if (item.choices && item.choices.length > 0 && (item.interactionType === 'food_confirmation' || item.interactionType === 'selection')) { - return ( - - - {item.content || ''} - - - {item.choices.map((choice) => { - const isSelected = selectedChoices[item.id] === choice.id; - const isAnySelected = selectedChoices[item.id] != null; - const isPending = pendingChoiceConfirmation[item.id]; - const isDisabled = isAnySelected && !isSelected; - - return ( - { - if (!isDisabled && !isPending) { - Haptics.selectionAsync(); - handleChoiceSelection(choice, item); - } - }} - > - - - {choice.emoji && ( - {choice.emoji} - )} - - {choice.label} - - - - {choice.recommended && !isSelected && ( - - 推荐 - - )} - {isSelected && isPending && ( - - )} - {isSelected && !isPending && ( - - 已选择 - - )} - - - - ); - })} - - + /> + ); } + // 普通消息使用 ChatMessageComponent return ( - - {item.content || ''} - + + { + const choice = item.choices?.find(c => c.id === choiceId); + if (choice) { + handleChoiceSelection(choice, item); + } + }} + selectedChoices={selectedChoices} + pendingChoiceConfirmation={pendingChoiceConfirmation} + isStreaming={isStreaming && pendingAssistantIdRef.current === item.id} + onCancelStream={cancelCurrentRequest} + /> + ); } + + function insertWeightInputCard() { const id = `wcard_${Date.now()}`; const preset = userProfile?.weight ? Number(userProfile.weight) : undefined; @@ -1970,122 +1510,33 @@ export default function CoachScreen() { /> - { const h = e.nativeEvent.layout.height; if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h); }} > - - {chips.map((c) => ( - - {c.label} - - ))} - - - {!!selectedImages.length && ( - - {selectedImages.map((img) => ( - - setPreviewImageUri(img.uploadedUrl || img.localUri)}> - - - {!!(img.progress > 0 && img.progress < 1) && ( - - {Math.round((img.progress || 0) * 100)}% - - )} - {img.error && ( - - uploadImage(img)} - style={styles.imageRetryBtn} - > - - - - )} - removeSelectedImage(img.id)} style={styles.imageRemoveBtn}> - - - - ))} - - )} - - - - - - send(input)} - submitBehavior="blurAndSubmit" - /> - { - if (isSending || isStreaming) { - cancelCurrentRequest(); - } else { - send(input); - } - }} - style={[ - styles.sendBtn, - { - backgroundColor: (isSending || isStreaming) ? theme.danger : theme.primary, - opacity: ((input.trim() || selectedImages.length > 0) || (isSending || isStreaming)) ? 1 : 0.5 - } - ]} - > - {isSending ? ( - - ) : isStreaming ? ( - - ) : ( - - )} - - - + send(input)} + onPickImages={pickImages} + onCancelRequest={cancelCurrentRequest} + selectedImages={selectedImages} + onRemoveImage={removeSelectedImage} + onPreviewImage={setPreviewImageUri} + isSending={isSending} + isStreaming={isStreaming} + chips={chips} + /> + {!isAtBottom && ( s.user.profile); const weightHistory = useAppSelector((s) => s.user.weightHistory); const [showWeightPicker, setShowWeightPicker] = useState(false); - const [pickerType, setPickerType] = useState<'current' | 'initial' | 'target'>('current'); + const [pickerType, setPickerType] = useState<'current' | 'initial' | 'target' | 'edit'>('current'); const [inputWeight, setInputWeight] = useState(''); + const [editingRecord, setEditingRecord] = useState(null); const colorScheme = useColorScheme(); const themeColors = Colors[colorScheme ?? 'light']; @@ -74,6 +76,23 @@ export default function WeightRecordsPage() { setShowWeightPicker(true); }; + const handleEditWeightRecord = (record: WeightHistoryItem) => { + setPickerType('edit'); + setEditingRecord(record); + initializeInput(parseFloat(record.weight)); + setShowWeightPicker(true); + }; + + const handleDeleteWeightRecord = async (id: string) => { + try { + await dispatch(deleteWeightRecord(id) as any); + await loadWeightHistory(); + } catch (error) { + console.error('删除体重记录失败:', error); + Alert.alert('错误', '删除体重记录失败,请重试'); + } + }; + const handleWeightSave = async () => { const weight = parseFloat(inputWeight); if (isNaN(weight) || weight <= 0 || weight > 500) { @@ -88,20 +107,45 @@ export default function WeightRecordsPage() { } else if (pickerType === 'initial') { // Update initial weight in profile console.log('更新初始体重'); - await dispatch(updateUserProfile({ initialWeight: weight }) as any); } else if (pickerType === 'target') { // Update target weight in profile await dispatch(updateUserProfile({ targetWeight: weight }) as any); + } else if (pickerType === 'edit' && editingRecord) { + await dispatch(updateWeightRecord({ id: editingRecord.id, weight }) as any); } setShowWeightPicker(false); setInputWeight(''); + setEditingRecord(null); await loadWeightHistory(); } catch (error) { console.error('保存体重失败:', error); + Alert.alert('错误', '保存体重失败,请重试'); } }; + const handleNumberPress = (number: string) => { + setInputWeight(prev => { + // 防止输入多个0开头 + if (prev === '0' && number === '0') return prev; + // 如果当前是0,输入非0数字时替换 + if (prev === '0' && number !== '0') return number; + return prev + number; + }); + }; + + const handleDeletePress = () => { + setInputWeight(prev => prev.slice(0, -1)); + }; + + const handleDecimalPress = () => { + setInputWeight(prev => { + if (prev.includes('.')) return prev; + // 如果没有输入任何数字,自动添加0 + if (!prev) return '0.'; + return prev + '.'; + }); + }; // Process weight history data const sortedHistory = [...weightHistory] @@ -210,38 +254,13 @@ export default function WeightRecordsPage() { parseFloat(record.weight) - parseFloat(prevRecord.weight) : 0; return ( - - - - {dayjs(record.createdAt).format('MM月DD日 HH:mm')} - - {/* - - */} - - - 体重: - {record.weight}kg - {Math.abs(weightChange) > 0 && ( - - - - {Math.abs(weightChange).toFixed(1)} - - - )} - - + ); })} @@ -263,90 +282,99 @@ export default function WeightRecordsPage() { transparent onRequestClose={() => setShowWeightPicker(false)} > - setShowWeightPicker(false)} - /> - - {/* Header */} - - setShowWeightPicker(false)}> - - - - {pickerType === 'current' && '记录体重'} - {pickerType === 'initial' && '编辑初始体重'} - {pickerType === 'target' && '编辑目标体重'} - - - - - - {/* Weight Input Section */} - - - - - - - - kg - - - - {/* Weight Range Hint */} - - 请输入 0-500 之间的数值,支持小数 + + setShowWeightPicker(false)} + /> + + {/* Header */} + + setShowWeightPicker(false)}> + + + + {pickerType === 'current' && '记录体重'} + {pickerType === 'initial' && '编辑初始体重'} + {pickerType === 'target' && '编辑目标体重'} + {pickerType === 'edit' && '编辑体重记录'} + - {/* Quick Selection */} - - 快速选择 - - {[50, 60, 70, 80, 90].map((weight) => ( - setInputWeight(weight.toString())} - > - - {weight}kg - - - ))} - - - - - {/* Save Button */} - - - 确定 - + {/* Weight Display Section */} + + + + + + + + {inputWeight || '输入体重'} + + kg + + + + {/* Weight Range Hint */} + + 请输入 0-500 之间的数值,支持小数 + + + + {/* Quick Selection */} + + 快速选择 + + {[50, 60, 70, 80, 90].map((weight) => ( + setInputWeight(weight.toString())} + > + + {weight}kg + + + ))} + + + + + {/* Custom Number Keyboard */} + + + {/* Save Button */} + + + 确定 + + @@ -486,62 +514,7 @@ const styles = StyleSheet.create({ fontWeight: '700', color: '#192126', }, - recordCard: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 20, - marginBottom: 12, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.06, - shadowRadius: 8, - elevation: 2, - }, - recordHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 12, - }, - recordDateTime: { - fontSize: 14, - color: '#687076', - fontWeight: '500', - }, - recordEditButton: { - padding: 6, - borderRadius: 8, - backgroundColor: 'rgba(255, 149, 0, 0.1)', - }, - recordContent: { - flexDirection: 'row', - alignItems: 'center', - }, - recordWeightLabel: { - fontSize: 16, - color: '#687076', - fontWeight: '500', - }, - recordWeightValue: { - fontSize: 18, - fontWeight: '700', - color: '#192126', - marginLeft: 4, - flex: 1, - }, - weightChangeTag: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 12, - marginLeft: 12, - }, - weightChangeText: { - fontSize: 12, - fontWeight: '600', - marginLeft: 2, - }, + emptyContainer: { flex: 1, justifyContent: 'center', @@ -562,6 +535,9 @@ const styles = StyleSheet.create({ color: '#687076', }, // Modal Styles + modalContainer: { + flex: 1, + }, modalBackdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.35)', @@ -573,7 +549,8 @@ const styles = StyleSheet.create({ bottom: 0, borderTopLeftRadius: 20, borderTopRightRadius: 20, - maxHeight: '80%', + maxHeight: '85%', + minHeight: 500, }, modalHeader: { flexDirection: 'row', @@ -588,8 +565,8 @@ const styles = StyleSheet.create({ fontWeight: '600', }, modalContent: { + flex: 1, paddingHorizontal: 20, - paddingBottom: 10, }, inputSection: { backgroundColor: '#FFFFFF', @@ -619,11 +596,12 @@ const styles = StyleSheet.create({ borderBottomColor: '#E5E7EB', paddingBottom: 6, }, - weightInput: { + weightDisplay: { flex: 1, fontSize: 24, fontWeight: '600', textAlign: 'center', + paddingVertical: 4, }, unitLabel: { fontSize: 18, @@ -637,7 +615,7 @@ const styles = StyleSheet.create({ }, quickSelectionSection: { paddingHorizontal: 4, - marginBottom: 8, + marginBottom: 20, }, quickSelectionTitle: { fontSize: 16, @@ -675,7 +653,8 @@ const styles = StyleSheet.create({ fontWeight: '600', }, modalFooter: { - padding: 20, + paddingHorizontal: 20, + paddingTop: 16, paddingBottom: 25, }, saveButton: { diff --git a/components/NumberKeyboard.tsx b/components/NumberKeyboard.tsx new file mode 100644 index 0000000..bcd4f24 --- /dev/null +++ b/components/NumberKeyboard.tsx @@ -0,0 +1,147 @@ +import { Ionicons } from '@expo/vector-icons'; +import React from 'react'; +import { + Dimensions, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +interface NumberKeyboardProps { + onNumberPress: (number: string) => void; + onDeletePress: () => void; + onDecimalPress: () => void; + hasDecimal?: boolean; + maxLength?: number; + currentValue?: string; +} + +const { width } = Dimensions.get('window'); +const keyWidth = (width - 80) / 3; // 减去左右边距和间隙 + +export default function NumberKeyboard({ + onNumberPress, + onDeletePress, + onDecimalPress, + hasDecimal = false, + maxLength = 6, + currentValue = '', +}: NumberKeyboardProps) { + const handleNumberPress = (number: string) => { + if (currentValue.length >= maxLength) return; + // 防止输入多个0开头 + if (currentValue === '0' && number === '0') return; + // 如果当前是0,输入非0数字时替换 + if (currentValue === '0' && number !== '0') { + // 这里不需要replace,直接传递number即可 + return; + } + onNumberPress(number); + }; + + const handleDecimalPress = () => { + if (hasDecimal || currentValue.includes('.')) return; + onDecimalPress(); + }; + + const renderKey = ( + value: string, + onPress: () => void, + style?: any, + textStyle?: any, + disabled?: boolean + ) => ( + + {value === 'delete' ? ( + + ) : ( + + {value} + + )} + + ); + + return ( + + + {renderKey('1', () => handleNumberPress('1'))} + {renderKey('2', () => handleNumberPress('2'))} + {renderKey('3', () => handleNumberPress('3'))} + + + {renderKey('4', () => handleNumberPress('4'))} + {renderKey('5', () => handleNumberPress('5'))} + {renderKey('6', () => handleNumberPress('6'))} + + + {renderKey('7', () => handleNumberPress('7'))} + {renderKey('8', () => handleNumberPress('8'))} + {renderKey('9', () => handleNumberPress('9'))} + + + {renderKey( + '.', + handleDecimalPress, + undefined, + undefined, + hasDecimal || currentValue.includes('.') + )} + {renderKey('0', () => handleNumberPress('0'))} + {renderKey('delete', onDeletePress)} + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#F9FAFB', + paddingVertical: 20, + paddingHorizontal: 20, + borderTopWidth: 1, + borderTopColor: '#E5E7EB', + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 12, + }, + key: { + height: 50, + backgroundColor: '#FFFFFF', + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + keyDisabled: { + backgroundColor: '#F3F4F6', + opacity: 0.5, + }, + keyText: { + fontSize: 20, + fontWeight: '600', + color: '#374151', + }, + keyTextDisabled: { + color: '#9CA3AF', + }, +}); \ No newline at end of file diff --git a/components/coach/ChatComposer.tsx b/components/coach/ChatComposer.tsx new file mode 100644 index 0000000..c696cb5 --- /dev/null +++ b/components/coach/ChatComposer.tsx @@ -0,0 +1,246 @@ +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { Ionicons } from '@expo/vector-icons'; +import { BlurView } from 'expo-blur'; +import { Image } from 'expo-image'; +import React from 'react'; +import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import QuickChips from './QuickChips'; +import { QuickChip, SelectedImage } from './types'; + +interface ChatComposerProps { + input: string; + onInputChange: (text: string) => void; + onSend: () => void; + onPickImages: () => void; + onCancelRequest: () => void; + selectedImages: SelectedImage[]; + onRemoveImage: (id: string) => void; + onPreviewImage: (uri: string) => void; + isSending: boolean; + isStreaming: boolean; + chips: QuickChip[]; +} + +const ChatComposer: React.FC = ({ + input, + onInputChange, + onSend, + onPickImages, + onCancelRequest, + selectedImages, + onRemoveImage, + onPreviewImage, + isSending, + isStreaming, + chips +}) => { + const colorScheme = useColorScheme(); + const theme = Colors[colorScheme ?? 'light']; + + const hasContent = input.trim() || selectedImages.length > 0; + const isActive = isSending || isStreaming; + + return ( + + {/* 快捷操作按钮 */} + + + {/* 选中的图片预览 */} + {selectedImages.length > 0 && ( + + {selectedImages.map((img) => ( + + onPreviewImage(img.localUri)} + style={styles.imageThumb} + > + + + + {/* 上传进度 */} + {img.progress > 0 && img.progress < 1 && ( + + + {Math.round(img.progress * 100)}% + + + )} + + {/* 上传错误 */} + {img.error && ( + + {/* 重试上传逻辑 */ }} + style={styles.imageRetryBtn} + > + + + + )} + + {/* 删除按钮 */} + onRemoveImage(img.id)} + style={styles.imageRemoveBtn} + > + + + + ))} + + )} + + {/* 输入区域 */} + + + + + + + + + {isActive ? ( + + ) : ( + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + composerWrap: { + paddingTop: 8, + paddingHorizontal: 10, + borderTopWidth: 0, + }, + imagesRow: { + maxHeight: 92, + marginBottom: 8, + }, + imagesRowContent: { + gap: 8, + paddingHorizontal: 6, + }, + imageThumbWrap: { + width: 72, + height: 72, + borderRadius: 12, + overflow: 'hidden', + position: 'relative', + backgroundColor: 'rgba(122,90,248,0.08)', + }, + imageThumb: { + width: '100%', + height: '100%', + }, + imageRemoveBtn: { + position: 'absolute', + right: 4, + top: 4, + width: 20, + height: 20, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.45)', + }, + imageProgressOverlay: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.35)', + alignItems: 'center', + justifyContent: 'center', + }, + imageProgressText: { + color: '#fff', + fontWeight: '700', + fontSize: 12, + }, + imageErrorOverlay: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + backgroundColor: 'rgba(255,0,0,0.35)', + alignItems: 'center', + justifyContent: 'center', + }, + imageRetryBtn: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.6)', + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, + }, + mediaBtn: { + width: 40, + height: 40, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + marginRight: 6, + }, + input: { + flex: 1, + fontSize: 15, + maxHeight: 120, + minHeight: 40, + paddingHorizontal: 8, + paddingVertical: 6, + textAlignVertical: 'center', + }, + sendBtn: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export default ChatComposer; \ No newline at end of file diff --git a/components/coach/ChatMessage.tsx b/components/coach/ChatMessage.tsx new file mode 100644 index 0000000..68a88b6 --- /dev/null +++ b/components/coach/ChatMessage.tsx @@ -0,0 +1,450 @@ +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { Ionicons } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import Markdown from 'react-native-markdown-display'; +import { ChatMessage, MessageAttachment } from './types'; + +interface MessageAttachmentComponentProps { + attachment: MessageAttachment; + onPreview?: (uri: string) => void; +} + +const MessageAttachmentComponent: React.FC = ({ + attachment, + onPreview +}) => { + const colorScheme = useColorScheme(); + const theme = Colors[colorScheme ?? 'light']; + + if (attachment.type === 'image') { + return ( + onPreview?.(attachment.url)} + activeOpacity={0.8} + > + + {attachment.uploadProgress !== undefined && attachment.uploadProgress < 1 && ( + + + {Math.round(attachment.uploadProgress * 100)}% + + + )} + {attachment.uploadError && ( + + 上传失败 + + )} + + ); + } + + if (attachment.type === 'video') { + return ( + + + + {attachment.filename && ( + {attachment.filename} + )} + + + ); + } + + if (attachment.type === 'file') { + return ( + + + + {attachment.filename || '未知文件'} + + + ); + } + + return null; +}; + +interface ChatMessageComponentProps { + message: ChatMessage; + onPreviewImage?: (uri: string) => void; + onChoiceSelect?: (messageId: string, choiceId: string) => void; + selectedChoices?: Record; + pendingChoiceConfirmation?: Record; + isStreaming?: boolean; + onCancelStream?: () => void; +} + +const ChatMessageComponent: React.FC = ({ + message, + onPreviewImage, + onChoiceSelect, + selectedChoices, + pendingChoiceConfirmation, + isStreaming, + onCancelStream +}) => { + const colorScheme = useColorScheme(); + const theme = Colors[colorScheme ?? 'light']; + + const isUser = message.role === 'user'; + const isAssistant = message.role === 'assistant'; + + return ( + + {isAssistant && ( + + AI + + )} + + + {/* 文本内容 */} + {message.content ? ( + isUser ? ( + + {message.content} + + ) : ( + + {message.content} + + ) + ) : null} + + {/* 附件 */} + {message.attachments && message.attachments.length > 0 && ( + + {message.attachments.map((attachment) => ( + + ))} + + )} + + {/* 选择选项 */} + {message.choices && message.choices.length > 0 && ( + + {message.choices.map((choice) => { + const isSelected = selectedChoices?.[message.id] === choice.id; + const isLoading = pendingChoiceConfirmation?.[message.id]; + const isDisabled = isLoading || (selectedChoices?.[message.id] && !isSelected) || false; + + return ( + onChoiceSelect?.(message.id, choice.id)} + disabled={isDisabled} + > + + + {choice.emoji ? `${choice.emoji} ` : ''}{choice.label} + + + + {!!choice.recommended && !isSelected && ( + + 推荐 + + )} + {isSelected && ( + + 已选择 + + )} + + + + ); + })} + + )} + + {/* 流式回复控制 */} + {isAssistant && isStreaming && !message.content && ( + + 正在思考... + + + 停止 + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: 8, + marginVertical: 6, + }, + userRow: { + justifyContent: 'flex-end', + }, + avatar: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + }, + avatarText: { + color: '#192126', + fontSize: 12, + fontWeight: '800', + }, + bubble: { + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 16, + }, + bubbleText: { + fontSize: 15, + lineHeight: 22, + }, + attachmentsContainer: { + marginTop: 8, + gap: 6, + }, + imageAttachment: { + borderRadius: 12, + overflow: 'hidden', + position: 'relative', + }, + attachmentImage: { + width: '100%', + minHeight: 120, + maxHeight: 200, + borderRadius: 12, + }, + attachmentProgressOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.4)', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 12, + }, + attachmentProgressText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + attachmentErrorOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(255,0,0,0.4)', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 12, + }, + attachmentErrorText: { + color: '#fff', + fontSize: 12, + fontWeight: '600', + }, + videoAttachment: { + borderRadius: 12, + overflow: 'hidden', + backgroundColor: 'rgba(0,0,0,0.1)', + }, + videoPlaceholder: { + height: 120, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.6)', + }, + videoFilename: { + color: '#fff', + fontSize: 12, + marginTop: 4, + textAlign: 'center', + }, + fileAttachment: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + backgroundColor: 'rgba(0,0,0,0.06)', + borderRadius: 8, + gap: 8, + }, + fileFilename: { + flex: 1, + fontSize: 14, + }, + choicesContainer: { + gap: 8, + width: '100%', + marginTop: 8, + }, + choiceButton: { + backgroundColor: 'rgba(255,255,255,0.9)', + borderWidth: 1, + borderColor: '#7a5af84d', + borderRadius: 12, + padding: 12, + width: '100%', + minWidth: 0, + }, + choiceButtonRecommended: { + borderColor: '#7a5af899', + backgroundColor: '#7a5af81a', + }, + choiceButtonSelected: { + borderColor: '#19b36e', + backgroundColor: '#19b36e33', + borderWidth: 2, + }, + choiceButtonDisabled: { + backgroundColor: 'rgba(0,0,0,0.05)', + borderColor: 'rgba(0,0,0,0.1)', + opacity: 0.5, + }, + choiceContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + }, + choiceLabel: { + fontSize: 15, + fontWeight: '600', + color: '#192126', + flex: 1, + flexWrap: 'wrap', + }, + choiceLabelRecommended: { + color: '#19b36e', + }, + choiceLabelSelected: { + color: '#19b36e', + fontWeight: '700', + }, + choiceLabelDisabled: { + color: '#687076', + }, + choiceStatusContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + recommendedBadge: { + backgroundColor: '#7a5af8cc', + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 2, + }, + recommendedText: { + fontSize: 12, + fontWeight: '700', + color: '#19b36e', + }, + selectedBadge: { + backgroundColor: '#19b36e', + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 2, + }, + selectedText: { + fontSize: 12, + fontWeight: '700', + color: '#FFFFFF', + }, + streamingContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + }, + cancelStreamBtn: { + 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)', + }, + cancelStreamText: { + fontSize: 12, + fontWeight: '600', + color: '#FF4444', + }, +}); + +export default ChatMessageComponent; \ No newline at end of file diff --git a/components/coach/DietInputCard.tsx b/components/coach/DietInputCard.tsx new file mode 100644 index 0000000..e94f1a8 --- /dev/null +++ b/components/coach/DietInputCard.tsx @@ -0,0 +1,128 @@ +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { Ionicons } from '@expo/vector-icons'; +import React from 'react'; +import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; + +interface DietInputCardProps { + cardId: string; + dietTextInputs: Record; + onDietTextInputChange: (cardId: string, value: string) => void; + onSubmitDietText: (cardId: string) => void; + onBackToDietOptions: (cardId: string) => void; + onShowDietPhotoActionSheet: (cardId: string) => void; +} + +const DietInputCard: React.FC = ({ + cardId, + dietTextInputs, + onDietTextInputChange, + onSubmitDietText, + onBackToDietOptions, + onShowDietPhotoActionSheet +}) => { + const colorScheme = useColorScheme(); + const theme = Colors[colorScheme ?? 'light']; + + return ( + + + onBackToDietOptions(cardId)} + > + + + 记录饮食 + onShowDietPhotoActionSheet(cardId)} + > + + + + + onDietTextInputChange(cardId, text)} + multiline + textAlignVertical="top" + /> + + onSubmitDietText(cardId)} + > + 提交 + + + ); +}; + +const styles = StyleSheet.create({ + bubble: { + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 16, + maxWidth: '85%', + alignSelf: 'flex-start', + gap: 12, + minWidth: 300 + }, + dietInputHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + dietBackBtn: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.06)', + }, + dietInputTitle: { + fontSize: 16, + fontWeight: '600', + flex: 1, + textAlign: 'center', + }, + photoBtn: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + }, + dietTextInput: { + minHeight: 80, + borderWidth: 1, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + textAlignVertical: 'top', + }, + dietSubmitBtn: { + height: 40, + paddingHorizontal: 16, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + alignSelf: 'flex-end', + }, + submitButtonText: { + fontSize: 14, + fontWeight: '600', + }, +}); + +export default DietInputCard; \ No newline at end of file diff --git a/components/coach/DietOptionsCard.tsx b/components/coach/DietOptionsCard.tsx new file mode 100644 index 0000000..5e871c4 --- /dev/null +++ b/components/coach/DietOptionsCard.tsx @@ -0,0 +1,123 @@ +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { Ionicons } from '@expo/vector-icons'; +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +interface DietOption { + id: string; + title: string; + description: string; + icon: string; + action: () => void; +} + +interface DietOptionsCardProps { + cardId: string; + onSelectOption: (cardId: string, optionId: string) => void; +} + +const DietOptionsCard: React.FC = ({ + cardId, + onSelectOption +}) => { + const colorScheme = useColorScheme(); + const theme = Colors[colorScheme ?? 'light']; + + const dietOptions: DietOption[] = [ + { + id: 'photo', + title: '拍照记录', + description: '拍摄食物照片,AI自动识别', + icon: 'camera', + action: () => onSelectOption(cardId, 'photo') + }, + { + id: 'text', + title: '文字描述', + description: '用文字描述你吃了什么', + icon: 'create', + action: () => onSelectOption(cardId, 'text') + } + ]; + + return ( + + 选择记录方式 + + {dietOptions.map((option) => ( + + + + + + + {option.title} + + + {option.description} + + + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + bubble: { + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 16, + maxWidth: '85%', + alignSelf: 'flex-start', + gap: 12, + minWidth: 300 + }, + title: { + fontSize: 16, + fontWeight: '600', + textAlign: 'center', + }, + dietOptionsContainer: { + gap: 8, + }, + dietOptionBtn: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + borderRadius: 12, + borderWidth: 1, + }, + dietOptionIconContainer: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + dietOptionTextContainer: { + flex: 1, + }, + dietOptionTitle: { + fontSize: 15, + fontWeight: '700', + }, + dietOptionDesc: { + fontSize: 13, + marginTop: 2, + }, +}); + +export default DietOptionsCard; \ No newline at end of file diff --git a/components/coach/DietPlanCard.tsx b/components/coach/DietPlanCard.tsx new file mode 100644 index 0000000..c7960d9 --- /dev/null +++ b/components/coach/DietPlanCard.tsx @@ -0,0 +1,425 @@ +import { Colors } from '@/constants/Colors'; +import { useAppSelector } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { selectUserAge } from '@/store/userSlice'; +import { Ionicons } from '@expo/vector-icons'; +import React, { useState } from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +interface DietPlanCardProps { + onGeneratePlan: () => void; +} + +const DietPlanCard: React.FC = ({ onGeneratePlan }) => { + const colorScheme = useColorScheme(); + const theme = Colors[colorScheme ?? 'light']; + const [isExpanded, setIsExpanded] = useState(false); + + const userProfile = useAppSelector((s) => s.user?.profile); + const userAge = useAppSelector(selectUserAge); + + // 计算BMI + const calculateBMI = () => { + if (!userProfile?.weight || !userProfile?.height) return null; + const weight = Number(userProfile.weight); + const height = Number(userProfile.height) / 100; // 转换为米 + return weight / (height * height); + }; + + const bmi = calculateBMI(); + + // 获取BMI状态 + const getBMIStatus = (bmi: number) => { + if (bmi < 18.5) return { text: '偏瘦', color: '#3B82F6' }; + if (bmi < 24) return { text: '正常', color: '#10B981' }; + if (bmi < 28) return { text: '超重', color: '#F59E0B' }; + return { text: '肥胖', color: '#EF4444' }; + }; + + const bmiStatus = bmi ? getBMIStatus(bmi) : null; + + // 估算基础代谢率 (BMR) + const calculateBMR = () => { + if (!userProfile?.weight || !userProfile?.height || userAge === null) return null; + + const weight = Number(userProfile.weight); + const height = Number(userProfile.height); + const age = userAge; + const gender = userProfile.gender; + + // 使用 Mifflin-St Jeor 公式 + if (gender === 'male') { + return Math.round(10 * weight + 6.25 * height - 5 * age + 5); + } else { + return Math.round(10 * weight + 6.25 * height - 5 * age - 161); + } + }; + + const bmr = calculateBMR(); + const dailyCalories = bmr ? Math.round(bmr * 1.4) : null; // 轻度活动系数 + + return ( + + {/* 头部 */} + + + + 个性化饮食方案 + + + 基于你的身体数据定制 + + + + {/* 用户资料概览 */} + {userProfile && ( + + 个人资料 + + + + + {userProfile.name?.charAt(0) || 'U'} + + + + + {userProfile.weight && ( + + + {userProfile.weight} + + kg + + )} + {userProfile.height && ( + + + {userProfile.height} + + cm + + )} + {userAge !== null && ( + + + {userAge} + + + + )} + + + + )} + + {/* BMI 部分 */} + {bmi && bmiStatus && ( + + + BMI 指数 + + {bmiStatus.text} + + + + {bmi.toFixed(1)} + + + {/* BMI 刻度条 */} + + + + + + + + 偏瘦 + 正常 + 超重 + 肥胖 + + + )} + + {/* 可折叠的详细信息 */} + setIsExpanded(!isExpanded)} + > + + 营养需求分析 + + + + + {isExpanded && ( + <> + {/* 卡路里需求 */} + {dailyCalories && ( + + + 每日卡路里需求 + + {dailyCalories} kcal + + + + )} + + {/* 营养素分配 */} + + + 55% + + + 碳水 + + + + 20% + + + 蛋白质 + + + + 25% + + + 脂肪 + + + + + + * 营养素比例基于一般健康成人推荐标准,具体需求因人而异 + + + )} + + {/* 生成方案按钮 */} + + + 生成个性化饮食方案 + + + {/* 使用次数提示 */} + + + + AI 将根据你的身体数据和健康目标制定专属方案 + + + + ); +}; + +const styles = StyleSheet.create({ + dietPlanContainer: { + borderRadius: 16, + padding: 16, + gap: 16, + borderWidth: 1, + maxWidth: '85%', + alignSelf: 'flex-start', + minWidth: 300 + }, + dietPlanHeader: { + gap: 4, + }, + dietPlanTitleContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + dietPlanTitle: { + fontSize: 18, + fontWeight: '800', + }, + dietPlanSubtitle: { + fontSize: 12, + fontWeight: '600', + letterSpacing: 1, + }, + profileSection: { + gap: 12, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '700', + }, + profileDataRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, + avatarContainer: { + alignItems: 'center', + }, + avatar: { + width: 48, + height: 48, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + }, + avatarText: { + color: '#FFFFFF', + fontSize: 18, + fontWeight: '800', + }, + profileStats: { + flexDirection: 'row', + flex: 1, + justifyContent: 'space-around', + }, + statItem: { + alignItems: 'center', + gap: 4, + }, + statValue: { + fontSize: 20, + fontWeight: '800', + }, + statLabel: { + fontSize: 12, + }, + bmiSection: { + gap: 12, + }, + bmiHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + bmiStatusBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + }, + bmiStatusText: { + fontSize: 12, + fontWeight: '700', + color: '#FFFFFF', + }, + bmiValue: { + fontSize: 32, + fontWeight: '800', + textAlign: 'center', + }, + bmiScale: { + flexDirection: 'row', + height: 8, + borderRadius: 4, + overflow: 'hidden', + gap: 1, + }, + bmiBar: { + flex: 1, + height: '100%', + }, + bmiLabels: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 4, + }, + bmiLabel: { + fontSize: 11, + }, + collapsibleSection: { + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.06)', + }, + collapsibleHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + caloriesSection: { + gap: 12, + }, + caloriesHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + caloriesValue: { + fontSize: 18, + fontWeight: '800', + }, + nutritionGrid: { + flexDirection: 'row', + justifyContent: 'space-around', + gap: 16, + }, + nutritionItem: { + alignItems: 'center', + gap: 8, + }, + nutritionValue: { + fontSize: 24, + fontWeight: '800', + }, + nutritionLabelRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + nutritionDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + nutritionLabel: { + fontSize: 12, + }, + nutritionNote: { + fontSize: 12, + lineHeight: 16, + textAlign: 'center', + marginTop: 8, + }, + dietPlanButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 12, + marginTop: 8, + }, + dietPlanButtonText: { + fontSize: 14, + fontWeight: '700', + color: '#FFFFFF', + }, + usageCountContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + backgroundColor: 'rgba(122,90,248,0.08)', + }, + usageText: { + fontSize: 12, + fontWeight: '600', + flex: 1, + }, +}); + +export default DietPlanCard; \ No newline at end of file diff --git a/components/coach/QuickChips.tsx b/components/coach/QuickChips.tsx new file mode 100644 index 0000000..625d01d --- /dev/null +++ b/components/coach/QuickChips.tsx @@ -0,0 +1,66 @@ +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import React from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity } from 'react-native'; +import { QuickChip } from './types'; + +interface QuickChipsProps { + chips: QuickChip[]; +} + +const QuickChips: React.FC = ({ chips }) => { + const colorScheme = useColorScheme(); + const theme = Colors[colorScheme ?? 'light']; + + return ( + + {chips.map((chip) => ( + + + {chip.label} + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + chipsRowScroll: { + marginBottom: 8, + }, + chipsRow: { + flexDirection: 'row', + gap: 8, + paddingHorizontal: 6, + }, + chip: { + paddingHorizontal: 10, + height: 34, + borderRadius: 18, + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center', + }, + chipText: { + fontSize: 13, + fontWeight: '600', + }, +}); + +export default QuickChips; \ No newline at end of file diff --git a/components/coach/WeightInputCard.tsx b/components/coach/WeightInputCard.tsx new file mode 100644 index 0000000..1356661 --- /dev/null +++ b/components/coach/WeightInputCard.tsx @@ -0,0 +1,83 @@ +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import React from 'react'; +import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; + +interface WeightInputCardProps { + cardId: string; + weightInputs: Record; + onWeightInputChange: (cardId: string, value: string) => void; + onSaveWeight: (cardId: string) => void; +} + +const WeightInputCard: React.FC = ({ + cardId, + weightInputs, + onWeightInputChange, + onSaveWeight +}) => { + const colorScheme = useColorScheme(); + const theme = Colors[colorScheme ?? 'light']; + + return ( + + + onWeightInputChange(cardId, text)} + keyboardType="numeric" + /> + kg + onSaveWeight(cardId)} + > + 保存 + + + + ); +}; + +const styles = StyleSheet.create({ + bubble: { + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 16, + maxWidth: '85%', + alignSelf: 'flex-start', + minWidth: 250 + }, + weightRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + weightInput: { + flex: 1, + height: 36, + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 10, + backgroundColor: 'rgba(255,255,255,0.9)', + }, + weightUnit: { + fontWeight: '700', + }, + weightSaveBtn: { + height: 36, + paddingHorizontal: 12, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + saveButtonText: { + fontSize: 14, + fontWeight: '600', + }, +}); + +export default WeightInputCard; \ No newline at end of file diff --git a/components/coach/index.ts b/components/coach/index.ts new file mode 100644 index 0000000..06fd1d2 --- /dev/null +++ b/components/coach/index.ts @@ -0,0 +1,11 @@ +// Coach 组件导出 +export { default as ChatComposer } from './ChatComposer'; +export { default as ChatMessage } from './ChatMessage'; +export { default as DietInputCard } from './DietInputCard'; +export { default as DietOptionsCard } from './DietOptionsCard'; +export { default as DietPlanCard } from './DietPlanCard'; +export { default as QuickChips } from './QuickChips'; +export { default as WeightInputCard } from './WeightInputCard'; + +// 类型导出 +export * from './types'; diff --git a/components/coach/types.ts b/components/coach/types.ts new file mode 100644 index 0000000..cc31207 --- /dev/null +++ b/components/coach/types.ts @@ -0,0 +1,88 @@ +export type Role = 'user' | 'assistant'; + +// 附件类型枚举 +export type AttachmentType = 'image' | 'video' | 'file'; + +// 附件数据结构 +export type MessageAttachment = { + id: string; + type: AttachmentType; + url: string; + localUri?: string; // 本地URI,用于上传中的显示 + filename?: string; + size?: number; + duration?: number; // 视频时长(秒) + thumbnail?: string; // 视频缩略图 + width?: number; + height?: number; + uploadProgress?: number; // 上传进度 0-1 + uploadError?: string; // 上传错误信息 +}; + +// AI选择选项数据结构 +export type AiChoiceOption = { + id: string; + label: string; + value: any; + recommended?: boolean; + emoji?: string; +}; + +// AI响应数据结构 +export type AiResponseData = { + content: string; + choices?: AiChoiceOption[]; + interactionType?: 'text' | 'food_confirmation' | 'selection'; + pendingData?: any; + context?: any; +}; + +// 重构后的消息数据结构 +export type ChatMessage = { + id: string; + role: Role; + content: string; // 文本内容 + attachments?: MessageAttachment[]; // 附件列表 + choices?: AiChoiceOption[]; // 选择选项(仅用于assistant消息) + interactionType?: string; // 交互类型 + pendingData?: any; // 待确认数据 + context?: any; // 上下文信息 +}; + +// 卡片类型常量定义 +export const CardType = { + WEIGHT_INPUT: '__WEIGHT_INPUT_CARD__', + DIET_INPUT: '__DIET_INPUT_CARD__', + DIET_TEXT_INPUT: '__DIET_TEXT_INPUT__', + DIET_PLAN: '__DIET_PLAN_CARD__', +} as const; + +export type CardType = typeof CardType[keyof typeof CardType]; + +// 定义路由参数类型 +export type CoachScreenParams = { + name?: string; + action?: 'diet' | 'weight' | 'mood' | 'workout'; + subAction?: 'record' | 'photo' | 'text' | 'card'; + meal?: 'breakfast' | 'lunch' | 'dinner' | 'snack'; + message?: string; +}; + +// 快捷操作按钮类型 +export type QuickChip = { + key: string; + label: string; + action: () => void; +}; + +// 选中的图片类型 +export type SelectedImage = { + id: string; + localUri: string; + width?: number; + height?: number; + progress: number; + uploadedKey?: string; + uploadedUrl?: string; + error?: string; +}; \ No newline at end of file diff --git a/components/weight/WeightHistoryCard.tsx b/components/weight/WeightHistoryCard.tsx index bdac92b..303c46b 100644 --- a/components/weight/WeightHistoryCard.tsx +++ b/components/weight/WeightHistoryCard.tsx @@ -327,16 +327,6 @@ export function WeightHistoryCard() { color={Colors.light.primary} /> - { - e.stopPropagation(); - navigateToCoach(); - }} - activeOpacity={0.8} - > - - diff --git a/components/weight/WeightRecordCard.tsx b/components/weight/WeightRecordCard.tsx new file mode 100644 index 0000000..3247866 --- /dev/null +++ b/components/weight/WeightRecordCard.tsx @@ -0,0 +1,183 @@ +import { Colors } from '@/constants/Colors'; +import { WeightHistoryItem } from '@/store/userSlice'; +import { Ionicons } from '@expo/vector-icons'; +import dayjs from 'dayjs'; +import React, { useRef } from 'react'; +import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Swipeable } from 'react-native-gesture-handler'; + +interface WeightRecordCardProps { + record: WeightHistoryItem; + onPress?: (record: WeightHistoryItem) => void; + onDelete?: (recordId: string) => void; + weightChange?: number; +} + +export const WeightRecordCard: React.FC = ({ + record, + onPress, + onDelete, + weightChange = 0 +}) => { + const swipeableRef = useRef(null); + + // 处理删除操作 + const handleDelete = () => { + Alert.alert( + '确认删除', + `确定要删除这条体重记录吗?此操作无法撤销。`, + [ + { + text: '取消', + style: 'cancel', + }, + { + text: '删除', + style: 'destructive', + onPress: () => { + const recordId = record.id || record.createdAt; + onDelete?.(recordId); + swipeableRef.current?.close(); + }, + }, + ] + ); + }; + + // 渲染删除按钮 + const renderRightActions = () => { + return ( + + + 删除 + + ); + }; + + return ( + + onPress?.(record)} + activeOpacity={0.7} + > + + + {dayjs(record.createdAt).format('MM月DD日 HH:mm')} + + onPress?.(record)} + > + + + + + 体重: + {record.weight}kg + {Math.abs(weightChange) > 0 && ( + + + + {Math.abs(weightChange).toFixed(1)} + + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + recordCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 20, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.06, + shadowRadius: 8, + elevation: 2, + }, + recordHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + recordDateTime: { + fontSize: 14, + color: '#687076', + fontWeight: '500', + }, + recordEditButton: { + padding: 6, + borderRadius: 8, + backgroundColor: 'rgba(255, 149, 0, 0.1)', + }, + recordContent: { + flexDirection: 'row', + alignItems: 'center', + }, + recordWeightLabel: { + fontSize: 16, + color: '#687076', + fontWeight: '500', + }, + recordWeightValue: { + fontSize: 18, + fontWeight: '700', + color: '#192126', + marginLeft: 4, + flex: 1, + }, + weightChangeTag: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + marginLeft: 12, + }, + weightChangeText: { + fontSize: 12, + fontWeight: '600', + marginLeft: 2, + }, + deleteButton: { + backgroundColor: '#EF4444', + justifyContent: 'center', + alignItems: 'center', + width: 80, + borderRadius: 16, + marginBottom: 12, + marginLeft: 8, + }, + deleteButtonText: { + color: '#FFFFFF', + fontSize: 12, + fontWeight: '600', + marginTop: 4, + }, +}); \ No newline at end of file diff --git a/docs/weight-records-edit-delete.md b/docs/weight-records-edit-delete.md new file mode 100644 index 0000000..00da4ba --- /dev/null +++ b/docs/weight-records-edit-delete.md @@ -0,0 +1,75 @@ +# 体重记录编辑和删除功能 + +## 功能概述 + +为体重记录页面添加了编辑和删除功能,用户可以: + +1. **点击体重记录卡片**:唤出编辑弹窗,修改体重值 +2. **左滑体重记录卡片**:显示删除按钮,支持删除记录 + +## 实现细节 + +### 1. 新增 Redux 操作 + +在 `store/userSlice.ts` 中添加了: + +- `deleteWeightRecord`: 删除体重记录 +- `updateWeightRecord`: 更新体重记录 +- 为 `WeightHistoryItem` 类型添加了可选的 `id` 字段 + +### 2. 新增组件 + +创建了 `components/WeightRecordCard.tsx` 组件: + +- 使用 `react-native-gesture-handler` 的 `Swipeable` 实现左滑删除 +- 支持点击编辑功能 +- 显示体重变化趋势(上升/下降箭头) +- 参考 `GoalCard` 的交互设计 + +### 3. 更新体重记录页面 + +在 `app/weight-records.tsx` 中: + +- 集成新的 `WeightRecordCard` 组件 +- 添加编辑体重记录的处理逻辑 +- 添加删除体重记录的处理逻辑 +- 扩展弹窗支持编辑现有记录 + +## 用户交互流程 + +### 编辑体重记录 + +1. 用户点击任意体重记录卡片 +2. 弹出编辑弹窗,标题显示"编辑体重记录" +3. 输入框预填充当前体重值 +4. 用户修改体重值并确认 +5. 调用 API 更新记录,刷新列表 + +### 删除体重记录 + +1. 用户左滑体重记录卡片 +2. 显示红色删除按钮 +3. 点击删除按钮,弹出确认对话框 +4. 用户确认后调用 API 删除记录 +5. 从列表中移除该记录 + +## API 接口 + +需要后端支持以下接口: + +``` +DELETE /api/users/weight-history/:recordId +PUT /api/users/weight-history/:recordId +``` + +## 注意事项 + +1. 由于原始数据结构中没有 `id` 字段,目前使用 `createdAt` 作为备用标识符 +2. 建议后端返回的体重记录包含唯一的 `id` 字段 +3. 删除和编辑操作都会重新加载体重历史数据以确保数据一致性 + +## 依赖 + +- `react-native-gesture-handler`: 已安装,用于左滑手势 +- 现有的 Redux 状态管理 +- 现有的 API 服务层 \ No newline at end of file diff --git a/ios/digitalpilates/SplashScreen.storyboard b/ios/digitalpilates/SplashScreen.storyboard index ddc9cd0..0125d7d 100644 --- a/ios/digitalpilates/SplashScreen.storyboard +++ b/ios/digitalpilates/SplashScreen.storyboard @@ -18,16 +18,32 @@ - + + + - + + + + + @@ -35,13 +51,13 @@ - + - + diff --git a/store/userSlice.ts b/store/userSlice.ts index 73dff26..00438ab 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -1,7 +1,8 @@ import { api, loadPersistedToken, setAuthToken, STORAGE_KEYS } from '@/services/api'; import { updateUser, UpdateUserDto } from '@/services/users'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import dayjs from 'dayjs'; export type Gender = 'male' | 'female' | ''; @@ -24,6 +25,7 @@ export type UserProfile = { }; export type WeightHistoryItem = { + id: string; // 添加 id 字段用于删除和更新操作 weight: string; source: string; createdAt: string; @@ -211,6 +213,32 @@ export const updateUserProfile = createAsyncThunk( } ); +// 删除体重记录 +export const deleteWeightRecord = createAsyncThunk( + 'user/deleteWeightRecord', + async (recordId: string, { rejectWithValue }) => { + try { + await api.delete(`/api/users/weight-records/${recordId}`); + return recordId; + } catch (err: any) { + return rejectWithValue(err?.message ?? '删除体重记录失败'); + } + } +); + +// 更新体重记录 +export const updateWeightRecord = createAsyncThunk( + 'user/updateWeightRecord', + async ({ id, weight }: { id: string; weight: number }, { rejectWithValue }) => { + try { + const data = await api.put(`/api/users/weight-records/${id}`, { weight }); + return data; + } catch (err: any) { + return rejectWithValue(err?.message ?? '更新体重记录失败'); + } + } +); + const userSlice = createSlice({ name: 'user', initialState, @@ -286,9 +314,54 @@ const userSlice = createSlice({ }) .addCase(updateUserProfile.rejected, (state, action) => { state.error = (action.payload as string) ?? '更新用户资料失败'; + }) + .addCase(deleteWeightRecord.fulfilled, (state, action) => { + state.weightHistory = state.weightHistory.filter(record => + record.id !== action.payload && record.createdAt !== action.payload + ); + }) + .addCase(deleteWeightRecord.rejected, (state, action) => { + state.error = (action.payload as string) ?? '删除体重记录失败'; + }) + .addCase(updateWeightRecord.fulfilled, (state, action) => { + const index = state.weightHistory.findIndex(record => + record.id === action.payload.id || record.createdAt === action.payload.id + ); + if (index !== -1) { + state.weightHistory[index] = { ...state.weightHistory[index], ...action.payload }; + } + }) + .addCase(updateWeightRecord.rejected, (state, action) => { + state.error = (action.payload as string) ?? '更新体重记录失败'; }); }, }); export const { updateProfile, setDailyStepsGoal, setDailyCaloriesGoal, setPilatesPurposes } = userSlice.actions; + +// Selectors +export const selectUserProfile = (state: { user: UserState }) => state.user.profile; + +// 计算用户年龄的 selector +export const selectUserAge = createSelector( + [selectUserProfile], + (profile) => { + if (!profile?.birthDate) return null; + + const birthDate = dayjs(profile.birthDate); + const today = dayjs(); + + // 计算精确年龄(考虑月份和日期) + let age = today.year() - birthDate.year(); + + // 如果今年的生日还没到,年龄减1 + if (today.month() < birthDate.month() || + (today.month() === birthDate.month() && today.date() < birthDate.date())) { + age--; + } + + return age; + } +); + export default userSlice.reducer; \ No newline at end of file