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

@@ -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<number>(60);
const pendingAssistantIdRef = useRef<string | null>(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<SelectedImage[]>([]);
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const [dietTextInputs, setDietTextInputs] = useState<Record<string, string>>({});
const [weightInputs, setWeightInputs] = useState<Record<string, string>>({});
@@ -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 (
<View key={attachment.id} style={styles.attachmentContainer}>
<TouchableOpacity
accessibilityRole="imagebutton"
onPress={() => setPreviewImageUri(imageUri)}
style={styles.imageAttachment}
>
<Image
source={{ uri: imageUri }}
style={[
styles.attachmentImage,
width && height ? { aspectRatio: width / height } : {}
]}
resizeMode="cover"
/>
{uploadProgress !== undefined && uploadProgress < 1 && (
<View style={styles.attachmentProgressOverlay}>
<Text style={styles.attachmentProgressText}>
{Math.round(uploadProgress * 100)}%
</Text>
</View>
)}
{uploadError && (
<View style={styles.attachmentErrorOverlay}>
<Text style={styles.attachmentErrorText}></Text>
</View>
)}
</TouchableOpacity>
</View>
);
}
if (type === 'video') {
// 视频附件的实现
return (
<View key={attachment.id} style={styles.attachmentContainer}>
<TouchableOpacity style={styles.videoAttachment}>
<View style={styles.videoPlaceholder}>
<Ionicons name="play-circle" size={48} color="rgba(255,255,255,0.9)" />
<Text style={styles.videoFilename}>{filename || '视频文件'}</Text>
</View>
</TouchableOpacity>
</View>
);
}
if (type === 'file') {
// 文件附件的实现
return (
<View key={attachment.id} style={styles.attachmentContainer}>
<TouchableOpacity style={styles.fileAttachment}>
<Ionicons name="document-outline" size={24} color="#687076" />
<Text style={styles.fileFilename} numberOfLines={1}>
{filename || 'unknown_file'}
</Text>
</TouchableOpacity>
</View>
);
}
return null;
}
// 渲染所有附件
function renderAttachments(attachments: MessageAttachment[], isUser: boolean) {
if (!attachments || attachments.length === 0) return null;
return (
<View style={styles.attachmentsContainer}>
{attachments.map(attachment => renderAttachment(attachment, isUser))}
</View>
);
}
function renderItem({ item }: { item: ChatMessage }) {
const isUser = item.role === 'user';
return (
<Animated.View
entering={isUser ? FadeInUp.springify().damping(18) : FadeInDown.springify().damping(18)}
layout={Layout.springify().damping(18)}
style={[styles.row, { justifyContent: isUser ? 'flex-end' : 'flex-start' }]}
>
<View
style={[
styles.bubble,
{
backgroundColor: theme.card, // 16% opacity
borderTopLeftRadius: isUser ? 16 : 6,
borderTopRightRadius: isUser ? 6 : 16,
maxWidth: isUser ? '82%' : '90%',
},
]}
>
{renderBubbleContent(item)}
{renderAttachments(item.attachments || [], isUser)}
</View>
</Animated.View>
);
}
function renderBubbleContent(item: ChatMessage) {
if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) {
return (
<View style={styles.streamingContainer}>
<Text style={[styles.bubbleText, { color: '#687076' }]}></Text>
<TouchableOpacity
accessibilityRole="button"
onPress={cancelCurrentRequest}
style={styles.cancelStreamBtn}
>
<Ionicons name="stop-circle" size={20} color="#FF4444" />
<Text style={styles.cancelStreamText}></Text>
</TouchableOpacity>
</View>
);
}
// 处理特殊卡片类型
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 (
<View style={{ gap: 8 }}>
<Text style={[styles.bubbleText, { color: '#192126', fontWeight: '700' }]}></Text>
<View style={styles.weightRow}>
<TextInput
placeholder="例如 60.5"
keyboardType="decimal-pad"
value={currentValue}
placeholderTextColor={'#687076'}
style={styles.weightInput}
onChangeText={(text) => setWeightInputs(prev => ({ ...prev, [cardId]: text }))}
onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text, cardId)}
returnKeyType="done"
submitBehavior="blurAndSubmit"
/>
<Text style={styles.weightUnit}>kg</Text>
<TouchableOpacity
accessibilityRole="button"
style={styles.weightSaveBtn}
onPress={() => handleSubmitWeight(currentValue, cardId)}
>
<Text style={{ color: '#192126', fontWeight: '700' }}></Text>
</TouchableOpacity>
</View>
<Text style={{ color: '#687076', fontSize: 12 }}></Text>
</View>
<Animated.View
entering={FadeInDown.springify().damping(18)}
layout={Layout.springify().damping(18)}
style={[styles.row, { justifyContent: 'flex-start' }]}
>
<WeightInputCard
cardId={cardId}
weightInputs={weightInputs}
onWeightInputChange={(id, value) => setWeightInputs(prev => ({ ...prev, [id]: value }))}
onSaveWeight={(id) => handleSubmitWeight(weightInputs[id], id)}
/>
</Animated.View>
);
}
if (item.content?.startsWith(CardType.DIET_INPUT)) {
return (
<View style={{ gap: 12 }}>
<Text style={[styles.bubbleText, { color: '#192126', fontWeight: '700' }]}></Text>
<Text style={{ color: '#687076', fontSize: 14 }}></Text>
<View style={styles.dietOptionsContainer}>
<TouchableOpacity
accessibilityRole="button"
style={styles.dietOptionBtn}
onPress={() => handleDietTextInput(item.id)}
>
<View style={styles.dietOptionIconContainer}>
<Ionicons name="create-outline" size={20} color="#192126" />
</View>
<View style={styles.dietOptionTextContainer}>
<Text style={styles.dietOptionTitle}></Text>
<Text style={styles.dietOptionDesc}></Text>
</View>
</TouchableOpacity>
<TouchableOpacity
accessibilityRole="button"
style={styles.dietOptionBtn}
onPress={() => handleDietPhotoInput(item.id)}
>
<View style={styles.dietOptionIconContainer}>
<Ionicons name="camera-outline" size={20} color="#192126" />
</View>
<View style={styles.dietOptionTextContainer}>
<Text style={styles.dietOptionTitle}></Text>
<Text style={styles.dietOptionDesc}>AI分析</Text>
</View>
</TouchableOpacity>
</View>
<Text style={{ color: '#687076', fontSize: 12 }}>Seal会根据您的饮食情况给出专业的营养建议</Text>
</View>
<Animated.View
entering={FadeInDown.springify().damping(18)}
layout={Layout.springify().damping(18)}
style={[styles.row, { justifyContent: 'flex-start' }]}
>
<DietOptionsCard
cardId={item.id}
onSelectOption={(cardId, optionId) => {
if (optionId === 'text') {
handleDietTextInput(cardId);
} else if (optionId === 'photo') {
handleDietPhotoInput(cardId);
}
}}
/>
</Animated.View>
);
}
if (item.content?.startsWith(CardType.DIET_TEXT_INPUT)) {
const cardId = item.content.split('\n')?.[1] || '';
const currentText = dietTextInputs[cardId] || '';
return (
<View style={{ gap: 8 }}>
<View style={styles.dietInputHeader}>
<Text style={[styles.bubbleText, { color: '#192126', fontWeight: '700' }]}></Text>
<TouchableOpacity
accessibilityRole="button"
onPress={() => handleBackToDietOptions(cardId)}
style={styles.dietBackBtn}
>
<Ionicons name="arrow-back" size={16} color="#687076" />
</TouchableOpacity>
</View>
<TextInput
placeholder="例如:午餐吃了一碗米饭(150g)、红烧肉(100g)、青菜(80g)"
placeholderTextColor={'#687076'}
style={styles.dietTextInput}
multiline
numberOfLines={3}
value={currentText}
onChangeText={(text) => setDietTextInputs(prev => ({ ...prev, [cardId]: text }))}
returnKeyType="done"
<Animated.View
entering={FadeInDown.springify().damping(18)}
layout={Layout.springify().damping(18)}
style={[styles.row, { justifyContent: 'flex-start' }]}
>
<DietInputCard
cardId={cardId}
dietTextInputs={dietTextInputs}
onDietTextInputChange={(id, value) => setDietTextInputs(prev => ({ ...prev, [id]: value }))}
onSubmitDietText={(id) => handleSubmitDietText(dietTextInputs[id], id)}
onBackToDietOptions={handleBackToDietOptions}
onShowDietPhotoActionSheet={(id) => {
setCurrentCardId(id);
setShowDietPhotoActionSheet(true);
}}
/>
<TouchableOpacity
accessibilityRole="button"
style={[styles.dietSubmitBtn, { opacity: currentText.trim() ? 1 : 0.5 }]}
disabled={!currentText.trim()}
onPress={() => handleSubmitDietText(currentText, cardId)}
>
<Text style={{ color: '#192126', fontWeight: '700' }}></Text>
</TouchableOpacity>
<Text style={{ color: '#687076', fontSize: 12 }}>Seal给出更精准的营养分析和建议</Text>
</View>
</Animated.View>
);
}
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 (
<View style={styles.dietPlanContainer}>
{/* 标题部分 */}
<View style={styles.dietPlanHeader}>
<View style={styles.dietPlanTitleContainer}>
<Ionicons name="restaurant-outline" size={20} color={theme.success} />
<Text style={styles.dietPlanTitle}></Text>
</View>
<Text style={styles.dietPlanSubtitle}>MY DIET PLAN</Text>
</View>
{/* 我的档案数据 */}
<View style={styles.profileSection}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.profileDataRow}>
<View style={styles.avatarContainer}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{name.charAt(0)}</Text>
</View>
</View>
<View style={styles.profileStats}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{age}</Text>
<Text style={styles.statLabel}>/</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{height.toFixed(1)}</Text>
<Text style={styles.statLabel}>/CM</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{weight.toFixed(1)}</Text>
<Text style={styles.statLabel}>/KG</Text>
</View>
</View>
</View>
</View>
{/* BMI部分 */}
<View style={styles.bmiSection}>
<View style={styles.bmiHeader}>
<Text style={styles.sectionTitle}>BMI</Text>
<View style={[styles.bmiStatusBadge, { backgroundColor: bmiStatus.color }]}>
<Text style={styles.bmiStatusText}>{bmiStatus.status}</Text>
</View>
</View>
<Text style={styles.bmiValue}>{bmi.toFixed(1)}</Text>
<View style={styles.bmiScale}>
<View style={[styles.bmiBar, { backgroundColor: '#87CEEB' }]} />
<View style={[styles.bmiBar, { backgroundColor: '#90EE90' }]} />
<View style={[styles.bmiBar, { backgroundColor: '#FFD700' }]} />
<View style={[styles.bmiBar, { backgroundColor: '#FFA07A' }]} />
</View>
<View style={styles.bmiLabels}>
<Text style={styles.bmiLabel}></Text>
<Text style={styles.bmiLabel}></Text>
<Text style={styles.bmiLabel}></Text>
<Text style={styles.bmiLabel}></Text>
</View>
</View>
{/* 饮食目标 */}
<View style={styles.collapsibleSection}>
<View style={styles.collapsibleHeader}>
<Text style={styles.sectionTitle}></Text>
<Ionicons name="chevron-down" size={16} color="#687076" />
</View>
</View>
{/* 目标体重 */}
<View style={styles.collapsibleSection}>
<View style={styles.collapsibleHeader}>
<Text style={styles.sectionTitle}></Text>
<Ionicons name="chevron-down" size={16} color="#687076" />
</View>
</View>
{/* 每日推荐摄入 */}
<View style={styles.caloriesSection}>
<View style={styles.caloriesHeader}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.caloriesValue}>{dailyCalories}</Text>
</View>
<View style={styles.nutritionGrid}>
<View style={styles.nutritionItem}>
<Text style={styles.nutritionValue}>{nutrition.carbs}g</Text>
<View style={styles.nutritionLabelRow}>
<Ionicons name="nutrition-outline" size={16} color="#687076" />
<Text style={styles.nutritionLabel}></Text>
</View>
</View>
<View style={styles.nutritionItem}>
<Text style={styles.nutritionValue}>{nutrition.protein}g</Text>
<View style={styles.nutritionLabelRow}>
<Ionicons name="fitness-outline" size={16} color="#687076" />
<Text style={styles.nutritionLabel}></Text>
</View>
</View>
<View style={styles.nutritionItem}>
<Text style={styles.nutritionValue}>{nutrition.fat}g</Text>
<View style={styles.nutritionLabelRow}>
<Ionicons name="water-outline" size={16} color="#687076" />
<Text style={styles.nutritionLabel}></Text>
</View>
</View>
</View>
<Text style={styles.nutritionNote}>
</Text>
</View>
{/* 底部按钮 */}
<TouchableOpacity
style={styles.dietPlanButton}
onPress={() => {
// 这里可以添加跳转到详细饮食方案页面的逻辑
<Animated.View
entering={FadeInDown.springify().damping(18)}
layout={Layout.springify().damping(18)}
style={[styles.row, { justifyContent: 'flex-start' }]}
>
<DietPlanCard
onGeneratePlan={() => {
console.log('跳转到饮食方案详情');
}}
>
<Ionicons name="restaurant" size={16} color="#FFFFFF" />
<Text style={styles.dietPlanButtonText}></Text>
</TouchableOpacity>
</View>
);
}
// 在流式回复过程中显示取消按钮
if (isStreaming && pendingAssistantIdRef.current === item.id && item.content?.trim()) {
return (
<View style={{ gap: 8 }}>
<Markdown style={markdownStyles} mergeStyle>
{item.content}
</Markdown>
<TouchableOpacity
accessibilityRole="button"
onPress={cancelCurrentRequest}
style={styles.cancelStreamBtn}
>
<Ionicons name="stop-circle" size={16} color="#FF4444" />
<Text style={styles.cancelStreamText}></Text>
</TouchableOpacity>
</View>
);
}
// 检查是否有选择选项需要显示
if (item.choices && item.choices.length > 0 && (item.interactionType === 'food_confirmation' || item.interactionType === 'selection')) {
return (
<View style={{ gap: 12, width: '100%' }}>
<Markdown style={markdownStyles} mergeStyle>
{item.content || ''}
</Markdown>
<View style={styles.choicesContainer}>
{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 (
<TouchableOpacity
key={choice.id}
accessibilityRole="button"
disabled={isDisabled || isPending}
style={[
styles.choiceButton,
choice.recommended && styles.choiceButtonRecommended,
isSelected && styles.choiceButtonSelected,
isDisabled && styles.choiceButtonDisabled,
]}
onPress={() => {
if (!isDisabled && !isPending) {
Haptics.selectionAsync();
handleChoiceSelection(choice, item);
}
}}
>
<View style={styles.choiceContent}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, flex: 1 }}>
{choice.emoji && (
<Text style={{ fontSize: 16 }}>{choice.emoji}</Text>
)}
<Text style={[
styles.choiceLabel,
choice.recommended && styles.choiceLabelRecommended,
isSelected && styles.choiceLabelSelected,
isDisabled && styles.choiceLabelDisabled,
]}>
{choice.label}
</Text>
</View>
<View style={styles.choiceStatusContainer}>
{choice.recommended && !isSelected && (
<View style={styles.recommendedBadge}>
<Text style={styles.recommendedText}></Text>
</View>
)}
{isSelected && isPending && (
<ActivityIndicator size="small" color={theme.success} />
)}
{isSelected && !isPending && (
<View style={styles.selectedBadge}>
<Text style={styles.selectedText}></Text>
</View>
)}
</View>
</View>
</TouchableOpacity>
);
})}
</View>
</View>
/>
</Animated.View>
);
}
// 普通消息使用 ChatMessageComponent
return (
<Markdown style={markdownStyles} mergeStyle>
{item.content || ''}
</Markdown>
<Animated.View
entering={isUser ? FadeInUp.springify().damping(18) : FadeInDown.springify().damping(18)}
layout={Layout.springify().damping(18)}
>
<ChatMessageComponent
message={item}
onPreviewImage={setPreviewImageUri}
onChoiceSelect={(messageId, choiceId) => {
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}
/>
</Animated.View>
);
}
function insertWeightInputCard() {
const id = `wcard_${Date.now()}`;
const preset = userProfile?.weight ? Number(userProfile.weight) : undefined;
@@ -1970,122 +1510,33 @@ export default function CoachScreen() {
/>
</View>
<BlurView
intensity={18}
tint={'light'}
style={[styles.composerWrap, { paddingBottom: getTabBarBottomPadding() + 10, bottom: keyboardOffset }]}
<View
style={[{
position: 'absolute',
left: 0,
right: 0,
bottom: keyboardOffset,
paddingBottom: getTabBarBottomPadding() + 10
}]}
onLayout={(e) => {
const h = e.nativeEvent.layout.height;
if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h);
}}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToAlignment="start"
style={styles.chipsRowScroll}
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
>
{chips.map((c) => (
<TouchableOpacity
key={c.key}
style={[
styles.chip,
{
borderColor: c.key === 'mood' ? `${theme.success}40` : `${theme.primary}40`,
backgroundColor: c.key === 'mood' ? `${theme.success}15` : `${theme.primary}15`
}
]}
onPress={c.action}
>
<Text style={[styles.chipText, { color: c.key === 'mood' ? theme.success : theme.text }]}>{c.label}</Text>
</TouchableOpacity>
))}
</ScrollView>
{!!selectedImages.length && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.imagesRow}
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
>
{selectedImages.map((img) => (
<View key={img.id} style={styles.imageThumbWrap}>
<TouchableOpacity accessibilityRole="imagebutton" onPress={() => setPreviewImageUri(img.uploadedUrl || img.localUri)}>
<Image source={{ uri: img.uploadedUrl || img.localUri }} style={styles.imageThumb} />
</TouchableOpacity>
{!!(img.progress > 0 && img.progress < 1) && (
<View style={styles.imageProgressOverlay}>
<Text style={styles.imageProgressText}>{Math.round((img.progress || 0) * 100)}%</Text>
</View>
)}
{img.error && (
<View style={styles.imageErrorOverlay}>
<TouchableOpacity
accessibilityRole="button"
onPress={() => uploadImage(img)}
style={styles.imageRetryBtn}
>
<Ionicons name="refresh" size={12} color="#fff" />
</TouchableOpacity>
</View>
)}
<TouchableOpacity accessibilityRole="button" onPress={() => removeSelectedImage(img.id)} style={styles.imageRemoveBtn}>
<Ionicons name="close" size={12} color="#fff" />
</TouchableOpacity>
</View>
))}
</ScrollView>
)}
<View style={[styles.inputRow]}>
<TouchableOpacity
accessibilityRole="button"
onPress={pickImages}
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={setInput}
multiline
onSubmitEditing={() => send(input)}
submitBehavior="blurAndSubmit"
/>
<TouchableOpacity
accessibilityRole="button"
disabled={(!input.trim() && selectedImages.length === 0) && !isSending}
onPress={() => {
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 ? (
<Ionicons name="stop" size={18} color="#fff" />
) : isStreaming ? (
<Ionicons name="stop" size={18} color="#fff" />
) : (
<Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
)}
</TouchableOpacity>
</View>
</BlurView>
<ChatComposer
input={input}
onInputChange={setInput}
onSend={() => send(input)}
onPickImages={pickImages}
onCancelRequest={cancelCurrentRequest}
selectedImages={selectedImages}
onRemoveImage={removeSelectedImage}
onPreviewImage={setPreviewImageUri}
isSending={isSending}
isStreaming={isStreaming}
chips={chips}
/>
</View>
{!isAtBottom && (
<TouchableOpacity