feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user