Files
digital-pilates/app/ai-coach-chat.tsx
richarjiang f3e6250505 feat: 添加训练计划和打卡功能
- 新增训练计划页面,允许用户制定个性化的训练计划
- 集成打卡功能,用户可以记录每日的训练情况
- 更新 Redux 状态管理,添加训练计划相关的 reducer
- 在首页中添加训练计划卡片,支持用户点击跳转
- 更新样式和布局,以适应新功能的展示和交互
- 添加日期选择器和相关依赖,支持用户选择训练日期
2025-08-13 09:10:00 +08:00

308 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
FlatList,
KeyboardAvoidingView,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
type Role = 'user' | 'assistant';
type ChatMessage = {
id: string;
role: Role;
content: string;
};
export default function AICoachChatScreen() {
const router = useRouter();
const params = useLocalSearchParams<{ name?: string }>();
const insets = useSafeAreaInsets();
const colorScheme = useColorScheme() ?? 'light';
// 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色
const theme = Colors.light;
const coachName = (params?.name || 'Sarah').toString();
const [input, setInput] = useState('');
const [isSending, setIsSending] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([{
id: 'm_welcome',
role: 'assistant',
content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~`,
}]);
const listRef = useRef<FlatList<ChatMessage>>(null);
const planDraft = useAppSelector((s) => s.trainingPlan?.draft);
const chips = useMemo(() => [
{ key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') },
{ key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() },
], [router, planDraft]);
function scrollToEnd() {
requestAnimationFrame(() => {
listRef.current?.scrollToEnd({ animated: true });
});
}
async function fakeStreamResponse(prompt: string): Promise<string> {
// 占位实现模拟AI逐字输出可替换为真实后端流式接口
const canned =
prompt.includes('训练计划') || prompt.includes('制定')
? '好的,我将基于你的目标与时间安排制定一周普拉提计划:\n\n- 周一核心激活与呼吸20-25分钟\n- 周三下肢稳定与髋部灵活25-30分钟\n- 周五全身整合与平衡30分钟\n\n每次训练前后各进行5分钟呼吸与拉伸。若有不适请降低强度或暂停。'
: '已收到,我会根据你的问题给出建议:保持规律练习与充分恢复,注意呼吸控制与动作节奏。若感到疼痛请及时调整或咨询专业教练。';
await new Promise((r) => setTimeout(r, 500));
return canned;
}
async function send(text: string) {
if (!text.trim() || isSending) return;
const userMsg: ChatMessage = { id: `u_${Date.now()}`, role: 'user', content: text.trim() };
setMessages((m) => [...m, userMsg]);
setInput('');
setIsSending(true);
scrollToEnd();
try {
const replyText = await fakeStreamResponse(text.trim());
const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: replyText };
setMessages((m) => [...m, aiMsg]);
scrollToEnd();
} catch (e) {
const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: '抱歉,请求失败,请稍后再试。' };
setMessages((m) => [...m, aiMsg]);
} finally {
setIsSending(false);
}
}
function handleQuickPlan() {
const goalMap: Record<string, string> = {
postpartum_recovery: '产后恢复',
fat_loss: '减脂塑形',
posture_correction: '体态矫正',
core_strength: '核心力量',
flexibility: '柔韧灵活',
rehab: '康复保健',
stress_relief: '释压放松',
};
const goalText = planDraft?.goal ? goalMap[planDraft.goal] : '整体提升';
const freq = planDraft?.mode === 'sessionsPerWeek'
? `${planDraft?.sessionsPerWeek ?? 3}次/周`
: (planDraft?.daysOfWeek?.length ? `${planDraft.daysOfWeek.length}次/周` : '3次/周');
const prefer = planDraft?.preferredTimeOfDay ? `偏好${planDraft.preferredTimeOfDay}` : '时间灵活';
const prompt = `请根据我的目标“${goalText}”、频率“${freq}”、${prefer}制定1周的普拉提训练计划包含每次训练主题、时长、主要动作与注意事项并给出恢复建议。`;
send(prompt);
}
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' }]}
>
{!isUser && (
<View style={[styles.avatar, { backgroundColor: theme.primary }]}>
<Text style={styles.avatarText}>AI</Text>
</View>
)}
<View
style={[
styles.bubble,
{
backgroundColor: isUser ? theme.primary : 'rgba(187,242,70,0.16)',
borderTopLeftRadius: isUser ? 16 : 6,
borderTopRightRadius: isUser ? 6 : 16,
},
]}
>
<Text style={[styles.bubbleText, { color: isUser ? theme.onPrimary : '#192126' }]}>{item.content}</Text>
</View>
</Animated.View>
);
}
return (
<View style={[styles.screen, { backgroundColor: theme.background }]}>
<HeaderBar
title={`教练 ${coachName}`}
onBack={() => router.back()}
tone="light"
transparent
/>
<FlatList
ref={listRef}
data={messages}
keyExtractor={(m) => m.id}
renderItem={renderItem}
contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8, paddingBottom: insets.bottom + 140 }}
onContentSizeChange={scrollToEnd}
showsVerticalScrollIndicator={false}
/>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={80}>
<BlurView intensity={18} tint={'light'} style={[styles.composerWrap, { paddingBottom: insets.bottom + 10 }]}>
<View style={styles.chipsRow}>
{chips.map((c) => (
<TouchableOpacity key={c.key} style={[styles.chip, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.12)' }]} onPress={c.action}>
<Text style={[styles.chipText, { color: '#192126' }]}>{c.label}</Text>
</TouchableOpacity>
))}
</View>
<View style={[styles.inputRow, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.08)' }]}>
<TextInput
placeholder="问我任何与普拉提相关的问题..."
placeholderTextColor={theme.textMuted}
style={[styles.input, { color: '#192126' }]}
value={input}
onChangeText={setInput}
multiline
onSubmitEditing={() => send(input)}
blurOnSubmit={false}
/>
<TouchableOpacity
accessibilityRole="button"
disabled={!input.trim() || isSending}
onPress={() => send(input)}
style={[
styles.sendBtn,
{ backgroundColor: theme.primary, opacity: input.trim() && !isSending ? 1 : 0.5 }
]}
>
{isSending ? (
<ActivityIndicator color={theme.onPrimary} />
) : (
<Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
)}
</TouchableOpacity>
</View>
</BlurView>
</KeyboardAvoidingView>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingBottom: 10,
},
backButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.06)'
},
headerTitle: {
fontSize: 20,
fontWeight: '800',
},
row: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 8,
marginVertical: 6,
},
avatar: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: {
color: '#192126',
fontSize: 12,
fontWeight: '800',
},
bubble: {
maxWidth: '82%',
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 16,
},
bubbleText: {
fontSize: 15,
lineHeight: 22,
},
composerWrap: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingTop: 8,
paddingHorizontal: 10,
borderTopWidth: 0,
},
chipsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
paddingHorizontal: 6,
marginBottom: 8,
},
chip: {
paddingHorizontal: 10,
height: 34,
borderRadius: 18,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
},
chipText: {
fontSize: 13,
fontWeight: '600',
},
inputRow: {
flexDirection: 'row',
alignItems: 'flex-end',
padding: 8,
borderWidth: 1,
borderRadius: 16,
backgroundColor: 'rgba(0,0,0,0.04)'
},
input: {
flex: 1,
fontSize: 15,
maxHeight: 120,
paddingHorizontal: 8,
paddingVertical: 6,
},
sendBtn: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
});