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,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',
},
});