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

Ritual

이번 한 조각을 정하고 시작해요.

-

목표만 적으면 바로 Focus 모드로 넘어가요.

+

목표를 정한 뒤 HUD의 시작 버튼으로 실제 세션을 시작해요.

{resumeHint ? ( @@ -174,7 +174,7 @@ export const SpaceSetupDrawerWidget = ({ 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" > - 이어서 시작 + 이어서 준비 @@ -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', )} > - 시작하기 + 집중 화면 열기 diff --git a/src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx b/src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx index 16ba35b..2c17c76 100644 --- a/src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx +++ b/src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx @@ -12,10 +12,14 @@ interface SpaceTimerHudWidgetProps { goal: string; timeDisplay?: string; className?: string; + hasActiveSession?: boolean; sessionPhase?: 'focus' | 'break' | null; playbackState?: 'running' | 'paused' | null; isControlsDisabled?: boolean; isImmersionMode?: boolean; + canStart?: boolean; + canPause?: boolean; + canReset?: boolean; onStartClick?: () => void; onPauseClick?: () => void; onResetClick?: () => void; @@ -33,10 +37,14 @@ export const SpaceTimerHudWidget = ({ goal, timeDisplay = '25:00', className, + hasActiveSession = false, sessionPhase = 'focus', - playbackState = 'running', + playbackState = 'paused', isControlsDisabled = false, isImmersionMode = false, + canStart = true, + canPause = false, + canReset = false, onStartClick, onPauseClick, onResetClick, @@ -46,6 +54,8 @@ export const SpaceTimerHudWidget = ({ const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.'; const modeLabel = isBreatheMode ? RECOVERY_30S_MODE_LABEL + : !hasActiveSession + ? 'Ready' : sessionPhase === 'break' ? 'Break' : 'Focus'; @@ -110,42 +120,59 @@ export const SpaceTimerHudWidget = ({
- {HUD_ACTIONS.map((action) => ( - - ))} + if (isPauseAction) { + onPauseClick?.(); + } + + if (isResetAction) { + onResetClick?.(); + } + }} + className={cn( + '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 + ? 'border-white/14 bg-black/28 text-white/82 hover:border-white/22 hover:bg-white/[0.09]' + : 'border-white/14 bg-black/28 text-white/84 hover:border-white/22 hover:bg-white/[0.09]', + isStartAction && isHighlighted + ? '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)]' + : '', + isPauseAction && isHighlighted + ? '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' + : '', + )} + > + {action.icon} + {action.label} + + ); + })}
{ const [resumeGoal, setResumeGoal] = useState(''); const [showResumePrompt, setShowResumePrompt] = 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('space-setup'); const [selectionOverride, setSelectionOverride] = useState({ sound: false, timer: false, }); + const queuedFocusStatusMessageRef = useRef(null); const { selectedPresetId, @@ -235,6 +239,9 @@ export const SpaceWorkspaceWidget = () => { const { activeStatus, pushStatusLine, runActiveAction } = useHudStatusLine(isFocusMode); const resolvedPlaybackState = playbackState ?? previewPlaybackState; const resolvedTimeDisplay = timeDisplay ?? resolveFocusTimeDisplayFromTimerLabel(selectedTimerLabel); + const canStartSession = canStart && (!currentSession || resolvedPlaybackState !== 'running'); + const canPauseSession = Boolean(currentSession && resolvedPlaybackState === 'running'); + const canRestartSession = Boolean(currentSession); const applyRecommendedSelections = useCallback(( sceneId: string, @@ -391,42 +398,75 @@ export const SpaceWorkspaceWidget = () => { } }; - const startFocusFlow = async ( + const openFocusMode = ( nextGoal: string, - entryPoint: 'space-setup' | 'goal-complete' | 'resume-restore' = 'space-setup', + entryPoint: SessionEntryPoint = 'space-setup', ) => { 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); if (!trimmedGoal || !timerPresetId) { return; } - setShowResumePrompt(false); - setPreviewPlaybackState('running'); - setWorkspaceMode('focus'); - const startedSession = await startSession({ sceneId: selectedSceneId, goal: trimmedGoal, timerPresetId, soundPresetId: selectedPresetId, - entryPoint, + entryPoint: pendingSessionEntryPoint, }); - if (!startedSession) { - pushStatusLine({ - message: '세션 API 연결 실패 · 로컬 미리보기 모드로 계속해요.', - }); + if (startedSession) { + setPreviewPlaybackState('running'); + return; } + + setPreviewPlaybackState('paused'); + pushStatusLine({ + message: '세션을 시작하지 못했어요. 잠시 후 다시 시도해 주세요.', + }); }; - const handleStart = () => { + const handleSetupFocusOpen = () => { if (!canStart) { 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 () => { @@ -439,7 +479,8 @@ export const SpaceWorkspaceWidget = () => { return; } - setPreviewPlaybackState('running'); + setPreviewPlaybackState('paused'); + setPendingSessionEntryPoint('space-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 () => { if (!currentSession) { - pushStatusLine({ - message: '실제 세션이 시작된 뒤에만 다시 시작할 수 있어요.', - }); return; } @@ -512,12 +535,17 @@ export const SpaceWorkspaceWidget = () => { pushStatusLine({ message: '현재 세션 완료를 서버에 반영하지 못했어요.', }); + return; } } setGoalInput(trimmedNextGoal); setSelectedGoalId(null); - void startFocusFlow(trimmedNextGoal, 'goal-complete'); + setPendingSessionEntryPoint('goal-complete'); + setPreviewPlaybackState('paused'); + pushStatusLine({ + message: '다음 한 조각 준비 완료 · 시작 버튼을 눌러 이어가요.', + }); }; useEffect(() => { @@ -561,6 +589,18 @@ export const SpaceWorkspaceWidget = () => { ); }, [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 (
{ onSoundSelect={(presetId) => handleSelectSound(presetId, true)} onGoalChange={handleGoalChange} onGoalChipSelect={handleGoalChipSelect} - onStart={handleStart} + onStart={handleSetupFocusOpen} resumeHint={ showResumePrompt && resumeGoal ? { @@ -599,7 +639,7 @@ export const SpaceWorkspaceWidget = () => { setGoalInput(resumeGoal); setSelectedGoalId(null); setShowResumePrompt(false); - void startFocusFlow(resumeGoal, 'resume-restore'); + openFocusMode(resumeGoal, 'resume-restore'); }, onStartFresh: () => { setGoalInput(''); @@ -616,15 +656,19 @@ export const SpaceWorkspaceWidget = () => { timerLabel={selectedTimerLabel} timeDisplay={resolvedTimeDisplay} visible={isFocusMode} + hasActiveSession={Boolean(currentSession)} playbackState={resolvedPlaybackState} sessionPhase={phase ?? 'focus'} isSessionActionPending={isSessionMutating} + canStartSession={canStartSession} + canPauseSession={canPauseSession} + canRestartSession={canRestartSession} + onStartRequested={() => { + void handleStartRequested(); + }} onPauseRequested={() => { void handlePauseRequested(); }} - onResumeRequested={() => { - void handleResumeRequested(); - }} onRestartRequested={() => { void handleRestartRequested(); }}