feat: 添加训练计划和打卡功能
- 新增训练计划页面,允许用户制定个性化的训练计划 - 集成打卡功能,用户可以记录每日的训练情况 - 更新 Redux 状态管理,添加训练计划相关的 reducer - 在首页中添加训练计划卡片,支持用户点击跳转 - 更新样式和布局,以适应新功能的展示和交互 - 添加日期选择器和相关依赖,支持用户选择训练日期
This commit is contained in:
307
app/ai-coach-chat.tsx
Normal file
307
app/ai-coach-chat.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user