refactor(coach): 重构教练组件,统一导入并简化UI实现与类型定义

This commit is contained in:
richarjiang
2025-08-28 09:46:14 +08:00
parent ba2d829e02
commit 5a59508b88
17 changed files with 2400 additions and 866 deletions

View File

@@ -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<ChatComposerProps> = ({
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 (
<BlurView intensity={95} tint={colorScheme} style={styles.composerWrap}>
{/* 快捷操作按钮 */}
<QuickChips chips={chips} />
{/* 选中的图片预览 */}
{selectedImages.length > 0 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.imagesRow}
contentContainerStyle={styles.imagesRowContent}
>
{selectedImages.map((img) => (
<View key={img.id} style={styles.imageThumbWrap}>
<TouchableOpacity
onPress={() => onPreviewImage(img.localUri)}
style={styles.imageThumb}
>
<Image
source={{ uri: img.localUri }}
style={styles.imageThumb}
contentFit="cover"
/>
</TouchableOpacity>
{/* 上传进度 */}
{img.progress > 0 && img.progress < 1 && (
<View style={styles.imageProgressOverlay}>
<Text style={styles.imageProgressText}>
{Math.round(img.progress * 100)}%
</Text>
</View>
)}
{/* 上传错误 */}
{img.error && (
<View style={styles.imageErrorOverlay}>
<TouchableOpacity
onPress={() => {/* 重试上传逻辑 */ }}
style={styles.imageRetryBtn}
>
<Ionicons name="refresh" size={12} color="#fff" />
</TouchableOpacity>
</View>
)}
{/* 删除按钮 */}
<TouchableOpacity
onPress={() => onRemoveImage(img.id)}
style={styles.imageRemoveBtn}
>
<Ionicons name="close" size={12} color="#fff" />
</TouchableOpacity>
</View>
))}
</ScrollView>
)}
{/* 输入区域 */}
<View style={styles.inputRow}>
<TouchableOpacity
onPress={onPickImages}
style={[styles.mediaBtn, { backgroundColor: `${theme.primary}20` }]}
>
<Ionicons name="image-outline" size={18} color={theme.text} />
</TouchableOpacity>
<TextInput
placeholder="问我任何健康相关的问题,如营养、健身、生活管理等..."
placeholderTextColor={theme.textMuted}
style={[styles.input, { color: theme.text }]}
value={input}
onChangeText={onInputChange}
multiline
onSubmitEditing={onSend}
submitBehavior="blurAndSubmit"
/>
<TouchableOpacity
disabled={!hasContent && !isActive}
onPress={isActive ? onCancelRequest : onSend}
style={[
styles.sendBtn,
{
backgroundColor: isActive ? theme.danger : theme.primary,
opacity: (hasContent || isActive) ? 1 : 0.5
}
]}
>
{isActive ? (
<Ionicons name="stop" size={18} color="#fff" />
) : (
<Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
)}
</TouchableOpacity>
</View>
</BlurView>
);
};
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;

View File

@@ -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<MessageAttachmentComponentProps> = ({
attachment,
onPreview
}) => {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'light'];
if (attachment.type === 'image') {
return (
<TouchableOpacity
style={styles.imageAttachment}
onPress={() => onPreview?.(attachment.url)}
activeOpacity={0.8}
>
<Image
source={{ uri: attachment.url }}
style={styles.attachmentImage}
contentFit="cover"
/>
{attachment.uploadProgress !== undefined && attachment.uploadProgress < 1 && (
<View style={styles.attachmentProgressOverlay}>
<Text style={styles.attachmentProgressText}>
{Math.round(attachment.uploadProgress * 100)}%
</Text>
</View>
)}
{attachment.uploadError && (
<View style={styles.attachmentErrorOverlay}>
<Text style={styles.attachmentErrorText}></Text>
</View>
)}
</TouchableOpacity>
);
}
if (attachment.type === 'video') {
return (
<View style={styles.videoAttachment}>
<View style={styles.videoPlaceholder}>
<Ionicons name="play-circle" size={32} color="#fff" />
{attachment.filename && (
<Text style={styles.videoFilename}>{attachment.filename}</Text>
)}
</View>
</View>
);
}
if (attachment.type === 'file') {
return (
<View style={styles.fileAttachment}>
<Ionicons name="document" size={20} color={theme.text} />
<Text style={[styles.fileFilename, { color: theme.text }]}>
{attachment.filename || '未知文件'}
</Text>
</View>
);
}
return null;
};
interface ChatMessageComponentProps {
message: ChatMessage;
onPreviewImage?: (uri: string) => void;
onChoiceSelect?: (messageId: string, choiceId: string) => void;
selectedChoices?: Record<string, string>;
pendingChoiceConfirmation?: Record<string, boolean>;
isStreaming?: boolean;
onCancelStream?: () => void;
}
const ChatMessageComponent: React.FC<ChatMessageComponentProps> = ({
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 (
<View style={[styles.row, isUser && styles.userRow]}>
{isAssistant && (
<View style={[styles.avatar, { backgroundColor: theme.primary }]}>
<Text style={styles.avatarText}>AI</Text>
</View>
)}
<View style={[
styles.bubble,
{
backgroundColor: isUser ? theme.primary : theme.surface,
alignSelf: isUser ? 'flex-end' : 'flex-start',
maxWidth: '85%',
}
]}>
{/* 文本内容 */}
{message.content ? (
isUser ? (
<Text style={[styles.bubbleText, { color: theme.onPrimary }]}>
{message.content}
</Text>
) : (
<Markdown
style={{
body: { color: theme.text, fontSize: 15, lineHeight: 22 },
paragraph: { marginTop: 2, marginBottom: 2 },
bullet_list: { marginVertical: 4 },
ordered_list: { marginVertical: 4 },
list_item: { flexDirection: 'row' },
code_inline: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 4,
paddingHorizontal: 4,
paddingVertical: 2,
},
code_block: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 6,
},
fence: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 6,
},
heading1: { fontSize: 20, fontWeight: '800', marginVertical: 6 },
heading2: { fontSize: 18, fontWeight: '800', marginVertical: 6 },
heading3: { fontSize: 16, fontWeight: '800', marginVertical: 6 },
link: { color: '#246BFD' },
}}
>
{message.content}
</Markdown>
)
) : null}
{/* 附件 */}
{message.attachments && message.attachments.length > 0 && (
<View style={styles.attachmentsContainer}>
{message.attachments.map((attachment) => (
<MessageAttachmentComponent
key={attachment.id}
attachment={attachment}
onPreview={onPreviewImage}
/>
))}
</View>
)}
{/* 选择选项 */}
{message.choices && message.choices.length > 0 && (
<View style={styles.choicesContainer}>
{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 (
<TouchableOpacity
key={choice.id}
style={[
styles.choiceButton,
!!choice.recommended && styles.choiceButtonRecommended,
isSelected && styles.choiceButtonSelected,
isDisabled && styles.choiceButtonDisabled,
]}
onPress={() => onChoiceSelect?.(message.id, choice.id)}
disabled={isDisabled}
>
<View style={styles.choiceContent}>
<Text style={[
styles.choiceLabel,
!!choice.recommended && styles.choiceLabelRecommended,
isSelected && styles.choiceLabelSelected,
isDisabled && styles.choiceLabelDisabled,
]}>
{choice.emoji ? `${choice.emoji} ` : ''}{choice.label}
</Text>
<View style={styles.choiceStatusContainer}>
{!!choice.recommended && !isSelected && (
<View style={styles.recommendedBadge}>
<Text style={styles.recommendedText}></Text>
</View>
)}
{isSelected && (
<View style={styles.selectedBadge}>
<Text style={styles.selectedText}></Text>
</View>
)}
</View>
</View>
</TouchableOpacity>
);
})}
</View>
)}
{/* 流式回复控制 */}
{isAssistant && isStreaming && !message.content && (
<View style={styles.streamingContainer}>
<Text style={[styles.bubbleText, { color: theme.text }]}>...</Text>
<TouchableOpacity
style={styles.cancelStreamBtn}
onPress={onCancelStream}
>
<Ionicons name="stop" size={12} color="#FF4444" />
<Text style={styles.cancelStreamText}></Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
);
};
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;

View File

@@ -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<string, string>;
onDietTextInputChange: (cardId: string, value: string) => void;
onSubmitDietText: (cardId: string) => void;
onBackToDietOptions: (cardId: string) => void;
onShowDietPhotoActionSheet: (cardId: string) => void;
}
const DietInputCard: React.FC<DietInputCardProps> = ({
cardId,
dietTextInputs,
onDietTextInputChange,
onSubmitDietText,
onBackToDietOptions,
onShowDietPhotoActionSheet
}) => {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'light'];
return (
<View style={[styles.bubble, { backgroundColor: theme.surface }]}>
<View style={styles.dietInputHeader}>
<TouchableOpacity
style={styles.dietBackBtn}
onPress={() => onBackToDietOptions(cardId)}
>
<Ionicons name="arrow-back" size={16} color={theme.text} />
</TouchableOpacity>
<Text style={[styles.dietInputTitle, { color: theme.text }]}></Text>
<TouchableOpacity
style={[styles.photoBtn, { backgroundColor: `${theme.primary}20` }]}
onPress={() => onShowDietPhotoActionSheet(cardId)}
>
<Ionicons name="camera" size={16} color={theme.primary} />
</TouchableOpacity>
</View>
<TextInput
style={[styles.dietTextInput, {
color: theme.text,
borderColor: theme.border,
backgroundColor: theme.background
}]}
placeholder="描述你吃了什么,比如:早餐吃了一个苹果、一杯牛奶..."
placeholderTextColor={theme.textMuted}
value={dietTextInputs[cardId] || ''}
onChangeText={(text) => onDietTextInputChange(cardId, text)}
multiline
textAlignVertical="top"
/>
<TouchableOpacity
style={[styles.dietSubmitBtn, { backgroundColor: theme.primary }]}
onPress={() => onSubmitDietText(cardId)}
>
<Text style={[styles.submitButtonText, { color: theme.onPrimary }]}></Text>
</TouchableOpacity>
</View>
);
};
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;

View File

@@ -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<DietOptionsCardProps> = ({
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 (
<View style={[styles.bubble, { backgroundColor: theme.surface }]}>
<Text style={[styles.title, { color: theme.text }]}></Text>
<View style={styles.dietOptionsContainer}>
{dietOptions.map((option) => (
<TouchableOpacity
key={option.id}
style={[styles.dietOptionBtn, {
backgroundColor: theme.background,
borderColor: `${theme.primary}4d`
}]}
onPress={option.action}
>
<View style={[styles.dietOptionIconContainer, {
backgroundColor: `${theme.primary}33`
}]}>
<Ionicons name={option.icon as any} size={20} color={theme.primary} />
</View>
<View style={styles.dietOptionTextContainer}>
<Text style={[styles.dietOptionTitle, { color: theme.text }]}>
{option.title}
</Text>
<Text style={[styles.dietOptionDesc, { color: theme.textMuted }]}>
{option.description}
</Text>
</View>
</TouchableOpacity>
))}
</View>
</View>
);
};
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;

View File

@@ -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<DietPlanCardProps> = ({ 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 (
<View style={[styles.dietPlanContainer, {
backgroundColor: theme.surface,
borderColor: `${theme.primary}33`
}]}>
{/* 头部 */}
<View style={styles.dietPlanHeader}>
<View style={styles.dietPlanTitleContainer}>
<Ionicons name="nutrition" size={24} color={theme.primary} />
<Text style={[styles.dietPlanTitle, { color: theme.text }]}></Text>
</View>
<Text style={[styles.dietPlanSubtitle, { color: theme.textMuted }]}>
</Text>
</View>
{/* 用户资料概览 */}
{userProfile && (
<View style={styles.profileSection}>
<Text style={[styles.sectionTitle, { color: theme.text }]}></Text>
<View style={styles.profileDataRow}>
<View style={styles.avatarContainer}>
<View style={[styles.avatar, { backgroundColor: theme.primary }]}>
<Text style={styles.avatarText}>
{userProfile.name?.charAt(0) || 'U'}
</Text>
</View>
</View>
<View style={styles.profileStats}>
{userProfile.weight && (
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: theme.text }]}>
{userProfile.weight}
</Text>
<Text style={[styles.statLabel, { color: theme.textMuted }]}>kg</Text>
</View>
)}
{userProfile.height && (
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: theme.text }]}>
{userProfile.height}
</Text>
<Text style={[styles.statLabel, { color: theme.textMuted }]}>cm</Text>
</View>
)}
{userAge !== null && (
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: theme.text }]}>
{userAge}
</Text>
<Text style={[styles.statLabel, { color: theme.textMuted }]}></Text>
</View>
)}
</View>
</View>
</View>
)}
{/* BMI 部分 */}
{bmi && bmiStatus && (
<View style={styles.bmiSection}>
<View style={styles.bmiHeader}>
<Text style={[styles.sectionTitle, { color: theme.text }]}>BMI </Text>
<View style={[styles.bmiStatusBadge, { backgroundColor: bmiStatus.color }]}>
<Text style={styles.bmiStatusText}>{bmiStatus.text}</Text>
</View>
</View>
<Text style={[styles.bmiValue, { color: theme.text }]}>
{bmi.toFixed(1)}
</Text>
{/* BMI 刻度条 */}
<View style={styles.bmiScale}>
<View style={[styles.bmiBar, { backgroundColor: '#3B82F6' }]} />
<View style={[styles.bmiBar, { backgroundColor: '#10B981' }]} />
<View style={[styles.bmiBar, { backgroundColor: '#F59E0B' }]} />
<View style={[styles.bmiBar, { backgroundColor: '#EF4444' }]} />
</View>
<View style={styles.bmiLabels}>
<Text style={[styles.bmiLabel, { color: theme.textMuted }]}></Text>
<Text style={[styles.bmiLabel, { color: theme.textMuted }]}></Text>
<Text style={[styles.bmiLabel, { color: theme.textMuted }]}></Text>
<Text style={[styles.bmiLabel, { color: theme.textMuted }]}></Text>
</View>
</View>
)}
{/* 可折叠的详细信息 */}
<TouchableOpacity
style={styles.collapsibleSection}
onPress={() => setIsExpanded(!isExpanded)}
>
<View style={styles.collapsibleHeader}>
<Text style={[styles.sectionTitle, { color: theme.text }]}></Text>
<Ionicons
name={isExpanded ? 'chevron-up' : 'chevron-down'}
size={20}
color={theme.text}
/>
</View>
</TouchableOpacity>
{isExpanded && (
<>
{/* 卡路里需求 */}
{dailyCalories && (
<View style={styles.caloriesSection}>
<View style={styles.caloriesHeader}>
<Text style={[styles.sectionTitle, { color: theme.text }]}></Text>
<Text style={[styles.caloriesValue, { color: '#10B981' }]}>
{dailyCalories} kcal
</Text>
</View>
</View>
)}
{/* 营养素分配 */}
<View style={styles.nutritionGrid}>
<View style={styles.nutritionItem}>
<Text style={[styles.nutritionValue, { color: theme.text }]}>55%</Text>
<View style={styles.nutritionLabelRow}>
<View style={[styles.nutritionDot, { backgroundColor: '#3B82F6' }]} />
<Text style={[styles.nutritionLabel, { color: theme.textMuted }]}></Text>
</View>
</View>
<View style={styles.nutritionItem}>
<Text style={[styles.nutritionValue, { color: theme.text }]}>20%</Text>
<View style={styles.nutritionLabelRow}>
<View style={[styles.nutritionDot, { backgroundColor: '#10B981' }]} />
<Text style={[styles.nutritionLabel, { color: theme.textMuted }]}></Text>
</View>
</View>
<View style={styles.nutritionItem}>
<Text style={[styles.nutritionValue, { color: theme.text }]}>25%</Text>
<View style={styles.nutritionLabelRow}>
<View style={[styles.nutritionDot, { backgroundColor: '#F59E0B' }]} />
<Text style={[styles.nutritionLabel, { color: theme.textMuted }]}></Text>
</View>
</View>
</View>
<Text style={[styles.nutritionNote, { color: theme.textMuted }]}>
*
</Text>
</>
)}
{/* 生成方案按钮 */}
<TouchableOpacity
style={[styles.dietPlanButton, { backgroundColor: '#10B981' }]}
onPress={onGeneratePlan}
>
<Ionicons name="sparkles" size={16} color="#FFFFFF" />
<Text style={styles.dietPlanButtonText}></Text>
</TouchableOpacity>
{/* 使用次数提示 */}
<View style={styles.usageCountContainer}>
<Ionicons name="information-circle" size={16} color={theme.primary} />
<Text style={[styles.usageText, { color: theme.primary }]}>
AI
</Text>
</View>
</View>
);
};
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;

View File

@@ -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<QuickChipsProps> = ({ chips }) => {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'light'];
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.chipsRowScroll}
contentContainerStyle={styles.chipsRow}
>
{chips.map((chip) => (
<TouchableOpacity
key={chip.key}
style={[
styles.chip,
{
borderColor: theme.primary,
backgroundColor: 'transparent',
}
]}
onPress={chip.action}
>
<Text style={[styles.chipText, { color: theme.primary }]}>
{chip.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
);
};
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;

View File

@@ -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<string, string>;
onWeightInputChange: (cardId: string, value: string) => void;
onSaveWeight: (cardId: string) => void;
}
const WeightInputCard: React.FC<WeightInputCardProps> = ({
cardId,
weightInputs,
onWeightInputChange,
onSaveWeight
}) => {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'light'];
return (
<View style={[styles.bubble, { backgroundColor: theme.surface }]}>
<View style={styles.weightRow}>
<TextInput
style={[styles.weightInput, { color: theme.text, borderColor: theme.border }]}
placeholder="输入体重"
placeholderTextColor={theme.textMuted}
value={weightInputs[cardId] || ''}
onChangeText={(text) => onWeightInputChange(cardId, text)}
keyboardType="numeric"
/>
<Text style={[styles.weightUnit, { color: theme.text }]}>kg</Text>
<TouchableOpacity
style={[styles.weightSaveBtn, { backgroundColor: theme.primary }]}
onPress={() => onSaveWeight(cardId)}
>
<Text style={[styles.saveButtonText, { color: theme.onPrimary }]}></Text>
</TouchableOpacity>
</View>
</View>
);
};
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;

11
components/coach/index.ts Normal file
View File

@@ -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';

88
components/coach/types.ts Normal file
View File

@@ -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;
};