refactor(coach): 重构教练组件,统一导入并简化UI实现与类型定义
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
import NumberKeyboard from '@/components/NumberKeyboard';
|
||||
import { WeightRecordCard } from '@/components/weight/WeightRecordCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { fetchWeightHistory, updateUserProfile, WeightHistoryItem } from '@/store/userSlice';
|
||||
import { deleteWeightRecord, fetchWeightHistory, updateUserProfile, updateWeightRecord, WeightHistoryItem } from '@/store/userSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
|
||||
export default function WeightRecordsPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const userProfile = useAppSelector((s) => 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<WeightHistoryItem | null>(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 (
|
||||
<View key={`${record.createdAt}-${recordIndex}`} style={styles.recordCard}>
|
||||
<View style={styles.recordHeader}>
|
||||
<Text style={styles.recordDateTime}>
|
||||
{dayjs(record.createdAt).format('MM月DD日 HH:mm')}
|
||||
</Text>
|
||||
{/* <TouchableOpacity style={styles.recordEditButton}>
|
||||
<Ionicons name="create-outline" size={16} color="#FF9500" />
|
||||
</TouchableOpacity> */}
|
||||
</View>
|
||||
<View style={styles.recordContent}>
|
||||
<Text style={styles.recordWeightLabel}>体重:</Text>
|
||||
<Text style={styles.recordWeightValue}>{record.weight}kg</Text>
|
||||
{Math.abs(weightChange) > 0 && (
|
||||
<View style={[
|
||||
styles.weightChangeTag,
|
||||
{ backgroundColor: weightChange < 0 ? '#E8F5E8' : '#FFF2E8' }
|
||||
]}>
|
||||
<Ionicons
|
||||
name={weightChange < 0 ? "arrow-down" : "arrow-up"}
|
||||
size={12}
|
||||
color={weightChange < 0 ? Colors.light.accentGreen : '#FF9500'}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.weightChangeText,
|
||||
{ color: weightChange < 0 ? Colors.light.accentGreen : '#FF9500' }
|
||||
]}>
|
||||
{Math.abs(weightChange).toFixed(1)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<WeightRecordCard
|
||||
key={`${record.createdAt}-${recordIndex}`}
|
||||
record={record}
|
||||
onPress={handleEditWeightRecord}
|
||||
onDelete={handleDeleteWeightRecord}
|
||||
weightChange={weightChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
@@ -263,90 +282,99 @@ export default function WeightRecordsPage() {
|
||||
transparent
|
||||
onRequestClose={() => setShowWeightPicker(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalBackdrop}
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowWeightPicker(false)}
|
||||
/>
|
||||
<View style={[styles.modalSheet, { backgroundColor: themeColors.background }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.modalHeader}>
|
||||
<TouchableOpacity onPress={() => setShowWeightPicker(false)}>
|
||||
<Ionicons name="close" size={24} color={themeColors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.modalTitle, { color: themeColors.text }]}>
|
||||
{pickerType === 'current' && '记录体重'}
|
||||
{pickerType === 'initial' && '编辑初始体重'}
|
||||
{pickerType === 'target' && '编辑目标体重'}
|
||||
</Text>
|
||||
<View style={{ width: 24 }} />
|
||||
</View>
|
||||
|
||||
<View style={styles.modalContent}>
|
||||
{/* Weight Input Section */}
|
||||
<View style={styles.inputSection}>
|
||||
<View style={styles.weightInputContainer}>
|
||||
<View style={styles.weightIcon}>
|
||||
<Ionicons name="scale-outline" size={20} color="#6366F1" />
|
||||
</View>
|
||||
<View style={styles.inputWrapper}>
|
||||
<TextInput
|
||||
style={[styles.weightInput, { color: themeColors.text }]}
|
||||
placeholder="输入体重"
|
||||
placeholderTextColor={themeColors.textSecondary}
|
||||
value={inputWeight}
|
||||
onChangeText={setInputWeight}
|
||||
keyboardType="decimal-pad"
|
||||
autoFocus={true}
|
||||
selectTextOnFocus={true}
|
||||
/>
|
||||
<Text style={[styles.unitLabel, { color: themeColors.textSecondary }]}>kg</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Weight Range Hint */}
|
||||
<Text style={[styles.hintText, { color: themeColors.textSecondary }]}>
|
||||
请输入 0-500 之间的数值,支持小数
|
||||
<View style={styles.modalContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.modalBackdrop}
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowWeightPicker(false)}
|
||||
/>
|
||||
<View style={[styles.modalSheet, { backgroundColor: themeColors.background }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.modalHeader}>
|
||||
<TouchableOpacity onPress={() => setShowWeightPicker(false)}>
|
||||
<Ionicons name="close" size={24} color={themeColors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.modalTitle, { color: themeColors.text }]}>
|
||||
{pickerType === 'current' && '记录体重'}
|
||||
{pickerType === 'initial' && '编辑初始体重'}
|
||||
{pickerType === 'target' && '编辑目标体重'}
|
||||
{pickerType === 'edit' && '编辑体重记录'}
|
||||
</Text>
|
||||
<View style={{ width: 24 }} />
|
||||
</View>
|
||||
|
||||
{/* Quick Selection */}
|
||||
<View style={styles.quickSelectionSection}>
|
||||
<Text style={[styles.quickSelectionTitle, { color: themeColors.text }]}>快速选择</Text>
|
||||
<View style={styles.quickButtons}>
|
||||
{[50, 60, 70, 80, 90].map((weight) => (
|
||||
<TouchableOpacity
|
||||
key={weight}
|
||||
style={[
|
||||
styles.quickButton,
|
||||
inputWeight === weight.toString() && styles.quickButtonSelected
|
||||
]}
|
||||
onPress={() => setInputWeight(weight.toString())}
|
||||
>
|
||||
<Text style={[
|
||||
styles.quickButtonText,
|
||||
inputWeight === weight.toString() && styles.quickButtonTextSelected
|
||||
]}>
|
||||
{weight}kg
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Save Button */}
|
||||
<View style={styles.modalFooter}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.saveButton,
|
||||
{ opacity: !inputWeight.trim() ? 0.5 : 1 }
|
||||
]}
|
||||
onPress={handleWeightSave}
|
||||
disabled={!inputWeight.trim()}
|
||||
<ScrollView
|
||||
style={styles.modalContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>确定</Text>
|
||||
</TouchableOpacity>
|
||||
{/* Weight Display Section */}
|
||||
<View style={styles.inputSection}>
|
||||
<View style={styles.weightInputContainer}>
|
||||
<View style={styles.weightIcon}>
|
||||
<Ionicons name="scale-outline" size={20} color="#6366F1" />
|
||||
</View>
|
||||
<View style={styles.inputWrapper}>
|
||||
<Text style={[styles.weightDisplay, { color: inputWeight ? themeColors.text : themeColors.textSecondary }]}>
|
||||
{inputWeight || '输入体重'}
|
||||
</Text>
|
||||
<Text style={[styles.unitLabel, { color: themeColors.textSecondary }]}>kg</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Weight Range Hint */}
|
||||
<Text style={[styles.hintText, { color: themeColors.textSecondary }]}>
|
||||
请输入 0-500 之间的数值,支持小数
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Quick Selection */}
|
||||
<View style={styles.quickSelectionSection}>
|
||||
<Text style={[styles.quickSelectionTitle, { color: themeColors.text }]}>快速选择</Text>
|
||||
<View style={styles.quickButtons}>
|
||||
{[50, 60, 70, 80, 90].map((weight) => (
|
||||
<TouchableOpacity
|
||||
key={weight}
|
||||
style={[
|
||||
styles.quickButton,
|
||||
inputWeight === weight.toString() && styles.quickButtonSelected
|
||||
]}
|
||||
onPress={() => setInputWeight(weight.toString())}
|
||||
>
|
||||
<Text style={[
|
||||
styles.quickButtonText,
|
||||
inputWeight === weight.toString() && styles.quickButtonTextSelected
|
||||
]}>
|
||||
{weight}kg
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Custom Number Keyboard */}
|
||||
<NumberKeyboard
|
||||
onNumberPress={handleNumberPress}
|
||||
onDeletePress={handleDeletePress}
|
||||
onDecimalPress={handleDecimalPress}
|
||||
hasDecimal={inputWeight.includes('.')}
|
||||
maxLength={6}
|
||||
currentValue={inputWeight}
|
||||
/>
|
||||
|
||||
{/* Save Button */}
|
||||
<View style={styles.modalFooter}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.saveButton,
|
||||
{ opacity: !inputWeight.trim() ? 0.5 : 1 }
|
||||
]}
|
||||
onPress={handleWeightSave}
|
||||
disabled={!inputWeight.trim()}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>确定</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user