refactor(coach): 重构教练组件,统一导入并简化UI实现与类型定义
This commit is contained in:
147
components/NumberKeyboard.tsx
Normal file
147
components/NumberKeyboard.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
interface NumberKeyboardProps {
|
||||
onNumberPress: (number: string) => void;
|
||||
onDeletePress: () => void;
|
||||
onDecimalPress: () => void;
|
||||
hasDecimal?: boolean;
|
||||
maxLength?: number;
|
||||
currentValue?: string;
|
||||
}
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const keyWidth = (width - 80) / 3; // 减去左右边距和间隙
|
||||
|
||||
export default function NumberKeyboard({
|
||||
onNumberPress,
|
||||
onDeletePress,
|
||||
onDecimalPress,
|
||||
hasDecimal = false,
|
||||
maxLength = 6,
|
||||
currentValue = '',
|
||||
}: NumberKeyboardProps) {
|
||||
const handleNumberPress = (number: string) => {
|
||||
if (currentValue.length >= maxLength) return;
|
||||
// 防止输入多个0开头
|
||||
if (currentValue === '0' && number === '0') return;
|
||||
// 如果当前是0,输入非0数字时替换
|
||||
if (currentValue === '0' && number !== '0') {
|
||||
// 这里不需要replace,直接传递number即可
|
||||
return;
|
||||
}
|
||||
onNumberPress(number);
|
||||
};
|
||||
|
||||
const handleDecimalPress = () => {
|
||||
if (hasDecimal || currentValue.includes('.')) return;
|
||||
onDecimalPress();
|
||||
};
|
||||
|
||||
const renderKey = (
|
||||
value: string,
|
||||
onPress: () => void,
|
||||
style?: any,
|
||||
textStyle?: any,
|
||||
disabled?: boolean
|
||||
) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.key,
|
||||
{ width: keyWidth },
|
||||
style,
|
||||
disabled && styles.keyDisabled
|
||||
]}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{value === 'delete' ? (
|
||||
<Ionicons name="backspace-outline" size={24} color="#374151" />
|
||||
) : (
|
||||
<Text style={[styles.keyText, textStyle, disabled && styles.keyTextDisabled]}>
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.row}>
|
||||
{renderKey('1', () => handleNumberPress('1'))}
|
||||
{renderKey('2', () => handleNumberPress('2'))}
|
||||
{renderKey('3', () => handleNumberPress('3'))}
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
{renderKey('4', () => handleNumberPress('4'))}
|
||||
{renderKey('5', () => handleNumberPress('5'))}
|
||||
{renderKey('6', () => handleNumberPress('6'))}
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
{renderKey('7', () => handleNumberPress('7'))}
|
||||
{renderKey('8', () => handleNumberPress('8'))}
|
||||
{renderKey('9', () => handleNumberPress('9'))}
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
{renderKey(
|
||||
'.',
|
||||
handleDecimalPress,
|
||||
undefined,
|
||||
undefined,
|
||||
hasDecimal || currentValue.includes('.')
|
||||
)}
|
||||
{renderKey('0', () => handleNumberPress('0'))}
|
||||
{renderKey('delete', onDeletePress)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#F9FAFB',
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E5E7EB',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
},
|
||||
key: {
|
||||
height: 50,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
keyDisabled: {
|
||||
backgroundColor: '#F3F4F6',
|
||||
opacity: 0.5,
|
||||
},
|
||||
keyText: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
},
|
||||
keyTextDisabled: {
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
});
|
||||
246
components/coach/ChatComposer.tsx
Normal file
246
components/coach/ChatComposer.tsx
Normal 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;
|
||||
450
components/coach/ChatMessage.tsx
Normal file
450
components/coach/ChatMessage.tsx
Normal 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;
|
||||
128
components/coach/DietInputCard.tsx
Normal file
128
components/coach/DietInputCard.tsx
Normal 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;
|
||||
123
components/coach/DietOptionsCard.tsx
Normal file
123
components/coach/DietOptionsCard.tsx
Normal 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;
|
||||
425
components/coach/DietPlanCard.tsx
Normal file
425
components/coach/DietPlanCard.tsx
Normal 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;
|
||||
66
components/coach/QuickChips.tsx
Normal file
66
components/coach/QuickChips.tsx
Normal 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;
|
||||
83
components/coach/WeightInputCard.tsx
Normal file
83
components/coach/WeightInputCard.tsx
Normal 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
11
components/coach/index.ts
Normal 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
88
components/coach/types.ts
Normal 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;
|
||||
};
|
||||
@@ -327,16 +327,6 @@ export function WeightHistoryCard() {
|
||||
color={Colors.light.primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateToCoach();
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="add" size={16} color={Colors.light.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
183
components/weight/WeightRecordCard.tsx
Normal file
183
components/weight/WeightRecordCard.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { WeightHistoryItem } from '@/store/userSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useRef } from 'react';
|
||||
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Swipeable } from 'react-native-gesture-handler';
|
||||
|
||||
interface WeightRecordCardProps {
|
||||
record: WeightHistoryItem;
|
||||
onPress?: (record: WeightHistoryItem) => void;
|
||||
onDelete?: (recordId: string) => void;
|
||||
weightChange?: number;
|
||||
}
|
||||
|
||||
export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
|
||||
record,
|
||||
onPress,
|
||||
onDelete,
|
||||
weightChange = 0
|
||||
}) => {
|
||||
const swipeableRef = useRef<Swipeable>(null);
|
||||
|
||||
// 处理删除操作
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
`确定要删除这条体重记录吗?此操作无法撤销。`,
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
const recordId = record.id || record.createdAt;
|
||||
onDelete?.(recordId);
|
||||
swipeableRef.current?.close();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染删除按钮
|
||||
const renderRightActions = () => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={handleDelete}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="trash" size={20} color="#FFFFFF" />
|
||||
<Text style={styles.deleteButtonText}>删除</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Swipeable
|
||||
ref={swipeableRef}
|
||||
renderRightActions={renderRightActions}
|
||||
rightThreshold={40}
|
||||
overshootRight={false}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.recordCard}
|
||||
onPress={() => onPress?.(record)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.recordHeader}>
|
||||
<Text style={styles.recordDateTime}>
|
||||
{dayjs(record.createdAt).format('MM月DD日 HH:mm')}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.recordEditButton}
|
||||
onPress={() => onPress?.(record)}
|
||||
>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
</Swipeable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
recordCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
recordHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
recordDateTime: {
|
||||
fontSize: 14,
|
||||
color: '#687076',
|
||||
fontWeight: '500',
|
||||
},
|
||||
recordEditButton: {
|
||||
padding: 6,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(255, 149, 0, 0.1)',
|
||||
},
|
||||
recordContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
recordWeightLabel: {
|
||||
fontSize: 16,
|
||||
color: '#687076',
|
||||
fontWeight: '500',
|
||||
},
|
||||
recordWeightValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
marginLeft: 4,
|
||||
flex: 1,
|
||||
},
|
||||
weightChangeTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
marginLeft: 12,
|
||||
},
|
||||
weightChangeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginLeft: 2,
|
||||
},
|
||||
deleteButton: {
|
||||
backgroundColor: '#EF4444',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 80,
|
||||
borderRadius: 16,
|
||||
marginBottom: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user