feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import axios from 'axios';
|
||||
import { ApiEnvelope, MotionCard, MotionSession } from './types';
|
||||
|
||||
const API_BASE_URL = 'http://47.101.56.101:8976/api/app_order_box';
|
||||
const MOTION_BASE = `${API_BASE_URL}/kits/motion_reminder`;
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const formatError = (error: any): string => {
|
||||
if (error?.response) {
|
||||
return `服务器响应 ${error.response.status}: ${error.response.statusText}\n${JSON.stringify(error.response.data)}`;
|
||||
}
|
||||
if (error?.request) {
|
||||
return `服务器无响应: 无法连接到 ${API_BASE_URL}`;
|
||||
}
|
||||
if (error?.message) {
|
||||
return `请求错误: ${error.message}`;
|
||||
}
|
||||
return '未知错误';
|
||||
};
|
||||
|
||||
export const motionReminderApi = {
|
||||
async listCards(): Promise<MotionCard[]> {
|
||||
try {
|
||||
const response = await api.get<ApiEnvelope<MotionCard[]>>(`${MOTION_BASE}/cards`);
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async saveCard(card: Partial<MotionCard>): Promise<MotionCard> {
|
||||
try {
|
||||
const response = card.id
|
||||
? await api.put<ApiEnvelope<MotionCard>>(`${MOTION_BASE}/cards`, card)
|
||||
: await api.post<ApiEnvelope<MotionCard>>(`${MOTION_BASE}/cards`, card);
|
||||
if (!response.data.ok || !response.data.data) {
|
||||
throw new Error(response.data.error || '保存失败');
|
||||
}
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCard(id: string): Promise<void> {
|
||||
try {
|
||||
await api.delete(`${MOTION_BASE}/cards`, { params: { id } });
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async listHistory(): Promise<MotionSession[]> {
|
||||
try {
|
||||
const response = await api.get<ApiEnvelope<MotionSession[]>>(`${MOTION_BASE}/history`);
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async startSession(card: MotionCard): Promise<MotionSession> {
|
||||
try {
|
||||
const response = await api.post<ApiEnvelope<MotionSession>>(`${MOTION_BASE}/sessions/start`, { card });
|
||||
if (!response.data.ok || !response.data.data) {
|
||||
throw new Error(response.data.error || '启动失败');
|
||||
}
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async syncSession(payload: Partial<MotionSession> & { id: string }): Promise<void> {
|
||||
try {
|
||||
await api.post(`${MOTION_BASE}/sessions/sync`, payload);
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async finishSession(payload: Partial<MotionSession> & { id: string; status: 'completed' | 'stopped' }): Promise<void> {
|
||||
try {
|
||||
await api.post(`${MOTION_BASE}/sessions/finish`, payload);
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,577 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Modal,
|
||||
PanResponder,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { motionReminderApi } from '../api';
|
||||
import { theme } from '../../../theme';
|
||||
import { LoopMode, MotionCard, MotionStep, MotionStepType } from '../types';
|
||||
|
||||
interface CardEditorProps {
|
||||
visible: boolean;
|
||||
initialCard: MotionCard | null;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const createId = () => `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const createDefaultStep = (type: MotionStepType = 'action'): MotionStep => ({
|
||||
id: createId(),
|
||||
type,
|
||||
name: type === 'rest' ? '休息' : '',
|
||||
durationSec: type === 'rest' ? 30 : 60,
|
||||
});
|
||||
|
||||
const createDraftCard = (): MotionCard => ({
|
||||
id: '',
|
||||
title: '',
|
||||
steps: [createDefaultStep('action'), createDefaultStep('rest')],
|
||||
loopMode: 'repeat_count',
|
||||
loopCount: 3,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
});
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||
const mm = Math.floor(safe / 60);
|
||||
const ss = safe % 60;
|
||||
if (mm > 0) return `${mm}分${ss}秒`;
|
||||
return `${ss}秒`;
|
||||
};
|
||||
|
||||
// ─── Draggable Step Item ───────────────────────────────────────────────
|
||||
|
||||
interface DraggableStepProps {
|
||||
step: MotionStep;
|
||||
index: number;
|
||||
totalSteps: number;
|
||||
canRemove: boolean;
|
||||
onUpdate: (updates: Partial<MotionStep>) => void;
|
||||
onRemove: () => void;
|
||||
onCopy: () => void;
|
||||
onDragStart: () => void;
|
||||
onDragMove: (dy: number) => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
const DraggableStepItem: React.FC<DraggableStepProps> = ({
|
||||
step, index, totalSteps, canRemove,
|
||||
onUpdate, onRemove, onCopy, onDragStart, onDragMove, onDragEnd,
|
||||
}) => {
|
||||
const isAction = step.type === 'action';
|
||||
const borderColor = isAction ? theme.colors.primary : theme.colors.success;
|
||||
const backgroundColor = isAction ? theme.colors.primarySoft : theme.colors.surfaceStrong;
|
||||
|
||||
const panResponder = PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: () => onDragStart(),
|
||||
onPanResponderMove: (_, gestureState) => onDragMove(gestureState.dy),
|
||||
onPanResponderRelease: () => onDragEnd(),
|
||||
onPanResponderTerminate: () => onDragEnd(),
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.stepEditor, { borderColor, backgroundColor }]}>
|
||||
<View {...panResponder.panHandlers} style={styles.stepDragHandle}>
|
||||
<Ionicons name="reorder-three-outline" size={20} color={theme.colors.textSecondary} />
|
||||
</View>
|
||||
|
||||
<View style={styles.stepEditorHeader}>
|
||||
<View style={styles.stepTitleRow}>
|
||||
<Text style={[styles.stepBadge, { backgroundColor: borderColor }]}>
|
||||
{isAction ? '动作' : '休息'}
|
||||
</Text>
|
||||
<Text style={styles.stepEditorTitle}>步骤 {index + 1}</Text>
|
||||
</View>
|
||||
<View style={styles.stepActions}>
|
||||
<TouchableOpacity onPress={onCopy}>
|
||||
<Ionicons name="copy-outline" size={18} color={theme.colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity disabled={!canRemove} onPress={onRemove}>
|
||||
<Ionicons name="trash-outline" size={18} color={canRemove ? theme.colors.error : theme.colors.textDisabled} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={step.name}
|
||||
onChangeText={(name) => onUpdate({ name })}
|
||||
placeholder={isAction ? '例如:俯卧撑' : '例如:休息'}
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={String(step.durationSec || '')}
|
||||
keyboardType="numeric"
|
||||
onChangeText={(value) => {
|
||||
const numericValue = value.replace(/[^0-9]/g, '');
|
||||
onUpdate({ durationSec: numericValue === '' ? null : Number(numericValue) });
|
||||
}}
|
||||
placeholder="时长(秒)"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
/>
|
||||
|
||||
<Text style={styles.stepDurationHint}>时长:{formatDuration(step.durationSec || 0)}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Card Editor ───────────────────────────────────────────────────────
|
||||
|
||||
export const CardEditor: React.FC<CardEditorProps> = ({ visible, initialCard, onSave, onClose }) => {
|
||||
const [draftCard, setDraftCard] = useState<MotionCard>(initialCard || createDraftCard());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dragOffset, setDragOffset] = useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setDraftCard(initialCard || createDraftCard());
|
||||
setDragIndex(null);
|
||||
setDragOffset(0);
|
||||
}
|
||||
}, [visible, initialCard]);
|
||||
|
||||
const totalDuration = draftCard.steps.reduce((sum, step) => sum + Math.max(1, Number(step.durationSec || 0)), 0);
|
||||
const loops = draftCard.loopMode === 'repeat_count'
|
||||
? Math.max(1, Number(draftCard.loopCount || 1))
|
||||
: null;
|
||||
const globalDuration = loops !== null ? totalDuration * loops : null;
|
||||
|
||||
const handleSave = async () => {
|
||||
const title = draftCard.title.trim();
|
||||
const cleanSteps = draftCard.steps
|
||||
.map((step) => ({
|
||||
...step,
|
||||
name: step.name.trim(),
|
||||
durationSec: Math.max(1, Math.floor(Number(step.durationSec || 0))),
|
||||
}))
|
||||
.filter((step) => step.name && step.durationSec > 0);
|
||||
|
||||
// 检查是否有步骤时长为空或无效
|
||||
const invalidSteps = draftCard.steps.filter((s) => {
|
||||
const val = Number(s.durationSec || 0);
|
||||
return !val || val <= 0;
|
||||
});
|
||||
if (invalidSteps.length > 0) {
|
||||
Alert.alert('无法保存', `${invalidSteps.length} 个步骤的时长无效,请输入大于 0 的数字`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
Alert.alert('无法保存', '请输入卡片名称');
|
||||
return;
|
||||
}
|
||||
if (!cleanSteps.length) {
|
||||
Alert.alert('无法保存', '至少保留一个有效步骤');
|
||||
return;
|
||||
}
|
||||
if (draftCard.loopMode === 'repeat_count' && Math.max(1, Number(draftCard.loopCount || 0)) < 1) {
|
||||
Alert.alert('无法保存', '循环次数至少为 1');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await motionReminderApi.saveCard({
|
||||
...draftCard,
|
||||
title,
|
||||
steps: cleanSteps,
|
||||
loopCount: draftCard.loopMode === 'repeat_count'
|
||||
? Math.max(1, Number(draftCard.loopCount || 1))
|
||||
: null,
|
||||
});
|
||||
onSave();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '保存失败';
|
||||
Alert.alert('保存失败', message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStep = useCallback((stepId: string, updates: Partial<MotionStep>) => {
|
||||
setDraftCard((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) => (s.id === stepId ? { ...s, ...updates } : s)),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const removeStep = useCallback((stepId: string) => {
|
||||
setDraftCard((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.filter((s) => s.id !== stepId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const copyStep = useCallback((stepId: string) => {
|
||||
setDraftCard((prev) => {
|
||||
const step = prev.steps.find((s) => s.id === stepId);
|
||||
if (!step) return prev;
|
||||
const newStep: MotionStep = {
|
||||
...step,
|
||||
id: createId(),
|
||||
name: `${step.name} (副本)`,
|
||||
};
|
||||
const index = prev.steps.findIndex((s) => s.id === stepId);
|
||||
const newSteps = [...prev.steps];
|
||||
newSteps.splice(index + 1, 0, newStep);
|
||||
return { ...prev, steps: newSteps };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addStep = useCallback((type: MotionStepType) => {
|
||||
setDraftCard((prev) => ({ ...prev, steps: [...prev.steps, createDefaultStep(type)] }));
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback((index: number) => {
|
||||
setDragIndex(index);
|
||||
setDragOffset(0);
|
||||
}, []);
|
||||
|
||||
const handleDragMove = useCallback((dy: number) => {
|
||||
setDragOffset(dy);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (dragIndex === null) return;
|
||||
const threshold = 60;
|
||||
let newIndex = dragIndex;
|
||||
if (dragOffset > threshold && dragIndex < draftCard.steps.length - 1) {
|
||||
newIndex = dragIndex + 1;
|
||||
} else if (dragOffset < -threshold && dragIndex > 0) {
|
||||
newIndex = dragIndex - 1;
|
||||
}
|
||||
if (newIndex !== dragIndex) {
|
||||
setDraftCard((prev) => {
|
||||
const newSteps = [...prev.steps];
|
||||
const [moved] = newSteps.splice(dragIndex, 1);
|
||||
newSteps.splice(newIndex, 0, moved);
|
||||
return { ...prev, steps: newSteps };
|
||||
});
|
||||
}
|
||||
setDragIndex(null);
|
||||
setDragOffset(0);
|
||||
}, [dragIndex, dragOffset, draftCard.steps.length]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContainer}>
|
||||
<View style={styles.modalHeader}>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<Ionicons name="close" size={24} color={theme.colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.modalTitle}>{draftCard.id ? '编辑流程卡片' : '新建流程卡片'}</Text>
|
||||
<TouchableOpacity disabled={saving} onPress={handleSave}>
|
||||
<Text style={[styles.saveText, saving && styles.disabledText]}>{saving ? '保存中…' : '保存'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.modalBody} contentContainerStyle={styles.modalContent}>
|
||||
<Text style={styles.inputLabel}>卡片名称</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={draftCard.title}
|
||||
onChangeText={(title) => setDraftCard((prev) => ({ ...prev, title }))}
|
||||
placeholder="例如:晨练循环"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
/>
|
||||
|
||||
<Text style={styles.inputLabel}>循环方式</Text>
|
||||
<View style={styles.segmentRow}>
|
||||
{[
|
||||
{ label: '单次', value: 'once' as LoopMode },
|
||||
{ label: 'N 次', value: 'repeat_count' as LoopMode },
|
||||
{ label: '无限', value: 'repeat_forever' as LoopMode },
|
||||
].map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.value}
|
||||
style={[styles.segmentButton, draftCard.loopMode === item.value && styles.segmentButtonActive]}
|
||||
onPress={() => setDraftCard((prev) => ({ ...prev, loopMode: item.value }))}
|
||||
>
|
||||
<Text style={[styles.segmentText, draftCard.loopMode === item.value && styles.segmentTextActive]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{draftCard.loopMode === 'repeat_count' ? (
|
||||
<>
|
||||
<Text style={styles.inputLabel}>循环次数</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={String(Math.max(1, Number(draftCard.loopCount || 1)))}
|
||||
keyboardType="numeric"
|
||||
onChangeText={(value) => {
|
||||
const next = Math.max(1, Number(value || 1));
|
||||
setDraftCard((prev) => ({ ...prev, loopCount: next }));
|
||||
}}
|
||||
placeholder="输入循环次数"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<View style={styles.stepsHeader}>
|
||||
<Text style={styles.sectionTitle}>流程步骤</Text>
|
||||
<View style={styles.stepsHeaderButtons}>
|
||||
<TouchableOpacity style={styles.smallButton} onPress={() => addStep('action')}>
|
||||
<Text style={styles.smallButtonText}>加动作</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.smallButton} onPress={() => addStep('rest')}>
|
||||
<Text style={styles.smallButtonText}>加休息</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Total duration preview */}
|
||||
<View style={styles.totalDurationCard}>
|
||||
<Ionicons name="time-outline" size={18} color={theme.colors.primary} />
|
||||
<Text style={styles.totalDurationText}>总时长:{formatDuration(totalDuration)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Steps list */}
|
||||
{draftCard.steps.map((step, index) => (
|
||||
<DraggableStepItem
|
||||
key={step.id}
|
||||
step={step}
|
||||
index={index}
|
||||
totalSteps={draftCard.steps.length}
|
||||
canRemove={draftCard.steps.length > 1}
|
||||
onUpdate={(updates) => updateStep(step.id, updates)}
|
||||
onRemove={() => removeStep(step.id)}
|
||||
onCopy={() => copyStep(step.id)}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragMove={handleDragMove}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 总时长信息卡片 */}
|
||||
<View style={styles.durationInfoCard}>
|
||||
<View style={styles.durationInfoRow}>
|
||||
<Ionicons name="timer-outline" size={18} color={theme.colors.textSecondary} />
|
||||
<Text style={styles.durationInfoLabel}>单轮时长</Text>
|
||||
<Text style={styles.durationInfoValue}>{formatDuration(totalDuration)}</Text>
|
||||
</View>
|
||||
<View style={styles.durationInfoRow}>
|
||||
<Ionicons name="repeat-outline" size={18} color={theme.colors.primary} />
|
||||
<Text style={styles.durationInfoLabel}>全局总时长</Text>
|
||||
<Text style={[styles.durationInfoValue, styles.durationInfoHighlight]}>
|
||||
{draftCard.loopMode === 'repeat_forever'
|
||||
? `无限循环(单轮 ${formatDuration(totalDuration)})`
|
||||
: formatDuration(globalDuration || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.58)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContainer: {
|
||||
maxHeight: '92%',
|
||||
backgroundColor: theme.colors.background,
|
||||
borderTopLeftRadius: theme.borderRadius.xl,
|
||||
borderTopRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: theme.spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
modalTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
saveText: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '700',
|
||||
},
|
||||
disabledText: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
modalBody: {
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
},
|
||||
modalContent: {
|
||||
paddingBottom: theme.spacing.xl,
|
||||
},
|
||||
inputLabel: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.text,
|
||||
marginTop: theme.spacing.lg,
|
||||
marginBottom: theme.spacing.sm,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.text,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: 12,
|
||||
marginBottom: theme.spacing.sm,
|
||||
},
|
||||
segmentRow: {
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
segmentButton: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.colors.surface,
|
||||
},
|
||||
segmentButtonActive: {
|
||||
borderColor: theme.colors.primary,
|
||||
backgroundColor: theme.colors.primarySoft,
|
||||
},
|
||||
segmentText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
segmentTextActive: {
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
stepsHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: theme.spacing.xl,
|
||||
marginBottom: theme.spacing.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
stepsHeaderButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
smallButton: {
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: 8,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surfaceStrong,
|
||||
},
|
||||
smallButtonText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
totalDurationCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.sm,
|
||||
backgroundColor: theme.colors.primarySoft,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
padding: theme.spacing.md,
|
||||
marginBottom: theme.spacing.md,
|
||||
},
|
||||
totalDurationText: {
|
||||
...theme.typography.bodyLarge,
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '700',
|
||||
},
|
||||
durationInfoCard: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
durationInfoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
durationInfoLabel: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.textSecondary,
|
||||
flex: 1,
|
||||
},
|
||||
durationInfoValue: {
|
||||
...theme.typography.bodyLarge,
|
||||
color: theme.colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
durationInfoHighlight: {
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '700',
|
||||
},
|
||||
stepEditor: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 2,
|
||||
padding: theme.spacing.md,
|
||||
marginBottom: theme.spacing.md,
|
||||
},
|
||||
stepDragHandle: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
padding: 4,
|
||||
},
|
||||
stepEditorHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: theme.spacing.md,
|
||||
},
|
||||
stepTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
stepBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
stepEditorTitle: {
|
||||
...theme.typography.bodyLarge,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
stepActions: {
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
stepDurationHint: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
marginTop: theme.spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Dimensions,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useKeepAwake } from 'expo-keep-awake';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { UseRunnerReturn } from '../hooks/useRunner';
|
||||
import { saveRunnerCache, clearRunnerCache } from '../hooks/useMotionAppState';
|
||||
import { theme } from '../../../theme';
|
||||
|
||||
interface RunnerModalProps {
|
||||
runner: UseRunnerReturn;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||
const mm = Math.floor(safe / 60);
|
||||
const ss = safe % 60;
|
||||
return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const RunnerModal: React.FC<RunnerModalProps> = ({ runner, onClose }) => {
|
||||
const { runner: runnerState, pauseSession, resumeSession, skipStep, finishSession } = runner;
|
||||
|
||||
// 保持屏幕常亮
|
||||
useKeepAwake();
|
||||
|
||||
// 进入后台时保存状态
|
||||
const handleBackground = useCallback(async () => {
|
||||
if (!runnerState || runnerState.status !== 'running') return;
|
||||
await saveRunnerCache({
|
||||
sessionId: runnerState.sessionId,
|
||||
currentLoop: runnerState.currentLoop,
|
||||
completedLoops: runnerState.completedLoops,
|
||||
currentStepIndex: runnerState.currentStepIndex,
|
||||
remainingSec: runnerState.remainingSec,
|
||||
status: runnerState.status,
|
||||
pausedAt: new Date().toISOString(),
|
||||
});
|
||||
await pauseSession();
|
||||
}, [runnerState, pauseSession]);
|
||||
|
||||
// 回到前台时恢复
|
||||
const handleForeground = useCallback(async () => {
|
||||
// 由外部 CardsScreen 处理恢复逻辑
|
||||
}, []);
|
||||
|
||||
// 暴露方法给父组件
|
||||
useEffect(() => {
|
||||
(window as any).__motionRunnerBackground = handleBackground;
|
||||
(window as any).__motionRunnerForeground = handleForeground;
|
||||
return () => {
|
||||
delete (window as any).__motionRunnerBackground;
|
||||
delete (window as any).__motionRunnerForeground;
|
||||
};
|
||||
}, [handleBackground, handleForeground]);
|
||||
|
||||
const currentStep = runnerState ? runnerState.card.steps[runnerState.currentStepIndex] : null;
|
||||
const nextStep = runnerState ? runnerState.card.steps[runnerState.currentStepIndex + 1] || null : null;
|
||||
const totalSteps = runnerState?.card.steps.length || 0;
|
||||
const totalLoops = runnerState?.card.loopMode === 'repeat_count'
|
||||
? Math.max(1, Number(runnerState.card.loopCount || 1))
|
||||
: null;
|
||||
|
||||
// 进度计算
|
||||
const stepDuration = currentStep?.durationSec || 0;
|
||||
const stepProgress = currentStep && stepDuration > 0
|
||||
? Math.max(0, Math.min(1, 1 - (runnerState?.remainingSec || 0) / stepDuration))
|
||||
: 0;
|
||||
|
||||
// 预计剩余时间计算
|
||||
const remainingStepsSec = (() => {
|
||||
if (!runnerState) return 0;
|
||||
const steps = runnerState.card.steps;
|
||||
// 当前步骤剩余秒数
|
||||
let total = runnerState.remainingSec || 0;
|
||||
// 当前轮剩余步骤
|
||||
for (let i = runnerState.currentStepIndex + 1; i < steps.length; i++) {
|
||||
total += Math.max(1, Number(steps[i]?.durationSec || 0));
|
||||
}
|
||||
// 剩余轮次
|
||||
const completedThisRound = runnerState.currentLoop + (stepProgress >= 1 ? 1 : 0);
|
||||
const roundsLeft = totalLoops !== null ? Math.max(0, totalLoops - completedThisRound) : 0;
|
||||
const perRoundSec = steps.reduce((sum, s) => sum + Math.max(1, Number(s.durationSec || 0)), 0);
|
||||
total += roundsLeft * perRoundSec;
|
||||
return total;
|
||||
})();
|
||||
|
||||
// 全局总时长
|
||||
const perRoundSec = runnerState?.card.steps.reduce((sum, s) => sum + Math.max(1, Number(s.durationSec || 0)), 0) || 0;
|
||||
const globalDurationSec = totalLoops !== null ? perRoundSec * totalLoops : null;
|
||||
|
||||
// 全局进度计算
|
||||
const executedSec = globalDurationSec !== null ? Math.max(0, globalDurationSec - remainingStepsSec) : 0;
|
||||
const globalProgress = globalDurationSec !== null && globalDurationSec > 0
|
||||
? Math.max(0, Math.min(1, executedSec / globalDurationSec))
|
||||
: 0;
|
||||
|
||||
const handleStop = () => {
|
||||
Alert.alert('结束执行', '确定提前结束本次执行吗?这会记录为手动提前结束。', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '结束',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
void finishSession('stopped', true);
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleAction = async (action: 'pause' | 'resume' | 'skip') => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
if (action === 'pause') await pauseSession();
|
||||
else if (action === 'resume') await resumeSession();
|
||||
else if (action === 'skip') skipStep();
|
||||
};
|
||||
|
||||
if (!runnerState) return null;
|
||||
|
||||
const isAction = currentStep?.type === 'action';
|
||||
const stepBorderColor = isAction ? theme.colors.primary : theme.colors.success;
|
||||
|
||||
return (
|
||||
<Modal visible={!!runnerState} animationType="slide" onRequestClose={handleStop}>
|
||||
<View style={styles.runnerContainer}>
|
||||
<View style={styles.runnerHeader}>
|
||||
<Text style={[styles.runnerBadge, { borderColor: stepBorderColor }]}>
|
||||
{isAction ? '动作' : '休息'}
|
||||
</Text>
|
||||
<Text style={styles.runnerTitle}>{runnerState.card.title}</Text>
|
||||
<Text style={styles.runnerLoopText}>
|
||||
第 {runnerState.currentLoop} 轮 {totalLoops ? `/ 共 ${totalLoops} 轮` : '/ 无限循环'}
|
||||
{' · '}步骤 {runnerState.currentStepIndex + 1} / {totalSteps}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Progress bar */}
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View style={[styles.progressBar, { width: `${stepProgress * 100}%` }]} />
|
||||
</View>
|
||||
|
||||
<View style={styles.runnerMain}>
|
||||
<Text style={styles.runnerLabel}>当前动作</Text>
|
||||
<Text style={[styles.runnerCurrentText, isAction && { color: stepBorderColor }]}>{currentStep?.name || '—'}</Text>
|
||||
<Text style={styles.runnerTimer}>{formatDuration(runnerState.remainingSec || 0)}</Text>
|
||||
|
||||
<View style={[styles.runnerNextCard, { borderColor: stepBorderColor }]}>
|
||||
<Text style={styles.runnerLabel}>下一个动作</Text>
|
||||
<Text style={styles.runnerNextText}>{nextStep?.name || '本轮结束'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.runnerFooter}>
|
||||
{/* 执行信息卡片 */}
|
||||
<View style={styles.runnerInfoCard}>
|
||||
<View style={styles.runnerInfoRow}>
|
||||
<Text style={styles.runnerInfoLabel}>已执行</Text>
|
||||
<Text style={styles.runnerInfoValue}>
|
||||
第 {runnerState.currentLoop} 轮,步骤 {runnerState.currentStepIndex + 1}/{totalSteps}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.runnerInfoRow}>
|
||||
<Text style={styles.runnerInfoLabel}>预计剩余</Text>
|
||||
<Text style={[styles.runnerInfoValue, styles.runnerInfoHighlight]}>
|
||||
约 {formatDuration(remainingStepsSec)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.runnerInfoRow}>
|
||||
<Text style={styles.runnerInfoLabel}>全局时长</Text>
|
||||
<Text style={[styles.runnerInfoValue, styles.runnerInfoHighlight]}>
|
||||
{globalDurationSec !== null
|
||||
? `约 ${formatDuration(globalDurationSec)}`
|
||||
: '无限循环'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 控制按钮 */}
|
||||
<View style={styles.runnerButtonRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.runnerButton}
|
||||
onPress={() => handleAction(runnerState.status === 'paused' ? 'resume' : 'pause')}
|
||||
>
|
||||
<Text style={styles.runnerButtonText}>{runnerState.status === 'paused' ? '继续' : '暂停'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.runnerButton} onPress={() => handleAction('skip')}>
|
||||
<Text style={styles.runnerButtonText}>跳过</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.runnerButton, styles.runnerDangerButton]} onPress={handleStop}>
|
||||
<Text style={styles.runnerButtonText}>结束</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 全局进度条 */}
|
||||
{globalDurationSec !== null && globalDurationSec > 0 && (
|
||||
<View style={styles.globalProgressWrap}>
|
||||
<View style={styles.globalProgressHeader}>
|
||||
<Text style={styles.globalProgressLabel}>执行进度</Text>
|
||||
<Text style={styles.globalProgressPercent}>{Math.round(globalProgress * 100)}%</Text>
|
||||
</View>
|
||||
<View style={styles.globalProgressBarContainer}>
|
||||
<View style={[styles.globalProgressBar, { width: `${globalProgress * 100}%` }]} />
|
||||
</View>
|
||||
<View style={styles.globalProgressStats}>
|
||||
<Text style={styles.globalProgressStat}>已执行 {formatDuration(executedSec)}</Text>
|
||||
<Text style={styles.globalProgressStatDivider}>/</Text>
|
||||
<Text style={styles.globalProgressStat}>总时长 {formatDuration(globalDurationSec)}</Text>
|
||||
<Text style={styles.globalProgressStatDivider}>·</Text>
|
||||
<Text style={[styles.globalProgressStat, styles.globalProgressRemaining]}>剩余 {formatDuration(remainingStepsSec)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
runnerContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
paddingTop: 72,
|
||||
paddingHorizontal: theme.spacing.lg,
|
||||
paddingBottom: theme.spacing.xl,
|
||||
},
|
||||
runnerHeader: {
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
runnerBadge: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.primary,
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
runnerTitle: {
|
||||
...theme.typography.h2,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
runnerLoopText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
progressBarContainer: {
|
||||
height: 10,
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: 5,
|
||||
marginVertical: theme.spacing.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: 5,
|
||||
},
|
||||
runnerMain: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.md,
|
||||
},
|
||||
runnerLabel: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
runnerCurrentText: {
|
||||
fontSize: 34,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
runnerTimer: {
|
||||
fontSize: 90,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
runnerNextCard: {
|
||||
width: '100%',
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
padding: theme.spacing.lg,
|
||||
alignItems: 'center',
|
||||
marginTop: theme.spacing.xl,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
runnerNextText: {
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
runnerFooter: {
|
||||
gap: theme.spacing.md,
|
||||
},
|
||||
runnerInfoCard: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
runnerInfoRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
runnerInfoLabel: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
runnerInfoValue: {
|
||||
...theme.typography.bodyLarge,
|
||||
color: theme.colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
runnerInfoHighlight: {
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '700',
|
||||
},
|
||||
runnerButtonRow: {
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
runnerButton: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surfaceStrong,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
runnerDangerButton: {
|
||||
backgroundColor: theme.colors.error,
|
||||
},
|
||||
runnerButtonText: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.text,
|
||||
fontWeight: '700',
|
||||
},
|
||||
// 全局进度条样式
|
||||
globalProgressWrap: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.lg,
|
||||
gap: theme.spacing.md,
|
||||
marginTop: theme.spacing.md,
|
||||
},
|
||||
globalProgressHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
globalProgressLabel: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.text,
|
||||
},
|
||||
globalProgressPercent: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
globalProgressBarContainer: {
|
||||
height: 24,
|
||||
backgroundColor: theme.colors.surfaceSoft,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
globalProgressBar: {
|
||||
height: '100%',
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: 12,
|
||||
},
|
||||
globalProgressStats: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
},
|
||||
globalProgressStat: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.text,
|
||||
},
|
||||
globalProgressStatDivider: {
|
||||
fontSize: 16,
|
||||
color: theme.colors.textSecondary,
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
globalProgressRemaining: {
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const RUNNER_CACHE_KEY = '@order_box_motion_runner_state';
|
||||
|
||||
export interface CachedRunnerState {
|
||||
sessionId: string;
|
||||
currentLoop: number;
|
||||
completedLoops: number;
|
||||
currentStepIndex: number;
|
||||
remainingSec: number;
|
||||
status: 'running' | 'paused';
|
||||
pausedAt: string;
|
||||
}
|
||||
|
||||
export interface UseMotionAppStateReturn {
|
||||
pauseRunner: () => void;
|
||||
resumeRunner: () => Promise<CachedRunnerState | null>;
|
||||
clearCache: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听 App 前后台切换,控制 Runner 暂停/恢复
|
||||
* 进入后台时:暂停 Runner 并保存状态到 AsyncStorage
|
||||
* 回到前台时:从 AsyncStorage 恢复状态
|
||||
*/
|
||||
export const useMotionAppState = (
|
||||
onBackground: () => void,
|
||||
onForeground: () => void
|
||||
): UseMotionAppStateReturn => {
|
||||
const appStateRef = useRef(AppState.currentState);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
const wasBackground = appStateRef.current.match(/inactive|background/);
|
||||
const isForeground = nextAppState === 'active';
|
||||
|
||||
if (!wasBackground && nextAppState.match(/inactive|background/)) {
|
||||
// 进入后台
|
||||
onBackground();
|
||||
}
|
||||
|
||||
if (wasBackground && isForeground) {
|
||||
// 回到前台
|
||||
onForeground();
|
||||
}
|
||||
|
||||
appStateRef.current = nextAppState;
|
||||
};
|
||||
|
||||
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||
return () => subscription.remove();
|
||||
}, [onBackground, onForeground]);
|
||||
|
||||
const pauseRunner = async () => {
|
||||
// 由外部调用,负责把当前 runner 状态存入缓存
|
||||
// 具体实现在 RunnerModal 中
|
||||
};
|
||||
|
||||
const resumeRunner = async (): Promise<CachedRunnerState | null> => {
|
||||
try {
|
||||
const cached = await AsyncStorage.getItem(RUNNER_CACHE_KEY);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
};
|
||||
|
||||
const clearCache = async () => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(RUNNER_CACHE_KEY);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return { pauseRunner, resumeRunner, clearCache };
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存 Runner 状态到 AsyncStorage
|
||||
*/
|
||||
export const saveRunnerCache = async (state: CachedRunnerState): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.setItem(RUNNER_CACHE_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除 Runner 缓存
|
||||
*/
|
||||
export const clearRunnerCache = async (): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(RUNNER_CACHE_KEY);
|
||||
} catch {}
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as Speech from 'expo-speech';
|
||||
import { motionReminderApi } from '../api';
|
||||
import { MotionCard, SessionStatus } from '../types';
|
||||
|
||||
export interface RunnerState {
|
||||
sessionId: string;
|
||||
card: MotionCard;
|
||||
status: SessionStatus;
|
||||
currentLoop: number;
|
||||
completedLoops: number;
|
||||
currentStepIndex: number;
|
||||
remainingSec: number;
|
||||
manualStopped: boolean;
|
||||
}
|
||||
|
||||
export interface UseRunnerReturn {
|
||||
runner: RunnerState | null;
|
||||
startSession: (card: MotionCard) => Promise<void>;
|
||||
pauseSession: () => Promise<void>;
|
||||
resumeSession: () => Promise<void>;
|
||||
skipStep: () => void;
|
||||
finishSession: (status: 'completed' | 'stopped', manualStopped: boolean) => Promise<void>;
|
||||
syncToServer: () => Promise<void>;
|
||||
}
|
||||
|
||||
const formatSpeechDuration = (seconds: number) => {
|
||||
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||
const mm = Math.floor(safe / 60);
|
||||
const ss = safe % 60;
|
||||
const parts: string[] = [];
|
||||
if (mm > 0) parts.push(`${mm}分钟`);
|
||||
if (ss > 0) parts.push(`${ss}秒`);
|
||||
if (!parts.length) parts.push('0秒');
|
||||
return parts.join('');
|
||||
};
|
||||
|
||||
export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
|
||||
const [runner, setRunner] = useState<RunnerState | null>(null);
|
||||
const announcementRef = useRef('');
|
||||
|
||||
const finishRunner = useCallback(async (status: 'completed' | 'stopped', manualStopped: boolean) => {
|
||||
const currentRunner = runner;
|
||||
if (!currentRunner) return;
|
||||
try {
|
||||
await motionReminderApi.finishSession({
|
||||
id: currentRunner.sessionId,
|
||||
status,
|
||||
currentLoop: currentRunner.currentLoop,
|
||||
completedLoops: currentRunner.completedLoops,
|
||||
currentStepIndex: currentRunner.currentStepIndex,
|
||||
manualStopped,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '结束失败';
|
||||
console.error('[Runner] 同步失败:', message);
|
||||
} finally {
|
||||
Speech.stop();
|
||||
setRunner(null);
|
||||
announcementRef.current = '';
|
||||
onSessionEnd?.();
|
||||
}
|
||||
}, [runner, onSessionEnd]);
|
||||
|
||||
const advanceRunner = useCallback(() => {
|
||||
setRunner((prev) => {
|
||||
if (!prev) return prev;
|
||||
const steps = prev.card.steps;
|
||||
const isLastStep = prev.currentStepIndex >= steps.length - 1;
|
||||
|
||||
if (!isLastStep) {
|
||||
return {
|
||||
...prev,
|
||||
currentStepIndex: prev.currentStepIndex + 1,
|
||||
remainingSec: Math.max(1, steps[prev.currentStepIndex + 1].durationSec || 0),
|
||||
};
|
||||
}
|
||||
|
||||
const nextCompletedLoops = prev.completedLoops + 1;
|
||||
const totalLoops = prev.card.loopMode === 'repeat_count'
|
||||
? Math.max(1, Number(prev.card.loopCount || 1))
|
||||
: null;
|
||||
|
||||
if (prev.card.loopMode === 'once' || (totalLoops !== null && nextCompletedLoops >= totalLoops)) {
|
||||
void finishRunner('completed', false);
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
currentLoop: prev.currentLoop + 1,
|
||||
completedLoops: nextCompletedLoops,
|
||||
currentStepIndex: 0,
|
||||
remainingSec: Math.max(1, steps[0].durationSec || 0),
|
||||
};
|
||||
});
|
||||
}, [finishRunner]);
|
||||
|
||||
// 倒计时逻辑
|
||||
useEffect(() => {
|
||||
if (!runner || runner.status !== 'running') return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const step = runner.card.steps[runner.currentStepIndex];
|
||||
const isAction = step?.type === 'action';
|
||||
const countdownPitch = isAction ? 1.1 : 0.9;
|
||||
|
||||
// 5/3 秒倒计时播报
|
||||
if (runner.remainingSec === 5 || runner.remainingSec === 3) {
|
||||
Speech.stop();
|
||||
Speech.speak(`还有 ${runner.remainingSec} 秒`, { language: 'zh-CN', pitch: countdownPitch, rate: 0.95 });
|
||||
}
|
||||
|
||||
if (runner.remainingSec <= 1) {
|
||||
advanceRunner();
|
||||
return;
|
||||
}
|
||||
|
||||
setRunner((prev) => (prev ? { ...prev, remainingSec: prev.remainingSec - 1 } : prev));
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [runner, advanceRunner]);
|
||||
|
||||
// 语音播报逻辑
|
||||
useEffect(() => {
|
||||
if (!runner) {
|
||||
announcementRef.current = '';
|
||||
return;
|
||||
}
|
||||
const key = `${runner.sessionId}:${runner.currentLoop}:${runner.currentStepIndex}`;
|
||||
if (announcementRef.current === key) return;
|
||||
announcementRef.current = key;
|
||||
|
||||
const step = runner.card.steps[runner.currentStepIndex];
|
||||
const nextStep = runner.card.steps[runner.currentStepIndex + 1] || null;
|
||||
if (!step) return;
|
||||
|
||||
// 动作步骤用较高音调,休息步骤用较低音调
|
||||
const stepPitch = step.type === 'action' ? 1.1 : 0.9;
|
||||
|
||||
const title = step.voiceText || `${step.name},${formatSpeechDuration(step.durationSec || 0)}`;
|
||||
const nextText = nextStep ? `。下一步,${nextStep.name}` : '';
|
||||
Speech.stop();
|
||||
Speech.speak(`第 ${runner.currentLoop} 轮,${title}${nextText}`, {
|
||||
language: 'zh-CN',
|
||||
pitch: stepPitch,
|
||||
rate: 0.95,
|
||||
});
|
||||
|
||||
// 同步到服务端
|
||||
void motionReminderApi.syncSession({
|
||||
id: runner.sessionId,
|
||||
currentLoop: runner.currentLoop,
|
||||
completedLoops: runner.completedLoops,
|
||||
currentStepIndex: runner.currentStepIndex,
|
||||
status: runner.status,
|
||||
manualStopped: runner.manualStopped,
|
||||
}).catch(() => {});
|
||||
}, [runner]);
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Speech.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startSession = async (card: MotionCard) => {
|
||||
if (!card.steps.length) {
|
||||
throw new Error('当前卡片没有可执行步骤');
|
||||
}
|
||||
const session = await motionReminderApi.startSession(card);
|
||||
setRunner({
|
||||
sessionId: session.id,
|
||||
card: session.cardSnapshot,
|
||||
status: 'running',
|
||||
currentLoop: 1,
|
||||
completedLoops: 0,
|
||||
currentStepIndex: 0,
|
||||
remainingSec: Math.max(1, session.cardSnapshot.steps[0]?.durationSec || 1),
|
||||
manualStopped: false,
|
||||
});
|
||||
};
|
||||
|
||||
const pauseSession = async () => {
|
||||
if (!runner) return;
|
||||
setRunner({ ...runner, status: 'paused' });
|
||||
try {
|
||||
await motionReminderApi.syncSession({
|
||||
id: runner.sessionId,
|
||||
status: 'paused',
|
||||
currentLoop: runner.currentLoop,
|
||||
completedLoops: runner.completedLoops,
|
||||
currentStepIndex: runner.currentStepIndex,
|
||||
manualStopped: runner.manualStopped,
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const resumeSession = async () => {
|
||||
if (!runner) return;
|
||||
setRunner({ ...runner, status: 'running' });
|
||||
try {
|
||||
await motionReminderApi.syncSession({
|
||||
id: runner.sessionId,
|
||||
status: 'running',
|
||||
currentLoop: runner.currentLoop,
|
||||
completedLoops: runner.completedLoops,
|
||||
currentStepIndex: runner.currentStepIndex,
|
||||
manualStopped: runner.manualStopped,
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const skipStep = () => {
|
||||
advanceRunner();
|
||||
};
|
||||
|
||||
const syncToServer = async () => {
|
||||
if (!runner) return;
|
||||
await motionReminderApi.syncSession({
|
||||
id: runner.sessionId,
|
||||
currentLoop: runner.currentLoop,
|
||||
completedLoops: runner.completedLoops,
|
||||
currentStepIndex: runner.currentStepIndex,
|
||||
status: runner.status,
|
||||
manualStopped: runner.manualStopped,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
runner,
|
||||
startSession,
|
||||
pauseSession,
|
||||
resumeSession,
|
||||
skipStep,
|
||||
finishSession: finishRunner,
|
||||
syncToServer,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export { CardsScreen } from './screens/CardsScreen';
|
||||
export { HistoryScreen } from './screens/HistoryScreen';
|
||||
export { CardEditor } from './components/CardEditor';
|
||||
export { RunnerModal } from './components/RunnerModal';
|
||||
export { useRunner } from './hooks/useRunner';
|
||||
export { useMotionAppState, saveRunnerCache, clearRunnerCache } from './hooks/useMotionAppState';
|
||||
export { motionReminderApi } from './api';
|
||||
export type * from './types';
|
||||
@@ -0,0 +1,369 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { motionReminderApi } from '../api';
|
||||
import { useRunner } from '../hooks/useRunner';
|
||||
import { CardEditor } from '../components/CardEditor';
|
||||
import { RunnerModal } from '../components/RunnerModal';
|
||||
import { theme } from '../../../theme';
|
||||
import { MotionCard, MotionSession, OrderBoxState } from '../../../types';
|
||||
|
||||
interface CardsScreenProps {
|
||||
onExit: () => void;
|
||||
serverState: OrderBoxState | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||
const mm = Math.floor(safe / 60);
|
||||
const ss = safe % 60;
|
||||
return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatLoopText = (loopMode: string, loopCount: number | null) => {
|
||||
if (loopMode === 'once') return '单次执行';
|
||||
if (loopMode === 'repeat_forever') return '无限循环';
|
||||
return `循环 ${Math.max(1, Number(loopCount || 1))} 次`;
|
||||
};
|
||||
|
||||
export const CardsScreen: React.FC<CardsScreenProps> = ({ onExit, serverState, onRefresh }) => {
|
||||
const [cards, setCards] = useState<MotionCard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editorVisible, setEditorVisible] = useState(false);
|
||||
const [editingCard, setEditingCard] = useState<MotionCard | null>(null);
|
||||
|
||||
const runner = useRunner(() => {
|
||||
onRefresh();
|
||||
});
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const nextCards = await motionReminderApi.listCards();
|
||||
setCards(nextCards);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载失败';
|
||||
Alert.alert('加载失败', message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
void loadData();
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const openCreateCard = () => {
|
||||
setEditingCard(null);
|
||||
setEditorVisible(true);
|
||||
};
|
||||
|
||||
const openEditCard = (card: MotionCard) => {
|
||||
setEditingCard(card);
|
||||
setEditorVisible(true);
|
||||
};
|
||||
|
||||
const deleteCard = (card: MotionCard) => {
|
||||
Alert.alert('确认删除', `确定删除"${card.title}"吗?`, [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await motionReminderApi.deleteCard(card.id);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '删除失败';
|
||||
Alert.alert('删除失败', message);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const startCard = async (card: MotionCard) => {
|
||||
if (!card.steps.length) {
|
||||
Alert.alert('无法启动', '当前卡片没有可执行步骤');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runner.startSession(card);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '启动失败';
|
||||
Alert.alert('启动失败', message);
|
||||
}
|
||||
};
|
||||
|
||||
const clearServerRunningState = async () => {
|
||||
try {
|
||||
await import('../../../api').then(({ orderBoxApi }) => orderBoxApi.resetRunningState());
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '清理失败';
|
||||
Alert.alert('清理失败', message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={onExit}>
|
||||
<Ionicons name="chevron-back" size={18} color={theme.colors.text} />
|
||||
<Text style={styles.backButtonText}>返回框子</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.primaryButton} onPress={openCreateCard}>
|
||||
<Ionicons name="add" size={18} color="#081018" />
|
||||
<Text style={styles.primaryButtonText}>新建卡片</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{serverState?.runningSession && !runner.runner ? (
|
||||
<View style={styles.warnCard}>
|
||||
<Text style={styles.warnCardTitle}>检测到服务端仍保留一个运行中的 session</Text>
|
||||
<Text style={styles.warnCardText}>如果这是上次异常退出遗留,可以先清理占用再重新启动。</Text>
|
||||
<TouchableOpacity style={styles.secondaryButton} onPress={clearServerRunningState}>
|
||||
<Text style={styles.secondaryButtonText}>清理占用</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<ActivityIndicator style={styles.loader} color={theme.colors.primary} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={cards}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.cardItem}>
|
||||
<View style={styles.cardItemHeader}>
|
||||
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||
<Text style={styles.cardMeta}>{formatLoopText(item.loopMode, item.loopCount)}</Text>
|
||||
</View>
|
||||
<Text style={styles.cardSubText}>{item.steps.length} 个步骤</Text>
|
||||
<View style={styles.stepPreviewWrap}>
|
||||
{item.steps.slice(0, 4).map((step, index) => (
|
||||
<Text key={step.id} style={styles.stepPreviewText}>
|
||||
{index + 1}. {step.name} · {formatDuration(step.durationSec || 0)}
|
||||
</Text>
|
||||
))}
|
||||
{item.steps.length > 4 ? (
|
||||
<Text style={styles.cardSubText}>还有 {item.steps.length - 4} 个步骤…</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.cardActions}>
|
||||
<TouchableOpacity style={styles.primaryActionButton} onPress={() => startCard(item)}>
|
||||
<Ionicons name="play" size={16} color="#081018" />
|
||||
<Text style={styles.primaryActionButtonText}>启动</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.ghostActionButton} onPress={() => openEditCard(item)}>
|
||||
<Text style={styles.ghostActionButtonText}>编辑</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.ghostActionButton} onPress={() => deleteCard(item)}>
|
||||
<Text style={[styles.ghostActionButtonText, styles.dangerText]}>删除</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyCard}>
|
||||
<Text style={styles.emptyTitle}>还没有流程卡片</Text>
|
||||
<Text style={styles.emptyText}>先建一个训练流程,再从卡片直接启动执行界面。</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CardEditor
|
||||
visible={editorVisible}
|
||||
initialCard={editingCard}
|
||||
onSave={() => {
|
||||
setEditorVisible(false);
|
||||
handleRefresh();
|
||||
}}
|
||||
onClose={() => setEditorVisible(false)}
|
||||
/>
|
||||
|
||||
<RunnerModal runner={runner} onClose={() => {}} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingTop: theme.spacing.md,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.xs,
|
||||
},
|
||||
backButtonText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
primaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.xs,
|
||||
backgroundColor: theme.colors.primary,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: 10,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
primaryButtonText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: '#081018',
|
||||
fontWeight: '700',
|
||||
},
|
||||
warnCard: {
|
||||
margin: theme.spacing.md,
|
||||
backgroundColor: theme.colors.surfaceSoft,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.warning,
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
warnCardTitle: {
|
||||
...theme.typography.bodyLarge,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
warnCardText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
secondaryButton: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: 10,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loader: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
listContent: {
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.md,
|
||||
},
|
||||
cardItem: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
cardItemHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
cardTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
flex: 1,
|
||||
},
|
||||
cardMeta: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
cardSubText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
stepPreviewWrap: {
|
||||
gap: theme.spacing.xs,
|
||||
},
|
||||
stepPreviewText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
cardActions: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: theme.spacing.sm,
|
||||
marginTop: theme.spacing.xs,
|
||||
},
|
||||
primaryActionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.xs,
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
primaryActionButtonText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: '#081018',
|
||||
fontWeight: '700',
|
||||
},
|
||||
ghostActionButton: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
ghostActionButtonText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
dangerText: {
|
||||
color: theme.colors.error,
|
||||
},
|
||||
emptyCard: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.xl,
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
emptyTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
emptyText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { motionReminderApi } from '../api';
|
||||
import { theme } from '../../../theme';
|
||||
import { MotionSession } from '../types';
|
||||
|
||||
interface HistoryScreenProps {
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
export const HistoryScreen: React.FC<HistoryScreenProps> = ({ onRefresh }) => {
|
||||
const [history, setHistory] = useState<MotionSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const nextHistory = await motionReminderApi.listHistory();
|
||||
setHistory(nextHistory);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载失败';
|
||||
Alert.alert('加载失败', message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
void loadData();
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{loading ? (
|
||||
<ActivityIndicator style={styles.loader} color={theme.colors.primary} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={history}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.cardItem}>
|
||||
<Text style={styles.cardTitle}>{item.cardSnapshot?.title || '未知流程'}</Text>
|
||||
<Text style={styles.historyLine}>开始:{formatDateTime(item.startedAt)}</Text>
|
||||
<Text style={styles.historyLine}>结束:{formatDateTime(item.endedAt)}</Text>
|
||||
<Text style={styles.historyLine}>完成轮次:{Math.max(0, Number(item.completedLoops || 0))}</Text>
|
||||
<Text style={styles.historyLine}>
|
||||
结束方式:{item.manualStopped ? '手动提前结束' : item.status === 'completed' ? '正常完成' : '停止'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyCard}>
|
||||
<Text style={styles.emptyTitle}>还没有执行记录</Text>
|
||||
<Text style={styles.emptyText}>从卡片页启动一次流程后,这里会出现历史记录。</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
loader: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
listContent: {
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.md,
|
||||
},
|
||||
cardItem: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
cardTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
historyLine: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
emptyCard: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.xl,
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
emptyTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
emptyText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
export type MotionStepType = 'action' | 'rest';
|
||||
export type LoopMode = 'once' | 'repeat_count' | 'repeat_forever';
|
||||
export type SessionStatus = 'running' | 'paused' | 'completed' | 'stopped';
|
||||
|
||||
export interface MotionStep {
|
||||
id: string;
|
||||
type: MotionStepType;
|
||||
name: string;
|
||||
durationSec: number | null;
|
||||
voiceText?: string;
|
||||
}
|
||||
|
||||
export interface MotionCard {
|
||||
id: string;
|
||||
title: string;
|
||||
steps: MotionStep[];
|
||||
loopMode: LoopMode;
|
||||
loopCount: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MotionSession {
|
||||
id: string;
|
||||
kitId: 'motion_reminder';
|
||||
cardId: string;
|
||||
cardSnapshot: MotionCard;
|
||||
status: SessionStatus;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
currentLoop: number;
|
||||
completedLoops: number;
|
||||
currentStepIndex: number;
|
||||
manualStopped: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
ok: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user