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