맥락: - Quick Controls 헤더의 모드 토글이 대시보드형 느낌을 만들어 감성 톤을 해치고 있었습니다. 변경사항: - Control Center 헤더에서는 모드 조작 UI를 제거하고 Plan Pill + 닫기만 유지했습니다. - 패널 바디 첫 섹션에 기본/몰입 segmented pill을 배치하고, 선택 상태에 따라 저자극 스타일을 적용했습니다. - 모드 설명 1줄(기본: 모든 컨트롤 표시, 몰입: 필수만 남기고 숨김)을 추가했습니다. - 모드 상태를 workspace -> tools-dock -> focus-hud 경로로 연결해 HUD 톤 반영을 유지했습니다. 검증: - npx tsc --noEmit 세션-상태: Quick Controls 헤더가 깔끔해지고 모드 선택이 패널 바디에서 동작합니다. 세션-다음: Scene 추천 매핑 품질 점검과 override UX 검증을 진행합니다. 세션-리스크: 모드 설명 문구의 톤/길이는 실제 사용성 테스트에서 추가 미세조정이 필요할 수 있습니다.
155 lines
4.1 KiB
TypeScript
155 lines
4.1 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import type { HudStatusLineItem, HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
|
|
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
|
|
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
|
|
import { GoalCompleteSheet } from './GoalCompleteSheet';
|
|
import { GoalFlashOverlay } from './GoalFlashOverlay';
|
|
|
|
interface SpaceFocusHudWidgetProps {
|
|
goal: string;
|
|
timerLabel: string;
|
|
visible: boolean;
|
|
isImmersionMode: boolean;
|
|
onGoalUpdate: (nextGoal: string) => void;
|
|
statusLine: HudStatusLineItem | null;
|
|
onStatusAction: () => void;
|
|
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
|
}
|
|
|
|
export const SpaceFocusHudWidget = ({
|
|
goal,
|
|
timerLabel,
|
|
visible,
|
|
isImmersionMode,
|
|
onGoalUpdate,
|
|
statusLine,
|
|
onStatusAction,
|
|
onStatusMessage,
|
|
}: SpaceFocusHudWidgetProps) => {
|
|
const reducedMotion = useReducedMotion();
|
|
const [flashVisible, setFlashVisible] = useState(false);
|
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
const playbackStateRef = useRef<'running' | 'paused'>('running');
|
|
const flashTimerRef = useRef<number | null>(null);
|
|
const restReminderTimerRef = useRef<number | null>(null);
|
|
|
|
const triggerFlash = useCallback((durationMs: number) => {
|
|
if (reducedMotion || !visible) {
|
|
return;
|
|
}
|
|
|
|
setFlashVisible(true);
|
|
|
|
if (flashTimerRef.current) {
|
|
window.clearTimeout(flashTimerRef.current);
|
|
}
|
|
|
|
flashTimerRef.current = window.setTimeout(() => {
|
|
setFlashVisible(false);
|
|
flashTimerRef.current = null;
|
|
}, durationMs);
|
|
}, [reducedMotion, visible]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (flashTimerRef.current) {
|
|
window.clearTimeout(flashTimerRef.current);
|
|
flashTimerRef.current = null;
|
|
}
|
|
if (restReminderTimerRef.current) {
|
|
window.clearTimeout(restReminderTimerRef.current);
|
|
restReminderTimerRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!visible || reducedMotion) {
|
|
setFlashVisible(false);
|
|
return;
|
|
}
|
|
|
|
triggerFlash(2000);
|
|
}, [visible, reducedMotion, triggerFlash]);
|
|
|
|
useEffect(() => {
|
|
const ENABLE_PERIODIC_FLASH = false;
|
|
|
|
if (!visible || reducedMotion || !ENABLE_PERIODIC_FLASH) {
|
|
return;
|
|
}
|
|
|
|
const intervalId = window.setInterval(() => {
|
|
triggerFlash(800);
|
|
}, 10 * 60 * 1000);
|
|
|
|
return () => {
|
|
window.clearInterval(intervalId);
|
|
};
|
|
}, [visible, reducedMotion, triggerFlash]);
|
|
|
|
if (!visible) {
|
|
return null;
|
|
}
|
|
|
|
const handleOpenCompleteSheet = () => {
|
|
setSheetOpen(true);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<GoalFlashOverlay goal={goal} visible={flashVisible} />
|
|
<SpaceTimerHudWidget
|
|
timerLabel={timerLabel}
|
|
goal={goal}
|
|
statusLine={
|
|
statusLine
|
|
? {
|
|
message: statusLine.message,
|
|
actionLabel: statusLine.action?.label,
|
|
}
|
|
: null
|
|
}
|
|
isImmersionMode={isImmersionMode}
|
|
className="pr-[4.2rem]"
|
|
onGoalCompleteRequest={handleOpenCompleteSheet}
|
|
onStatusAction={onStatusAction}
|
|
onPlaybackStateChange={(state) => {
|
|
if (reducedMotion) {
|
|
playbackStateRef.current = state;
|
|
return;
|
|
}
|
|
|
|
if (playbackStateRef.current === 'paused' && state === 'running') {
|
|
triggerFlash(1000);
|
|
}
|
|
|
|
playbackStateRef.current = state;
|
|
}}
|
|
/>
|
|
<GoalCompleteSheet
|
|
open={sheetOpen}
|
|
currentGoal={goal}
|
|
onClose={() => setSheetOpen(false)}
|
|
onRest={() => {
|
|
setSheetOpen(false);
|
|
|
|
if (restReminderTimerRef.current) {
|
|
window.clearTimeout(restReminderTimerRef.current);
|
|
}
|
|
|
|
restReminderTimerRef.current = window.setTimeout(() => {
|
|
onStatusMessage({ message: '5분이 지났어요. 다음 한 조각으로 돌아와요.' });
|
|
restReminderTimerRef.current = null;
|
|
}, 5 * 60 * 1000);
|
|
}}
|
|
onConfirm={(nextGoal) => {
|
|
onGoalUpdate(nextGoal);
|
|
setSheetOpen(false);
|
|
triggerFlash(1200);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
};
|