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
+3
View File
@@ -0,0 +1,3 @@
{
"devices": []
}
+205
View File
@@ -0,0 +1,205 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Ionicons } from '@expo/vector-icons';
import { orderBoxApi } from './api';
import { theme } from './theme';
import { OrderBoxState } from './types';
import { CardsScreen, HistoryScreen } from './kits/motion_reminder';
const Tab = createBottomTabNavigator();
type ScreenMode = 'hub' | 'motion_reminder';
const HubScreen = ({
onEnterMotionReminder,
loading,
}: {
onEnterMotionReminder: () => void;
loading: boolean;
}) => (
<View style={styles.hubContainer}>
<StatusBar barStyle="light-content" backgroundColor={theme.colors.background} />
<View style={styles.hubHeader}>
<Text style={styles.hubTitle}>框子</Text>
<Text style={styles.hubSubtitle}>kit 聚合壳。当前阶段先落地第一个工具:运动提醒宝。</Text>
</View>
<TouchableOpacity style={styles.kitCard} disabled={loading} onPress={onEnterMotionReminder}>
<View style={styles.kitCardIcon}>
<Ionicons name="barbell-outline" size={28} color={theme.colors.primary} />
</View>
<View style={styles.kitCardBody}>
<Text style={styles.kitCardTitle}>运动提醒宝</Text>
<Text style={styles.kitCardText}>流程卡片、自动切步、语音播报、大字倒计时、下一个动作常驻显示。</Text>
</View>
<Ionicons name="chevron-forward" size={24} color={theme.colors.textSecondary} />
</TouchableOpacity>
</View>
);
function MotionReminderKit({ onExit }: { onExit: () => void }) {
const [serverState, setServerState] = useState<OrderBoxState | null>(null);
const [loading, setLoading] = useState(true);
const refreshData = useCallback(async () => {
try {
const nextState = await orderBoxApi.getState();
setServerState(nextState);
} catch (error) {
const message = error instanceof Error ? error.message : '加载失败';
Alert.alert('加载失败', message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshData();
}, [refreshData]);
return (
<NavigationContainer independent>
<Tab.Navigator
screenOptions={{
headerStyle: { backgroundColor: theme.colors.background },
headerTintColor: theme.colors.text,
tabBarStyle: { backgroundColor: theme.colors.surface, borderTopColor: theme.colors.border },
tabBarActiveTintColor: theme.colors.primary,
tabBarInactiveTintColor: theme.colors.textSecondary,
}}
>
<Tab.Screen
name="Cards"
children={() => <CardsScreen onExit={onExit} serverState={serverState} onRefresh={refreshData} />}
options={{
title: '运动提醒宝',
tabBarLabel: '卡片',
tabBarIcon: ({ color, size }) => <Ionicons name="albums-outline" size={size} color={color} />,
}}
/>
<Tab.Screen
name="History"
children={() => <HistoryScreen onRefresh={refreshData} />}
options={{
title: '执行历史',
tabBarLabel: '历史',
tabBarIcon: ({ color, size }) => <Ionicons name="time-outline" size={size} color={color} />,
}}
/>
</Tab.Navigator>
</NavigationContainer>
);
}
export default function App() {
const [screenMode, setScreenMode] = useState<ScreenMode>('hub');
const [loading, setLoading] = useState(true);
const initialize = useCallback(async () => {
try {
await orderBoxApi.getMeta();
const state = await orderBoxApi.getState();
if (state.activeKit === 'motion_reminder') {
setScreenMode('motion_reminder');
}
} catch (error) {
const message = error instanceof Error ? error.message : '初始化失败';
Alert.alert('初始化失败', message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void initialize();
}, [initialize]);
const enterMotionReminder = async () => {
try {
setLoading(true);
await orderBoxApi.setActiveKit('motion_reminder');
setScreenMode('motion_reminder');
} catch (error) {
const message = error instanceof Error ? error.message : '进入失败';
Alert.alert('进入失败', message);
} finally {
setLoading(false);
}
};
const content = useMemo(() => {
if (screenMode === 'motion_reminder') {
return <MotionReminderKit onExit={() => setScreenMode('hub')} />;
}
return <HubScreen loading={loading} onEnterMotionReminder={() => void enterMotionReminder()} />;
}, [loading, screenMode]);
return <View style={styles.appContainer}>{content}</View>;
}
const styles = StyleSheet.create({
appContainer: {
flex: 1,
backgroundColor: theme.colors.background,
},
hubContainer: {
flex: 1,
backgroundColor: theme.colors.background,
padding: theme.spacing.lg,
paddingTop: 72,
},
hubHeader: {
marginBottom: theme.spacing.xl,
},
hubTitle: {
...theme.typography.h1,
color: theme.colors.text,
marginBottom: theme.spacing.sm,
},
hubSubtitle: {
...theme.typography.body,
color: theme.colors.textSecondary,
lineHeight: 24,
},
kitCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.xl,
padding: theme.spacing.lg,
borderWidth: 1,
borderColor: theme.colors.border,
gap: theme.spacing.md,
},
kitCardIcon: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: theme.colors.primarySoft,
justifyContent: 'center',
alignItems: 'center',
},
kitCardBody: {
flex: 1,
},
kitCardTitle: {
...theme.typography.h3,
color: theme.colors.text,
marginBottom: theme.spacing.xs,
},
kitCardText: {
...theme.typography.bodySmall,
color: theme.colors.textSecondary,
lineHeight: 20,
},
});
+139
View File
@@ -0,0 +1,139 @@
import axios from 'axios';
import {
ApiEnvelope,
MotionCard,
MotionSession,
OrderBoxKitId,
OrderBoxMeta,
OrderBoxState,
} 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',
},
});
export 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 orderBoxApi = {
async getMeta(): Promise<OrderBoxMeta> {
try {
const response = await api.get<OrderBoxMeta>('/meta');
return response.data;
} catch (error) {
throw new Error(formatError(error));
}
},
async getState(): Promise<OrderBoxState> {
try {
const response = await api.get<OrderBoxState>('/state');
return response.data;
} catch (error) {
throw new Error(formatError(error));
}
},
async setActiveKit(kitId: OrderBoxKitId | null): Promise<void> {
try {
await api.put('/state/active-kit', { kitId });
} catch (error) {
throw new Error(formatError(error));
}
},
async resetRunningState(): Promise<void> {
try {
await api.post('/state/reset-running');
} catch (error) {
throw new Error(formatError(error));
}
},
};
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));
}
},
};
+38
View File
@@ -0,0 +1,38 @@
{
"expo": {
"name": "框子",
"slug": "order-box",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#080b12"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#080b12"
}
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-splash-screen"
],
"extra": {
"eas": {
"projectId": "e11fbd29-d9e4-41d5-a2d4-a563af8dbb54"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+6
View File
@@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
@@ -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;
}
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "app-order-box",
"version": "1.0.0",
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^6.5.0",
"@react-navigation/native": "^6.1.0",
"axios": "^1.6.0",
"expo": "~54.0.0",
"expo-haptics": "^57.0.0",
"expo-keep-awake": "^57.0.0",
"expo-speech": "~13.0.1",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-worklets": "^0.10.0",
"react-native-worklets-core": "^1.6.3"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~19.1.10",
"@types/react-native": "~0.73.0",
"babel-preset-expo": "~54.0.10",
"typescript": "~5.9.2"
},
"private": true
}
+42
View File
@@ -0,0 +1,42 @@
import { TextStyle } from 'react-native';
export const theme = {
colors: {
background: '#080b12',
surface: '#121826',
surfaceSoft: '#1a2234',
surfaceStrong: '#202b42',
primary: '#f0b35f',
primarySoft: 'rgba(240, 179, 95, 0.16)',
text: '#f6f7fb',
textSecondary: '#9ba7bc',
textDisabled: '#5a6578',
border: '#2a3449',
success: '#4ade80',
warning: '#fbbf24',
error: '#ef4444',
},
spacing: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
},
borderRadius: {
sm: 8,
md: 12,
lg: 18,
xl: 24,
},
typography: {
hero: { fontSize: 54, fontWeight: '700' } as TextStyle,
h1: { fontSize: 28, fontWeight: '700' } as TextStyle,
h2: { fontSize: 24, fontWeight: '700' } as TextStyle,
h3: { fontSize: 20, fontWeight: '600' } as TextStyle,
body: { fontSize: 16, fontWeight: '400' } as TextStyle,
bodyLarge: { fontSize: 18, fontWeight: '500' } as TextStyle,
bodySmall: { fontSize: 14, fontWeight: '400' } as TextStyle,
caption: { fontSize: 12, fontWeight: '400' } as TextStyle,
},
};
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx"
]
}
+65
View File
@@ -0,0 +1,65 @@
export type OrderBoxKitId = 'motion_reminder';
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: OrderBoxKitId;
cardId: string;
cardSnapshot: MotionCard;
status: SessionStatus;
startedAt: string;
endedAt: string | null;
currentLoop: number;
completedLoops: number;
currentStepIndex: number;
manualStopped: boolean;
updatedAt: string;
}
export interface OrderBoxMeta {
ok: boolean;
appName: string;
appNameZh: string;
kits: Array<{
id: OrderBoxKitId;
name: string;
description: string;
}>;
}
export interface OrderBoxState {
ok: boolean;
activeKit: OrderBoxKitId | null;
runningSession: {
kitId: OrderBoxKitId;
sessionId: string;
updatedAt: string;
} | null;
}
export interface ApiEnvelope<T> {
ok: boolean;
data?: T;
error?: string;
}