- 新增教练页面,用户可以与教练进行互动和咨询 - 更新首页,切换到教练 tab 并传递名称参数 - 优化个人信息页面,添加注销帐号和退出登录功能 - 更新隐私政策和用户协议的链接,确保用户在使用前同意相关条款 - 修改今日训练页面标题为“开始训练”,提升用户体验 - 删除不再使用的进度条组件,简化代码结构
1159 lines
40 KiB
TypeScript
1159 lines
40 KiB
TypeScript
import { Ionicons } from '@expo/vector-icons';
|
||
import { BlurView } from 'expo-blur';
|
||
import * as ImagePicker from 'expo-image-picker';
|
||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
Alert,
|
||
FlatList,
|
||
Image,
|
||
Keyboard,
|
||
Modal,
|
||
Platform,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Text,
|
||
TextInput,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
import Markdown from 'react-native-markdown-display';
|
||
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
import { Colors } from '@/constants/Colors';
|
||
import { buildCosKey, buildPublicUrl } from '@/constants/Cos';
|
||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||
import { deleteConversation, getConversationDetail, listConversations, type AiConversationListItem } from '@/services/aiCoach';
|
||
import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession';
|
||
import { api, getAuthToken, postTextStream } from '@/services/api';
|
||
import { uploadWithRetry } from '@/services/cos';
|
||
import { updateUser as updateUserApi } from '@/services/users';
|
||
import type { CheckinRecord } from '@/store/checkinSlice';
|
||
import { fetchMyProfile, updateProfile } from '@/store/userSlice';
|
||
import dayjs from 'dayjs';
|
||
|
||
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 CoachScreen() {
|
||
const router = useRouter();
|
||
const params = useLocalSearchParams<{ name?: string }>();
|
||
const insets = useSafeAreaInsets();
|
||
|
||
const { isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
|
||
// 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色
|
||
const theme = Colors.light;
|
||
const coachName = (params?.name || 'Sarah').toString();
|
||
const [input, setInput] = useState('');
|
||
const [isSending, setIsSending] = useState(false);
|
||
const [isStreaming, setIsStreaming] = useState(false);
|
||
const [conversationId, setConversationId] = useState<string | undefined>(undefined);
|
||
const [messages, setMessages] = useState<ChatMessage[]>([{
|
||
id: 'm_welcome',
|
||
role: 'assistant',
|
||
content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~`,
|
||
}]);
|
||
const [historyVisible, setHistoryVisible] = useState(false);
|
||
const [historyLoading, setHistoryLoading] = useState(false);
|
||
const [historyPage, setHistoryPage] = useState(1);
|
||
const [historyTotal, setHistoryTotal] = useState(0);
|
||
const [historyItems, setHistoryItems] = useState<AiConversationListItem[]>([]);
|
||
const listRef = useRef<FlatList<ChatMessage>>(null);
|
||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||
const didInitialScrollRef = useRef(false);
|
||
const [composerHeight, setComposerHeight] = useState<number>(80);
|
||
const shouldAutoScrollRef = useRef(false);
|
||
const [keyboardOffset, setKeyboardOffset] = useState(0);
|
||
const [headerHeight, setHeaderHeight] = useState<number>(60);
|
||
const pendingAssistantIdRef = useRef<string | null>(null);
|
||
const [selectedImages, setSelectedImages] = useState<Array<{
|
||
id: string;
|
||
localUri: string;
|
||
width?: number;
|
||
height?: number;
|
||
progress: number;
|
||
uploadedKey?: string;
|
||
uploadedUrl?: string;
|
||
error?: string;
|
||
}>>([]);
|
||
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
|
||
|
||
const planDraft = useAppSelector((s) => s.trainingPlan?.draft);
|
||
const checkin = useAppSelector((s) => (s as any).checkin);
|
||
const dispatch = useAppDispatch();
|
||
const userProfile = useAppSelector((s) => (s as any)?.user?.profile);
|
||
|
||
const chips = useMemo(() => [
|
||
{ key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') },
|
||
{ key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() },
|
||
{ key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
|
||
{ key: 'weight', label: '记体重', action: () => insertWeightInputCard() },
|
||
], [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]);
|
||
// 启动页面时尝试恢复当次应用会话缓存
|
||
useEffect(() => {
|
||
(async () => {
|
||
try {
|
||
const cached = await loadAiCoachSessionCache();
|
||
if (cached && Array.isArray(cached.messages) && cached.messages.length > 0) {
|
||
setConversationId(cached.conversationId);
|
||
setMessages(cached.messages as any);
|
||
setTimeout(scrollToEnd, 0);
|
||
}
|
||
} catch { }
|
||
})();
|
||
}, [scrollToEnd]);
|
||
|
||
// 会话变动时,轻量防抖写入缓存(在本次应用生命周期内可跨页面恢复;下次冷启动会被根布局清空)
|
||
const saveCacheTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
useEffect(() => {
|
||
if (saveCacheTimerRef.current) clearTimeout(saveCacheTimerRef.current);
|
||
saveCacheTimerRef.current = setTimeout(() => {
|
||
saveAiCoachSessionCache({ conversationId, messages: messages as any, updatedAt: Date.now() }).catch(() => { });
|
||
}, 150);
|
||
return () => { if (saveCacheTimerRef.current) clearTimeout(saveCacheTimerRef.current); };
|
||
// 仅在 messages 或 conversationId 变化时触发
|
||
}, [messages, conversationId]);
|
||
|
||
|
||
// 取消对 messages.length 的全局监听滚动,改为在"消息实际追加完成"后再判断与滚动,避免突兀与多次触发
|
||
|
||
useEffect(() => {
|
||
// 输入区高度变化时,若用户在底部则轻柔跟随一次
|
||
if (isAtBottom) {
|
||
const id = setTimeout(scrollToEnd, 0);
|
||
return () => clearTimeout(id);
|
||
}
|
||
}, [composerHeight, isAtBottom, scrollToEnd]);
|
||
|
||
// 键盘事件:在键盘弹出时,将输入区与悬浮按钮一起上移,避免遮挡
|
||
useEffect(() => {
|
||
let showSub: any = null;
|
||
let hideSub: any = null;
|
||
if (Platform.OS === 'ios') {
|
||
showSub = Keyboard.addListener('keyboardWillChangeFrame', (e: any) => {
|
||
try {
|
||
const height = Math.max(0, (e.endCoordinates?.height ?? 0) - insets.bottom);
|
||
setKeyboardOffset(height);
|
||
} catch { setKeyboardOffset(0); }
|
||
});
|
||
hideSub = Keyboard.addListener('keyboardWillHide', () => setKeyboardOffset(0));
|
||
} else {
|
||
showSub = Keyboard.addListener('keyboardDidShow', (e: any) => {
|
||
try { setKeyboardOffset(Math.max(0, e.endCoordinates?.height ?? 0)); } catch { setKeyboardOffset(0); }
|
||
});
|
||
hideSub = Keyboard.addListener('keyboardDidHide', () => setKeyboardOffset(0));
|
||
}
|
||
return () => {
|
||
try { showSub?.remove?.(); } catch { }
|
||
try { hideSub?.remove?.(); } catch { }
|
||
};
|
||
}, [insets.bottom]);
|
||
|
||
const streamAbortRef = useRef<{ abort: () => void } | null>(null);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
try { streamAbortRef.current?.abort(); } catch { }
|
||
};
|
||
}, []);
|
||
|
||
function ensureConversationId(): string {
|
||
if (conversationId && conversationId.trim()) return conversationId;
|
||
const cid = `mobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||
setConversationId(cid);
|
||
try { console.log('[AI_CHAT][ui] create temp conversationId', cid); } catch { }
|
||
return cid;
|
||
}
|
||
|
||
function convertToServerMessages(history: ChatMessage[]): Array<{ role: 'user' | 'assistant' | 'system'; content: string }> {
|
||
// 仅映射 user/assistant 消息;系统提示由后端自动注入
|
||
return history
|
||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||
.map((m) => ({ role: m.role, content: m.content }));
|
||
}
|
||
|
||
async function openHistory() {
|
||
if (isStreaming) {
|
||
try { streamAbortRef.current?.abort(); } catch { }
|
||
}
|
||
setHistoryVisible(true);
|
||
await refreshHistory(1);
|
||
}
|
||
|
||
async function refreshHistory(page = 1) {
|
||
try {
|
||
setHistoryLoading(true);
|
||
const resp = await listConversations(page, 20);
|
||
setHistoryPage(resp.page);
|
||
setHistoryTotal(resp.total);
|
||
setHistoryItems(resp.items || []);
|
||
} catch (e) {
|
||
Alert.alert('错误', (e as any)?.message || '获取会话列表失败');
|
||
} finally {
|
||
setHistoryLoading(false);
|
||
}
|
||
}
|
||
|
||
async function handleSelectConversation(id: string) {
|
||
try {
|
||
if (isStreaming) {
|
||
try { streamAbortRef.current?.abort(); } catch { }
|
||
}
|
||
const detail = await getConversationDetail(id);
|
||
if (!detail || !(detail as any).messages) {
|
||
Alert.alert('提示', '会话不存在或已删除');
|
||
return;
|
||
}
|
||
const mapped: ChatMessage[] = (detail.messages || [])
|
||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||
.map((m, idx) => ({ id: `${m.role}_${idx}_${Date.now()}`, role: m.role as Role, content: m.content || '' }));
|
||
setConversationId(detail.conversationId);
|
||
setMessages(mapped.length ? mapped : [{ id: 'm_welcome', role: 'assistant', content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~` }]);
|
||
setHistoryVisible(false);
|
||
setTimeout(scrollToEnd, 0);
|
||
} catch (e) {
|
||
Alert.alert('错误', (e as any)?.message || '加载会话失败');
|
||
}
|
||
}
|
||
|
||
function confirmDeleteConversation(id: string) {
|
||
Alert.alert('删除会话', '删除后将无法恢复,确定要删除该会话吗?', [
|
||
{ text: '取消', style: 'cancel' },
|
||
{
|
||
text: '删除', style: 'destructive', onPress: async () => {
|
||
try {
|
||
await deleteConversation(id);
|
||
if (conversationId === id) {
|
||
setConversationId(undefined);
|
||
setMessages([{ id: 'm_welcome', role: 'assistant', content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~` }]);
|
||
}
|
||
await refreshHistory(historyPage);
|
||
} catch (e) {
|
||
Alert.alert('错误', (e as any)?.message || '删除失败');
|
||
}
|
||
}
|
||
}
|
||
]);
|
||
}
|
||
|
||
async function sendStream(text: string) {
|
||
const tokenExists = !!getAuthToken();
|
||
try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50) }); } catch { }
|
||
|
||
// 终止上一次未完成的流
|
||
if (streamAbortRef.current) {
|
||
try { console.log('[AI_CHAT][ui] abort previous stream'); } catch { }
|
||
try { streamAbortRef.current.abort(); } catch { }
|
||
streamAbortRef.current = null;
|
||
}
|
||
|
||
// 发送 body:尽量提供历史消息,后端会优先使用 conversationId 关联上下文
|
||
const historyForServer = convertToServerMessages(messages);
|
||
const cid = ensureConversationId();
|
||
const body = {
|
||
conversationId: cid,
|
||
messages: [...historyForServer, { role: 'user' as const, content: text }],
|
||
stream: true,
|
||
};
|
||
|
||
// 在 UI 中先放置占位回答,随后持续增量更新
|
||
const assistantId = `a_${Date.now()}`;
|
||
const userMsgId = `u_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||
|
||
const userMsg: ChatMessage = { id: userMsgId, role: 'user', content: text };
|
||
shouldAutoScrollRef.current = isAtBottom;
|
||
setMessages((m) => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
|
||
pendingAssistantIdRef.current = assistantId;
|
||
|
||
setIsSending(true);
|
||
setIsStreaming(true);
|
||
|
||
let receivedAnyChunk = false;
|
||
|
||
const updateAssistantContent = (delta: string) => {
|
||
setMessages((prev) => {
|
||
const next = prev.map((msg) => {
|
||
if (msg.id === assistantId) {
|
||
return { ...msg, content: msg.content + delta };
|
||
}
|
||
return msg;
|
||
});
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const onChunk = (chunk: string) => {
|
||
receivedAnyChunk = true;
|
||
const atBottomNow = isAtBottom;
|
||
updateAssistantContent(chunk);
|
||
if (atBottomNow) {
|
||
// 在底部时,持续开启自动滚动,并主动触发一次滚动以避免极小增量未触发 onContentSizeChange 的情况
|
||
shouldAutoScrollRef.current = true;
|
||
setTimeout(scrollToEnd, 0);
|
||
}
|
||
try { console.log('[AI_CHAT][api] chunk', { length: chunk.length, preview: chunk.slice(0, 40) }); } catch { }
|
||
};
|
||
|
||
const onEnd = (cidFromHeader?: string) => {
|
||
setIsSending(false);
|
||
setIsStreaming(false);
|
||
streamAbortRef.current = null;
|
||
if (cidFromHeader && !conversationId) setConversationId(cidFromHeader);
|
||
pendingAssistantIdRef.current = null;
|
||
try { console.log('[AI_CHAT][api] end', { cidFromHeader, hadChunks: receivedAnyChunk }); } catch { }
|
||
};
|
||
|
||
const onError = async (err: any) => {
|
||
try { console.warn('[AI_CHAT][api] error', err); } catch { }
|
||
setIsSending(false);
|
||
setIsStreaming(false);
|
||
streamAbortRef.current = null;
|
||
pendingAssistantIdRef.current = null;
|
||
// 流式失败时的降级:尝试一次性非流式
|
||
try {
|
||
const bodyNoStream = { ...body, stream: false };
|
||
try { console.log('[AI_CHAT][fallback] try non-stream'); } catch { }
|
||
const resp = await api.post<{ conversationId?: string; text: string }>('/api/ai-coach/chat', bodyNoStream);
|
||
const textCombined = (resp as any)?.text ?? '';
|
||
if ((resp as any)?.conversationId && !conversationId) {
|
||
setConversationId((resp as any).conversationId);
|
||
}
|
||
setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: textCombined || '(空响应)' } : msg));
|
||
} catch (e2: any) {
|
||
setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: '抱歉,请求失败,请稍后再试。' } : msg));
|
||
try { console.warn('[AI_CHAT][fallback] non-stream error', e2); } catch { }
|
||
}
|
||
};
|
||
|
||
try {
|
||
const controller = postTextStream('/api/ai-coach/chat', body, { onChunk, onEnd, onError }, { timeoutMs: 120000 });
|
||
streamAbortRef.current = controller;
|
||
} catch (e) {
|
||
onError(e);
|
||
}
|
||
}
|
||
|
||
async function send(text: string) {
|
||
if (!isLoggedIn) {
|
||
pushIfAuthedElseLogin('/auth/login');
|
||
return;
|
||
}
|
||
if (isSending) return;
|
||
const trimmed = text.trim();
|
||
if (!trimmed && selectedImages.length === 0) return;
|
||
|
||
async function ensureImagesUploaded(): Promise<string[]> {
|
||
const urls: string[] = [];
|
||
for (const img of selectedImages) {
|
||
if (img.uploadedUrl) {
|
||
urls.push(img.uploadedUrl);
|
||
continue;
|
||
}
|
||
try {
|
||
const resp = await fetch(img.localUri);
|
||
const blob = await resp.blob();
|
||
const ext = (() => {
|
||
const t = (blob.type || '').toLowerCase();
|
||
if (t.includes('png')) return 'png';
|
||
if (t.includes('webp')) return 'webp';
|
||
if (t.includes('heic')) return 'heic';
|
||
if (t.includes('heif')) return 'heif';
|
||
return 'jpg';
|
||
})();
|
||
const key = buildCosKey({ prefix: 'images/chat', ext });
|
||
const res = await uploadWithRetry({
|
||
key,
|
||
body: blob,
|
||
contentType: blob.type || 'image/jpeg',
|
||
onProgress: ({ percent }: { percent?: number }) => {
|
||
const p = typeof percent === 'number' ? percent : 0;
|
||
setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, progress: p } : it));
|
||
},
|
||
} as any);
|
||
const url = buildPublicUrl(res.key);
|
||
urls.push(url);
|
||
setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, uploadedKey: res.key, uploadedUrl: url, progress: 1 } : it));
|
||
} catch (e: any) {
|
||
setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, error: e?.message || '上传失败' } : it));
|
||
throw e;
|
||
}
|
||
}
|
||
return urls;
|
||
}
|
||
|
||
try {
|
||
const urls = await ensureImagesUploaded();
|
||
const mdImages = urls.map((u) => ``).join('\n\n');
|
||
const composed = [trimmed, mdImages].filter(Boolean).join('\n\n');
|
||
setInput('');
|
||
setSelectedImages([]);
|
||
await sendStream(composed);
|
||
} catch (e: any) {
|
||
Alert.alert('上传失败', e?.message || '图片上传失败,请稍后重试');
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
const pickImages = useCallback(async () => {
|
||
try {
|
||
const result = await ImagePicker.launchImageLibraryAsync({
|
||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||
allowsMultipleSelection: true,
|
||
selectionLimit: 4,
|
||
quality: 0.9,
|
||
} as any);
|
||
if ((result as any).canceled) return;
|
||
const assets = (result as any).assets || [];
|
||
const next = assets.map((a: any) => ({
|
||
id: `${a.assetId || a.fileName || a.uri}_${Math.random().toString(36).slice(2, 8)}`,
|
||
localUri: a.uri,
|
||
width: a.width,
|
||
height: a.height,
|
||
progress: 0,
|
||
}));
|
||
setSelectedImages((prev) => {
|
||
const merged = [...prev, ...next];
|
||
return merged.slice(0, 4);
|
||
});
|
||
setTimeout(scrollToEnd, 0);
|
||
} catch (e: any) {
|
||
Alert.alert('错误', e?.message || '选择图片失败');
|
||
}
|
||
}, [scrollToEnd]);
|
||
|
||
const removeSelectedImage = useCallback((id: string) => {
|
||
setSelectedImages((prev) => prev.filter((it) => it.id !== id));
|
||
}, []);
|
||
|
||
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,
|
||
},
|
||
]}
|
||
>
|
||
{renderBubbleContent(item)}
|
||
</View>
|
||
{false}
|
||
</Animated.View>
|
||
);
|
||
}
|
||
|
||
function renderBubbleContent(item: ChatMessage) {
|
||
if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) {
|
||
return <Text style={[styles.bubbleText, { color: '#687076' }]}>正在思考…</Text>;
|
||
}
|
||
if (item.content?.startsWith('__WEIGHT_INPUT_CARD__')) {
|
||
const preset = (() => {
|
||
const m = item.content.split('\n')?.[1];
|
||
const v = parseFloat(m || '');
|
||
return isNaN(v) ? '' : String(v);
|
||
})();
|
||
return (
|
||
<View style={{ gap: 8 }}>
|
||
<Text style={[styles.bubbleText, { color: '#192126', fontWeight: '700' }]}>记录今日体重</Text>
|
||
<View style={styles.weightRow}>
|
||
<TextInput
|
||
placeholder="例如 60.5"
|
||
keyboardType="decimal-pad"
|
||
defaultValue={preset}
|
||
placeholderTextColor={'#687076'}
|
||
style={styles.weightInput}
|
||
onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text)}
|
||
returnKeyType="done"
|
||
submitBehavior="blurAndSubmit"
|
||
/>
|
||
<Text style={styles.weightUnit}>kg</Text>
|
||
<TouchableOpacity accessibilityRole="button" style={styles.weightSaveBtn} onPress={() => handleSubmitWeight((preset || '').toString())}>
|
||
<Text style={{ color: '#192126', fontWeight: '700' }}>保存</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
<Text style={{ color: '#687076', fontSize: 12 }}>按回车或点击保存,即可将该体重同步到账户并发送到对话。</Text>
|
||
</View>
|
||
);
|
||
}
|
||
return (
|
||
<Markdown style={markdownStyles} mergeStyle>
|
||
{item.content}
|
||
</Markdown>
|
||
);
|
||
}
|
||
|
||
function insertWeightInputCard() {
|
||
const id = `wcard_${Date.now()}`;
|
||
const preset = userProfile?.weight ? Number(userProfile.weight) : undefined;
|
||
const payload = `__WEIGHT_INPUT_CARD__\n${preset ?? ''}`;
|
||
setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]);
|
||
setTimeout(scrollToEnd, 0);
|
||
}
|
||
|
||
async function handleSubmitWeight(text?: string) {
|
||
const val = parseFloat(String(text ?? '').trim());
|
||
if (isNaN(val) || val <= 0 || val > 500) {
|
||
Alert.alert('请输入有效体重', '请填写合理的公斤数,例如 60.5');
|
||
return;
|
||
}
|
||
try {
|
||
// 本地更新
|
||
dispatch(updateProfile({ weight: String(val) }));
|
||
// 后端同步(若有 userId 则更稳妥;后端实现容错)
|
||
try {
|
||
const userId = (userProfile as any)?.userId || (userProfile as any)?.id || (userProfile as any)?._id;
|
||
if (userId) {
|
||
await updateUserApi({ userId, weight: val });
|
||
await dispatch(fetchMyProfile() as any);
|
||
}
|
||
} catch (e) {
|
||
// 不阻断对话体验
|
||
}
|
||
// 在对话中插入"确认消息"并发送给教练
|
||
const textMsg = `记录了今日体重:${val} kg。`;
|
||
await send(textMsg);
|
||
} catch (e: any) {
|
||
Alert.alert('保存失败', e?.message || '请稍后重试');
|
||
}
|
||
}
|
||
|
||
return (
|
||
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
||
{/* 顶部标题区域,只显示教练名称和历史按钮 */}
|
||
<View
|
||
style={[styles.header, { paddingTop: insets.top + 10 }]}
|
||
onLayout={(e) => {
|
||
const h = e.nativeEvent.layout.height;
|
||
if (h && Math.abs(h - headerHeight) > 0.5) setHeaderHeight(h);
|
||
}}
|
||
>
|
||
<Text style={[styles.headerTitle, { color: theme.text }]}>教练 {coachName}</Text>
|
||
<TouchableOpacity accessibilityRole="button" onPress={openHistory} style={[styles.historyButton, { backgroundColor: 'rgba(187,242,70,0.2)' }]}>
|
||
<Ionicons name="time-outline" size={18} color={theme.onPrimary} />
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* 消息列表容器 - 设置固定高度避免输入框重叠 */}
|
||
<View style={{
|
||
flex: 1,
|
||
marginBottom: composerHeight + keyboardOffset
|
||
}}>
|
||
<FlatList
|
||
ref={listRef}
|
||
data={messages}
|
||
keyExtractor={(m) => m.id}
|
||
renderItem={renderItem}
|
||
onLayout={() => {
|
||
// 确保首屏布局后也尝试滚动
|
||
if (!didInitialScrollRef.current) {
|
||
didInitialScrollRef.current = true;
|
||
setTimeout(scrollToEnd, 0);
|
||
requestAnimationFrame(scrollToEnd);
|
||
}
|
||
}}
|
||
contentContainerStyle={{
|
||
paddingHorizontal: 14,
|
||
paddingTop: 8,
|
||
paddingBottom: 16
|
||
}}
|
||
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}
|
||
/>
|
||
</View>
|
||
|
||
<BlurView
|
||
intensity={18}
|
||
tint={'light'}
|
||
style={[styles.composerWrap, { paddingBottom: getTabBarBottomPadding() + 10, bottom: keyboardOffset }]}
|
||
onLayout={(e) => {
|
||
const h = e.nativeEvent.layout.height;
|
||
if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h);
|
||
}}
|
||
>
|
||
<ScrollView
|
||
horizontal
|
||
showsHorizontalScrollIndicator={false}
|
||
decelerationRate="fast"
|
||
snapToAlignment="start"
|
||
style={styles.chipsRowScroll}
|
||
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
|
||
>
|
||
{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>
|
||
))}
|
||
</ScrollView>
|
||
|
||
{!!selectedImages.length && (
|
||
<ScrollView
|
||
horizontal
|
||
showsHorizontalScrollIndicator={false}
|
||
style={styles.imagesRow}
|
||
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
|
||
>
|
||
{selectedImages.map((img) => (
|
||
<View key={img.id} style={styles.imageThumbWrap}>
|
||
<TouchableOpacity accessibilityRole="imagebutton" onPress={() => setPreviewImageUri(img.uploadedUrl || img.localUri)}>
|
||
<Image source={{ uri: img.uploadedUrl || img.localUri }} style={styles.imageThumb} />
|
||
</TouchableOpacity>
|
||
{!!(img.progress > 0 && img.progress < 1) && (
|
||
<View style={styles.imageProgressOverlay}>
|
||
<Text style={styles.imageProgressText}>{Math.round((img.progress || 0) * 100)}%</Text>
|
||
</View>
|
||
)}
|
||
<TouchableOpacity accessibilityRole="button" onPress={() => removeSelectedImage(img.id)} style={styles.imageRemoveBtn}>
|
||
<Ionicons name="close" size={12} color="#fff" />
|
||
</TouchableOpacity>
|
||
</View>
|
||
))}
|
||
</ScrollView>
|
||
)}
|
||
|
||
<View style={[styles.inputRow, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.08)' }]}>
|
||
<TouchableOpacity
|
||
accessibilityRole="button"
|
||
onPress={pickImages}
|
||
style={[styles.mediaBtn, { backgroundColor: 'rgba(187,242,70,0.16)' }]}
|
||
>
|
||
<Ionicons name="image-outline" size={18} color={'#192126'} />
|
||
</TouchableOpacity>
|
||
<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() && selectedImages.length === 0) || isSending}
|
||
onPress={() => send(input)}
|
||
style={[
|
||
styles.sendBtn,
|
||
{ backgroundColor: theme.primary, opacity: (input.trim() || selectedImages.length > 0) && !isSending ? 1 : 0.5 }
|
||
]}
|
||
>
|
||
{isSending ? (
|
||
<ActivityIndicator color={theme.onPrimary} />
|
||
) : (
|
||
<Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
|
||
)}
|
||
</TouchableOpacity>
|
||
</View>
|
||
</BlurView>
|
||
|
||
{!isAtBottom && (
|
||
<TouchableOpacity
|
||
accessibilityRole="button"
|
||
onPress={scrollToEnd}
|
||
style={[styles.scrollToBottomFab, {
|
||
bottom: composerHeight + keyboardOffset + 10,
|
||
backgroundColor: theme.primary
|
||
}]}
|
||
>
|
||
<Ionicons name="chevron-down" size={18} color={theme.onPrimary} />
|
||
</TouchableOpacity>
|
||
)}
|
||
|
||
<Modal transparent visible={historyVisible} animationType="fade" onRequestClose={() => setHistoryVisible(false)}>
|
||
<TouchableOpacity activeOpacity={1} style={styles.modalBackdrop} onPress={() => setHistoryVisible(false)}>
|
||
<View style={[styles.modalSheet, { backgroundColor: '#FFFFFF' }]}>
|
||
<View style={styles.modalHeader}>
|
||
<Text style={styles.modalTitle}>历史会话</Text>
|
||
<TouchableOpacity accessibilityRole="button" onPress={() => refreshHistory(historyPage)} style={styles.modalRefreshBtn}>
|
||
<Ionicons name="refresh" size={16} color="#192126" />
|
||
</TouchableOpacity>
|
||
</View>
|
||
{historyLoading ? (
|
||
<View style={{ paddingVertical: 20, alignItems: 'center' }}>
|
||
<ActivityIndicator />
|
||
<Text style={{ marginTop: 8, color: '#687076' }}>加载中...</Text>
|
||
</View>
|
||
) : (
|
||
<ScrollView style={{ maxHeight: 360 }}>
|
||
{historyItems.length === 0 ? (
|
||
<Text style={{ padding: 16, color: '#687076' }}>暂无会话</Text>
|
||
) : (
|
||
historyItems.map((it) => (
|
||
<View key={it.conversationId} style={styles.historyRow}>
|
||
<TouchableOpacity
|
||
style={{ flex: 1 }}
|
||
onPress={() => handleSelectConversation(it.conversationId)}
|
||
>
|
||
<Text style={styles.historyTitle} numberOfLines={1}>{it.title || '未命名会话'}</Text>
|
||
<Text style={styles.historyMeta}>
|
||
{dayjs(it.lastMessageAt || it.createdAt).format('YYYY/MM/DD HH:mm')}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity accessibilityRole="button" onPress={() => confirmDeleteConversation(it.conversationId)} style={styles.historyDeleteBtn}>
|
||
<Ionicons name="trash-outline" size={16} color="#FF4444" />
|
||
</TouchableOpacity>
|
||
</View>
|
||
))
|
||
)}
|
||
</ScrollView>
|
||
)}
|
||
<View style={styles.modalFooter}>
|
||
<TouchableOpacity accessibilityRole="button" onPress={() => setHistoryVisible(false)} style={styles.modalCloseBtn}>
|
||
<Text style={{ color: '#192126', fontWeight: '600' }}>关闭</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
</TouchableOpacity>
|
||
</Modal>
|
||
<Modal transparent visible={!!previewImageUri} animationType="fade" onRequestClose={() => setPreviewImageUri(null)}>
|
||
<TouchableOpacity activeOpacity={1} style={styles.previewBackdrop} onPress={() => setPreviewImageUri(null)}>
|
||
<View style={styles.previewBox}>
|
||
{previewImageUri ? (
|
||
<Image source={{ uri: previewImageUri }} style={styles.previewImage} resizeMode="contain" />
|
||
) : null}
|
||
</View>
|
||
</TouchableOpacity>
|
||
</Modal>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
screen: {
|
||
flex: 1,
|
||
},
|
||
header: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: 16,
|
||
paddingBottom: 10,
|
||
},
|
||
headerTitle: {
|
||
fontSize: 20,
|
||
fontWeight: '800',
|
||
},
|
||
historyButton: {
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 16,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
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,
|
||
},
|
||
weightRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
},
|
||
weightInput: {
|
||
flex: 1,
|
||
height: 36,
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(0,0,0,0.08)',
|
||
borderRadius: 8,
|
||
paddingHorizontal: 10,
|
||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||
color: '#192126',
|
||
},
|
||
weightUnit: {
|
||
color: '#192126',
|
||
fontWeight: '700',
|
||
},
|
||
weightSaveBtn: {
|
||
height: 36,
|
||
paddingHorizontal: 12,
|
||
borderRadius: 8,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: 'rgba(187,242,70,0.6)'
|
||
},
|
||
// markdown 基础样式承载容器的字体尺寸保持与气泡一致
|
||
composerWrap: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
paddingTop: 8,
|
||
paddingHorizontal: 10,
|
||
borderTopWidth: 0,
|
||
},
|
||
chipsRow: {
|
||
flexDirection: 'row',
|
||
gap: 8,
|
||
paddingHorizontal: 6,
|
||
marginBottom: 8,
|
||
},
|
||
chipsRowScroll: {
|
||
marginBottom: 8,
|
||
},
|
||
chip: {
|
||
paddingHorizontal: 10,
|
||
height: 34,
|
||
borderRadius: 18,
|
||
borderWidth: 1,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: 'transparent',
|
||
},
|
||
chipText: {
|
||
fontSize: 13,
|
||
fontWeight: '600',
|
||
},
|
||
imagesRow: {
|
||
maxHeight: 92,
|
||
marginBottom: 8,
|
||
},
|
||
imageThumbWrap: {
|
||
width: 72,
|
||
height: 72,
|
||
borderRadius: 12,
|
||
overflow: 'hidden',
|
||
position: 'relative',
|
||
backgroundColor: 'rgba(0,0,0,0.06)'
|
||
},
|
||
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'
|
||
},
|
||
inputRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
padding: 8,
|
||
borderWidth: 1,
|
||
borderRadius: 16,
|
||
backgroundColor: 'rgba(0,0,0,0.04)'
|
||
},
|
||
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',
|
||
},
|
||
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,
|
||
},
|
||
modalBackdrop: {
|
||
flex: 1,
|
||
backgroundColor: 'rgba(0,0,0,0.35)',
|
||
padding: 16,
|
||
justifyContent: 'flex-end',
|
||
},
|
||
modalSheet: {
|
||
borderTopLeftRadius: 16,
|
||
borderTopRightRadius: 16,
|
||
paddingHorizontal: 12,
|
||
paddingTop: 10,
|
||
paddingBottom: 12,
|
||
},
|
||
modalHeader: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: 4,
|
||
paddingBottom: 8,
|
||
},
|
||
modalTitle: {
|
||
fontSize: 16,
|
||
fontWeight: '800',
|
||
color: '#192126',
|
||
},
|
||
modalRefreshBtn: {
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 14,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: 'rgba(0,0,0,0.06)'
|
||
},
|
||
historyRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingVertical: 10,
|
||
paddingHorizontal: 8,
|
||
borderRadius: 10,
|
||
},
|
||
historyTitle: {
|
||
fontSize: 15,
|
||
color: '#192126',
|
||
fontWeight: '600',
|
||
},
|
||
historyMeta: {
|
||
marginTop: 2,
|
||
fontSize: 12,
|
||
color: '#687076',
|
||
},
|
||
historyDeleteBtn: {
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 14,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: 'rgba(255,68,68,0.08)'
|
||
},
|
||
modalFooter: {
|
||
paddingTop: 8,
|
||
alignItems: 'flex-end',
|
||
},
|
||
modalCloseBtn: {
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 8,
|
||
borderRadius: 10,
|
||
backgroundColor: 'rgba(0,0,0,0.06)'
|
||
},
|
||
previewBackdrop: {
|
||
flex: 1,
|
||
backgroundColor: 'rgba(0,0,0,0.85)',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: 16,
|
||
},
|
||
previewBox: {
|
||
width: '100%',
|
||
height: '80%',
|
||
borderRadius: 12,
|
||
overflow: 'hidden',
|
||
},
|
||
previewImage: {
|
||
width: '100%',
|
||
height: '100%',
|
||
},
|
||
});
|
||
|
||
const markdownStyles = {
|
||
body: {
|
||
color: '#192126',
|
||
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' },
|
||
} as const;
|