import { Ionicons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; import { useLocalSearchParams, useRouter } from 'expo-router'; import React, { useCallback, useEffect, 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([{ id: 'm_welcome', role: 'assistant', content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~`, }]); const listRef = useRef>(null); const [isAtBottom, setIsAtBottom] = useState(true); const didInitialScrollRef = useRef(false); const [composerHeight, setComposerHeight] = useState(80); const shouldAutoScrollRef = useRef(false); 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]); const scrollToEnd = useCallback(() => { requestAnimationFrame(() => { listRef.current?.scrollToEnd({ animated: true }); }); }, []); const handleScroll = useCallback((e: any) => { try { const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent || {}; const paddingToBottom = 60; const distanceFromBottom = (contentSize?.height || 0) - ((layoutMeasurement?.height || 0) + (contentOffset?.y || 0)); setIsAtBottom(distanceFromBottom <= paddingToBottom); } catch { } }, []); useEffect(() => { // 初次进入或恢复时,保持最新消息可见 scrollToEnd(); }, [scrollToEnd]); // 取消对 messages.length 的全局监听滚动,改为在“消息实际追加完成”后再判断与滚动,避免突兀与多次触发 useEffect(() => { // 输入区高度变化时,若用户在底部则轻柔跟随一次 if (isAtBottom) { const id = setTimeout(scrollToEnd, 0); return () => clearTimeout(id); } }, [composerHeight, isAtBottom, scrollToEnd]); async function fakeStreamResponse(prompt: string): Promise { // 占位实现:模拟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() }; // 标记:这次是新增消息,等内容真正渲染并触发 onContentSizeChange 时再滚动 shouldAutoScrollRef.current = isAtBottom; setMessages((m) => [...m, userMsg]); setInput(''); setIsSending(true); // 立即滚动改为延后到 onContentSizeChange,避免突兀 try { const replyText = await fakeStreamResponse(text.trim()); const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: replyText }; // 同理:AI 消息到达时,与内容变化同步滚动 shouldAutoScrollRef.current = isAtBottom; setMessages((m) => [...m, aiMsg]); } catch (e) { const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: '抱歉,请求失败,请稍后再试。' }; shouldAutoScrollRef.current = isAtBottom; setMessages((m) => [...m, aiMsg]); } finally { setIsSending(false); } } function handleQuickPlan() { const goalMap: Record = { 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 = {}; const exerciseCount: Record = {}; 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 ( {!isUser && ( )} {item.content} ); } return ( router.back()} tone="light" transparent /> m.id} renderItem={renderItem} onLayout={() => { // 确保首屏布局后也尝试滚动 if (!didInitialScrollRef.current) { didInitialScrollRef.current = true; setTimeout(scrollToEnd, 0); requestAnimationFrame(scrollToEnd); } }} contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8 }} ListFooterComponent={() => ( )} onContentSizeChange={() => { // 首次内容变化强制滚底,其余仅在接近底部时滚动 if (!didInitialScrollRef.current) { didInitialScrollRef.current = true; setTimeout(scrollToEnd, 0); requestAnimationFrame(scrollToEnd); return; } if (shouldAutoScrollRef.current) { shouldAutoScrollRef.current = false; setTimeout(scrollToEnd, 0); } }} onScroll={handleScroll} scrollEventThrottle={16} showsVerticalScrollIndicator={false} /> { const h = e.nativeEvent.layout.height; if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h); }} > {chips.map((c) => ( {c.label} ))} send(input)} blurOnSubmit={false} /> send(input)} style={[ styles.sendBtn, { backgroundColor: theme.primary, opacity: input.trim() && !isSending ? 1 : 0.5 } ]} > {isSending ? ( ) : ( )} {!isAtBottom && ( )} ); } 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', }, scrollToBottomFab: { position: 'absolute', right: 16, width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, elevation: 2, }, });