Files
digital-pilates/app/ai-coach-chat.tsx
richarjiang 5814044cee feat: 更新 AI 教练聊天界面和个人信息页面
- 在 AI 教练聊天界面中添加训练记录分析功能,允许用户基于近期训练记录获取分析建议
- 更新 Redux 状态管理,集成每日步数和卡路里目标
- 在个人信息页面中优化用户头像显示,支持从库中选择头像
- 修改首页布局,添加可拖动的教练徽章,提升用户交互体验
- 更新样式以适应新功能的展示和交互
2025-08-13 10:09:55 +08:00

353 lines
12 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,
Image,
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';
import type { CheckinRecord } from '@/store/checkinSlice';
type Role = 'user' | 'assistant';
type ChatMessage = {
id: string;
role: Role;
content: string;
};
const COACH_AVATAR = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/imageCoach01.jpeg';
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 checkin = useAppSelector((s) => (s as any).checkin);
const chips = useMemo(() => [
{ key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') },
{ key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() },
{ key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
], [router, planDraft, checkin]);
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 buildTrainingSummary(): string {
const entries = Object.values(checkin?.byDate || {}) as CheckinRecord[];
if (!entries.length) return '';
const recent = entries.sort((a: any, b: any) => String(b.date).localeCompare(String(a.date))).slice(0, 14);
let totalSessions = 0;
let totalExercises = 0;
let totalCompleted = 0;
const categoryCount: Record<string, number> = {};
const exerciseCount: Record<string, number> = {};
for (const rec of recent) {
if (!rec?.items?.length) continue;
totalSessions += 1;
for (const it of rec.items) {
totalExercises += 1;
if (it.completed) totalCompleted += 1;
categoryCount[it.category] = (categoryCount[it.category] || 0) + 1;
exerciseCount[it.name] = (exerciseCount[it.name] || 0) + 1;
}
}
const topCategories = Object.entries(categoryCount).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, v]) => `${k}×${v}`);
const topExercises = Object.entries(exerciseCount).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k, v]) => `${k}×${v}`);
return [
`统计周期:最近${recent.length}天(按有记录日计 ${totalSessions} 天)`,
`记录条目:${totalExercises},完成标记:${totalCompleted}`,
topCategories.length ? `高频类别:${topCategories.join('')}` : '',
topExercises.length ? `高频动作:${topExercises.join('')}` : '',
].filter(Boolean).join('\n');
}
function handleAnalyzeRecords() {
const summary = buildTrainingSummary();
if (!summary) {
send('我还没有可分析的打卡记录,请先在“每日打卡”添加并完成一些训练记录,然后帮我分析近期训练表现与改进建议。');
return;
}
const prompt = `请基于以下我的近期训练记录进行分析输出1整体训练负荷与节奏2动作与肌群的均衡性指出偏多/偏少3容易忽视的恢复与热身建议4后续一周的优化建议频次/时长/动作方向)。\n\n${summary}`;
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 && (
<Image source={{ uri: COACH_AVATAR }} style={styles.avatar} />
)}
<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: 'center',
padding: 8,
borderWidth: 1,
borderRadius: 16,
backgroundColor: 'rgba(0,0,0,0.04)'
},
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',
},
});