feat: 初始提交(仅核心代码,已排除大文件)

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
@@ -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;
}