fix(space): HUD 시작 흐름과 컨트롤 상태를 정리

This commit is contained in:
2026-03-09 13:13:53 +09:00
parent 675014166a
commit cceaa6bd82
4 changed files with 167 additions and 87 deletions

View File

@@ -8,11 +8,15 @@ interface SpaceFocusHudWidgetProps {
timerLabel: string; timerLabel: string;
timeDisplay?: string; timeDisplay?: string;
visible: boolean; visible: boolean;
hasActiveSession?: boolean;
playbackState?: 'running' | 'paused'; playbackState?: 'running' | 'paused';
sessionPhase?: 'focus' | 'break' | null; sessionPhase?: 'focus' | 'break' | null;
isSessionActionPending?: boolean; isSessionActionPending?: boolean;
canStartSession?: boolean;
canPauseSession?: boolean;
canRestartSession?: boolean;
onStartRequested?: () => void;
onPauseRequested?: () => void; onPauseRequested?: () => void;
onResumeRequested?: () => void;
onRestartRequested?: () => void; onRestartRequested?: () => void;
onGoalUpdate: (nextGoal: string) => void | Promise<void>; onGoalUpdate: (nextGoal: string) => void | Promise<void>;
onStatusMessage: (payload: HudStatusLinePayload) => void; onStatusMessage: (payload: HudStatusLinePayload) => void;
@@ -23,11 +27,15 @@ export const SpaceFocusHudWidget = ({
timerLabel, timerLabel,
timeDisplay, timeDisplay,
visible, visible,
playbackState = 'running', hasActiveSession = false,
playbackState = 'paused',
sessionPhase = 'focus', sessionPhase = 'focus',
isSessionActionPending = false, isSessionActionPending = false,
canStartSession = false,
canPauseSession = false,
canRestartSession = false,
onStartRequested,
onPauseRequested, onPauseRequested,
onResumeRequested,
onRestartRequested, onRestartRequested,
onGoalUpdate, onGoalUpdate,
onStatusMessage, onStatusMessage,
@@ -48,14 +56,14 @@ export const SpaceFocusHudWidget = ({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (visible && !visibleRef.current) { if (visible && !visibleRef.current && playbackState === 'running') {
onStatusMessage({ onStatusMessage({
message: `이번 한 조각 · ${normalizedGoal}`, message: `이번 한 조각 · ${normalizedGoal}`,
}); });
} }
visibleRef.current = visible; visibleRef.current = visible;
}, [normalizedGoal, onStatusMessage, visible]); }, [normalizedGoal, onStatusMessage, playbackState, visible]);
useEffect(() => { useEffect(() => {
if (playbackStateRef.current === 'paused' && playbackState === 'running' && visible) { if (playbackStateRef.current === 'paused' && playbackState === 'running' && visible) {
@@ -82,12 +90,16 @@ export const SpaceFocusHudWidget = ({
goal={goal} goal={goal}
timeDisplay={timeDisplay} timeDisplay={timeDisplay}
isImmersionMode isImmersionMode
hasActiveSession={hasActiveSession}
sessionPhase={sessionPhase} sessionPhase={sessionPhase}
playbackState={playbackState} playbackState={playbackState}
isControlsDisabled={isSessionActionPending} isControlsDisabled={isSessionActionPending}
canStart={canStartSession}
canPause={canPauseSession}
canReset={canRestartSession}
className="pr-[4.2rem]" className="pr-[4.2rem]"
onGoalCompleteRequest={handleOpenCompleteSheet} onGoalCompleteRequest={handleOpenCompleteSheet}
onStartClick={onResumeRequested} onStartClick={onStartRequested}
onPauseClick={onPauseRequested} onPauseClick={onPauseRequested}
onResetClick={onRestartRequested} onResetClick={onRestartRequested}
/> />
@@ -110,9 +122,6 @@ export const SpaceFocusHudWidget = ({
onConfirm={(nextGoal) => { onConfirm={(nextGoal) => {
void onGoalUpdate(nextGoal); void onGoalUpdate(nextGoal);
setSheetOpen(false); setSheetOpen(false);
onStatusMessage({
message: `이번 한 조각 · ${nextGoal}`,
});
}} }}
/> />
</> </>

View File

@@ -154,7 +154,7 @@ export const SpaceSetupDrawerWidget = ({
<header className="mb-3 space-y-1"> <header className="mb-3 space-y-1">
<p className="text-[10px] uppercase tracking-[0.18em] text-white/48">Ritual</p> <p className="text-[10px] uppercase tracking-[0.18em] text-white/48">Ritual</p>
<h1 className="text-[1.45rem] font-semibold leading-tight text-white"> .</h1> <h1 className="text-[1.45rem] font-semibold leading-tight text-white"> .</h1>
<p className="text-xs text-white/60"> Focus .</p> <p className="text-xs text-white/60"> HUD의 .</p>
</header> </header>
{resumeHint ? ( {resumeHint ? (
@@ -174,7 +174,7 @@ export const SpaceSetupDrawerWidget = ({
onClick={resumeHint.onResume} onClick={resumeHint.onResume}
className="rounded-full border border-sky-200/34 bg-sky-200/14 px-2.5 py-1 text-[11px] text-white/90 transition-colors hover:bg-sky-200/22" className="rounded-full border border-sky-200/34 bg-sky-200/14 px-2.5 py-1 text-[11px] text-white/90 transition-colors hover:bg-sky-200/22"
> >
</button> </button>
</div> </div>
</div> </div>
@@ -295,7 +295,7 @@ export const SpaceSetupDrawerWidget = ({
'h-10 rounded-xl !bg-sky-300/84 !text-slate-900 shadow-[0_8px_16px_rgba(125,211,252,0.24)] hover:!bg-sky-300 disabled:!bg-white/10 disabled:!text-white/42', 'h-10 rounded-xl !bg-sky-300/84 !text-slate-900 shadow-[0_8px_16px_rgba(125,211,252,0.24)] hover:!bg-sky-300 disabled:!bg-white/10 disabled:!text-white/42',
)} )}
> >
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -12,10 +12,14 @@ interface SpaceTimerHudWidgetProps {
goal: string; goal: string;
timeDisplay?: string; timeDisplay?: string;
className?: string; className?: string;
hasActiveSession?: boolean;
sessionPhase?: 'focus' | 'break' | null; sessionPhase?: 'focus' | 'break' | null;
playbackState?: 'running' | 'paused' | null; playbackState?: 'running' | 'paused' | null;
isControlsDisabled?: boolean; isControlsDisabled?: boolean;
isImmersionMode?: boolean; isImmersionMode?: boolean;
canStart?: boolean;
canPause?: boolean;
canReset?: boolean;
onStartClick?: () => void; onStartClick?: () => void;
onPauseClick?: () => void; onPauseClick?: () => void;
onResetClick?: () => void; onResetClick?: () => void;
@@ -33,10 +37,14 @@ export const SpaceTimerHudWidget = ({
goal, goal,
timeDisplay = '25:00', timeDisplay = '25:00',
className, className,
hasActiveSession = false,
sessionPhase = 'focus', sessionPhase = 'focus',
playbackState = 'running', playbackState = 'paused',
isControlsDisabled = false, isControlsDisabled = false,
isImmersionMode = false, isImmersionMode = false,
canStart = true,
canPause = false,
canReset = false,
onStartClick, onStartClick,
onPauseClick, onPauseClick,
onResetClick, onResetClick,
@@ -46,6 +54,8 @@ export const SpaceTimerHudWidget = ({
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.'; const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
const modeLabel = isBreatheMode const modeLabel = isBreatheMode
? RECOVERY_30S_MODE_LABEL ? RECOVERY_30S_MODE_LABEL
: !hasActiveSession
? 'Ready'
: sessionPhase === 'break' : sessionPhase === 'break'
? 'Break' ? 'Break'
: 'Focus'; : 'Focus';
@@ -110,42 +120,59 @@ export const SpaceTimerHudWidget = ({
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{HUD_ACTIONS.map((action) => ( {HUD_ACTIONS.map((action) => {
const isStartAction = action.id === 'start';
const isPauseAction = action.id === 'pause';
const isResetAction = action.id === 'reset';
const isDisabled =
isControlsDisabled ||
(isStartAction ? !canStart : isPauseAction ? !canPause : !canReset);
const isHighlighted =
(isStartAction && playbackState !== 'running') ||
(isPauseAction && playbackState === 'running');
return (
<button <button
key={action.id} key={action.id}
type="button" type="button"
title={action.label} title={action.label}
disabled={isControlsDisabled} aria-pressed={isHighlighted}
disabled={isDisabled}
onClick={() => { onClick={() => {
if (action.id === 'start') { if (isStartAction) {
onStartClick?.(); onStartClick?.();
} }
if (action.id === 'pause') { if (isPauseAction) {
onPauseClick?.(); onPauseClick?.();
} }
if (action.id === 'reset') { if (isResetAction) {
onResetClick?.(); onResetClick?.();
} }
}} }}
className={cn( className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-full border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80 disabled:cursor-not-allowed disabled:opacity-45', 'inline-flex h-9 w-9 items-center justify-center rounded-full border text-sm transition-[transform,background-color,border-color,box-shadow,color,opacity] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80 active:translate-y-px active:scale-[0.95] disabled:cursor-not-allowed disabled:opacity-38 disabled:shadow-none',
'shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_8px_18px_rgba(2,6,23,0.18)]',
isImmersionMode isImmersionMode
? 'border-white/14 bg-black/26 text-white/82 hover:bg-black/34' ? 'border-white/14 bg-black/28 text-white/82 hover:border-white/22 hover:bg-white/[0.09]'
: 'border-white/14 bg-black/26 text-white/84 hover:bg-black/34', : 'border-white/14 bg-black/28 text-white/84 hover:border-white/22 hover:bg-white/[0.09]',
action.id === 'start' && playbackState === 'running' isStartAction && isHighlighted
? 'border-sky-200/42 bg-sky-200/18 text-white' ? 'border-sky-200/56 bg-sky-200/20 text-sky-50 shadow-[inset_0_1px_0_rgba(255,255,255,0.12),0_10px_22px_rgba(56,189,248,0.24)]'
: '', : '',
action.id === 'pause' && playbackState === 'paused' isPauseAction && isHighlighted
? 'border-amber-200/42 bg-amber-200/16 text-white' ? 'border-amber-200/52 bg-amber-200/18 text-amber-50 shadow-[inset_0_1px_0_rgba(255,255,255,0.12),0_10px_22px_rgba(251,191,36,0.18)]'
: '',
isResetAction && !isDisabled
? 'hover:border-white/26 hover:bg-white/[0.12] hover:text-white'
: '', : '',
)} )}
> >
<span aria-hidden>{action.icon}</span> <span aria-hidden>{action.icon}</span>
<span className="sr-only">{action.label}</span> <span className="sr-only">{action.label}</span>
</button> </button>
))} );
})}
</div> </div>
<Restart30sAction <Restart30sAction
onTrigger={triggerRestart} onTrigger={triggerRestart}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { import {
getSceneBackgroundStyle, getSceneBackgroundStyle,
@@ -24,6 +24,7 @@ import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
import { FocusTopToast } from './FocusTopToast'; import { FocusTopToast } from './FocusTopToast';
type WorkspaceMode = 'setup' | 'focus'; type WorkspaceMode = 'setup' | 'focus';
type SessionEntryPoint = 'space-setup' | 'goal-complete' | 'resume-restore';
type SelectionOverride = { type SelectionOverride = {
sound: boolean; sound: boolean;
timer: boolean; timer: boolean;
@@ -188,11 +189,14 @@ export const SpaceWorkspaceWidget = () => {
const [resumeGoal, setResumeGoal] = useState(''); const [resumeGoal, setResumeGoal] = useState('');
const [showResumePrompt, setShowResumePrompt] = useState(false); const [showResumePrompt, setShowResumePrompt] = useState(false);
const [hasHydratedSelection, setHasHydratedSelection] = useState(false); const [hasHydratedSelection, setHasHydratedSelection] = useState(false);
const [previewPlaybackState, setPreviewPlaybackState] = useState<'running' | 'paused'>('running'); const [previewPlaybackState, setPreviewPlaybackState] = useState<'running' | 'paused'>('paused');
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] =
useState<SessionEntryPoint>('space-setup');
const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({ const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({
sound: false, sound: false,
timer: false, timer: false,
}); });
const queuedFocusStatusMessageRef = useRef<string | null>(null);
const { const {
selectedPresetId, selectedPresetId,
@@ -235,6 +239,9 @@ export const SpaceWorkspaceWidget = () => {
const { activeStatus, pushStatusLine, runActiveAction } = useHudStatusLine(isFocusMode); const { activeStatus, pushStatusLine, runActiveAction } = useHudStatusLine(isFocusMode);
const resolvedPlaybackState = playbackState ?? previewPlaybackState; const resolvedPlaybackState = playbackState ?? previewPlaybackState;
const resolvedTimeDisplay = timeDisplay ?? resolveFocusTimeDisplayFromTimerLabel(selectedTimerLabel); const resolvedTimeDisplay = timeDisplay ?? resolveFocusTimeDisplayFromTimerLabel(selectedTimerLabel);
const canStartSession = canStart && (!currentSession || resolvedPlaybackState !== 'running');
const canPauseSession = Boolean(currentSession && resolvedPlaybackState === 'running');
const canRestartSession = Boolean(currentSession);
const applyRecommendedSelections = useCallback(( const applyRecommendedSelections = useCallback((
sceneId: string, sceneId: string,
@@ -391,42 +398,75 @@ export const SpaceWorkspaceWidget = () => {
} }
}; };
const startFocusFlow = async ( const openFocusMode = (
nextGoal: string, nextGoal: string,
entryPoint: 'space-setup' | 'goal-complete' | 'resume-restore' = 'space-setup', entryPoint: SessionEntryPoint = 'space-setup',
) => { ) => {
const trimmedGoal = nextGoal.trim(); const trimmedGoal = nextGoal.trim();
if (!trimmedGoal) {
return;
}
setShowResumePrompt(false);
setPendingSessionEntryPoint(entryPoint);
setPreviewPlaybackState('paused');
setWorkspaceMode('focus');
queuedFocusStatusMessageRef.current = '준비 완료 · 시작 버튼을 눌러 집중을 시작해요.';
};
const startFocusFlow = async () => {
const trimmedGoal = goalInput.trim();
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel); const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
if (!trimmedGoal || !timerPresetId) { if (!trimmedGoal || !timerPresetId) {
return; return;
} }
setShowResumePrompt(false);
setPreviewPlaybackState('running');
setWorkspaceMode('focus');
const startedSession = await startSession({ const startedSession = await startSession({
sceneId: selectedSceneId, sceneId: selectedSceneId,
goal: trimmedGoal, goal: trimmedGoal,
timerPresetId, timerPresetId,
soundPresetId: selectedPresetId, soundPresetId: selectedPresetId,
entryPoint, entryPoint: pendingSessionEntryPoint,
}); });
if (!startedSession) { if (startedSession) {
pushStatusLine({ setPreviewPlaybackState('running');
message: '세션 API 연결 실패 · 로컬 미리보기 모드로 계속해요.', return;
});
} }
setPreviewPlaybackState('paused');
pushStatusLine({
message: '세션을 시작하지 못했어요. 잠시 후 다시 시도해 주세요.',
});
}; };
const handleStart = () => { const handleSetupFocusOpen = () => {
if (!canStart) { if (!canStart) {
return; return;
} }
void startFocusFlow(goalInput, 'space-setup'); openFocusMode(goalInput, 'space-setup');
};
const handleStartRequested = async () => {
if (!canStartSession) {
return;
}
if (!currentSession) {
await startFocusFlow();
return;
}
const resumedSession = await resumeSession();
if (!resumedSession) {
pushStatusLine({
message: '세션을 다시 시작하지 못했어요.',
});
}
}; };
const handleExitRequested = async () => { const handleExitRequested = async () => {
@@ -439,7 +479,8 @@ export const SpaceWorkspaceWidget = () => {
return; return;
} }
setPreviewPlaybackState('running'); setPreviewPlaybackState('paused');
setPendingSessionEntryPoint('space-setup');
setWorkspaceMode('setup'); setWorkspaceMode('setup');
}; };
@@ -458,26 +499,8 @@ export const SpaceWorkspaceWidget = () => {
} }
}; };
const handleResumeRequested = async () => {
if (!currentSession) {
setPreviewPlaybackState('running');
return;
}
const resumedSession = await resumeSession();
if (!resumedSession) {
pushStatusLine({
message: '세션을 다시 시작하지 못했어요.',
});
}
};
const handleRestartRequested = async () => { const handleRestartRequested = async () => {
if (!currentSession) { if (!currentSession) {
pushStatusLine({
message: '실제 세션이 시작된 뒤에만 다시 시작할 수 있어요.',
});
return; return;
} }
@@ -512,12 +535,17 @@ export const SpaceWorkspaceWidget = () => {
pushStatusLine({ pushStatusLine({
message: '현재 세션 완료를 서버에 반영하지 못했어요.', message: '현재 세션 완료를 서버에 반영하지 못했어요.',
}); });
return;
} }
} }
setGoalInput(trimmedNextGoal); setGoalInput(trimmedNextGoal);
setSelectedGoalId(null); setSelectedGoalId(null);
void startFocusFlow(trimmedNextGoal, 'goal-complete'); setPendingSessionEntryPoint('goal-complete');
setPreviewPlaybackState('paused');
pushStatusLine({
message: '다음 한 조각 준비 완료 · 시작 버튼을 눌러 이어가요.',
});
}; };
useEffect(() => { useEffect(() => {
@@ -561,6 +589,18 @@ export const SpaceWorkspaceWidget = () => {
); );
}, [goalInput, hasHydratedSelection, resumeGoal, selectedSceneId, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt]); }, [goalInput, hasHydratedSelection, resumeGoal, selectedSceneId, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt]);
useEffect(() => {
if (!isFocusMode || !queuedFocusStatusMessageRef.current) {
return;
}
const message = queuedFocusStatusMessageRef.current;
queuedFocusStatusMessageRef.current = null;
pushStatusLine({
message,
});
}, [isFocusMode, pushStatusLine]);
return ( return (
<div className="relative h-dvh overflow-hidden text-white"> <div className="relative h-dvh overflow-hidden text-white">
<div <div
@@ -590,7 +630,7 @@ export const SpaceWorkspaceWidget = () => {
onSoundSelect={(presetId) => handleSelectSound(presetId, true)} onSoundSelect={(presetId) => handleSelectSound(presetId, true)}
onGoalChange={handleGoalChange} onGoalChange={handleGoalChange}
onGoalChipSelect={handleGoalChipSelect} onGoalChipSelect={handleGoalChipSelect}
onStart={handleStart} onStart={handleSetupFocusOpen}
resumeHint={ resumeHint={
showResumePrompt && resumeGoal showResumePrompt && resumeGoal
? { ? {
@@ -599,7 +639,7 @@ export const SpaceWorkspaceWidget = () => {
setGoalInput(resumeGoal); setGoalInput(resumeGoal);
setSelectedGoalId(null); setSelectedGoalId(null);
setShowResumePrompt(false); setShowResumePrompt(false);
void startFocusFlow(resumeGoal, 'resume-restore'); openFocusMode(resumeGoal, 'resume-restore');
}, },
onStartFresh: () => { onStartFresh: () => {
setGoalInput(''); setGoalInput('');
@@ -616,15 +656,19 @@ export const SpaceWorkspaceWidget = () => {
timerLabel={selectedTimerLabel} timerLabel={selectedTimerLabel}
timeDisplay={resolvedTimeDisplay} timeDisplay={resolvedTimeDisplay}
visible={isFocusMode} visible={isFocusMode}
hasActiveSession={Boolean(currentSession)}
playbackState={resolvedPlaybackState} playbackState={resolvedPlaybackState}
sessionPhase={phase ?? 'focus'} sessionPhase={phase ?? 'focus'}
isSessionActionPending={isSessionMutating} isSessionActionPending={isSessionMutating}
canStartSession={canStartSession}
canPauseSession={canPauseSession}
canRestartSession={canRestartSession}
onStartRequested={() => {
void handleStartRequested();
}}
onPauseRequested={() => { onPauseRequested={() => {
void handlePauseRequested(); void handlePauseRequested();
}} }}
onResumeRequested={() => {
void handleResumeRequested();
}}
onRestartRequested={() => { onRestartRequested={() => {
void handleRestartRequested(); void handleRestartRequested();
}} }}