feat(goal): Focus HUD 목표 가독성·Flash 상기·완료 액션 추가
This commit is contained in:
25
src/widgets/space-focus-hud/ui/GoalFlashOverlay.tsx
Normal file
25
src/widgets/space-focus-hud/ui/GoalFlashOverlay.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface GoalFlashOverlayProps {
|
||||
goal: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export const GoalFlashOverlay = ({ goal, visible }: GoalFlashOverlayProps) => {
|
||||
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 잊지 마세요.';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none fixed inset-x-0 z-20 flex justify-center px-4 transition-opacity duration-[240ms] ease-out motion-reduce:duration-0',
|
||||
visible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.1rem)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<div className="rounded-full border border-white/12 bg-black/24 px-3 py-1.5 text-xs text-white/82 backdrop-blur-md">
|
||||
이번 한 조각: <span className="text-white/92">{normalizedGoal}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
|
||||
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
|
||||
import { GoalFlashOverlay } from './GoalFlashOverlay';
|
||||
|
||||
interface SpaceFocusHudWidgetProps {
|
||||
goal: string;
|
||||
@@ -11,16 +14,85 @@ export const SpaceFocusHudWidget = ({
|
||||
timerLabel,
|
||||
visible,
|
||||
}: SpaceFocusHudWidgetProps) => {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [flashVisible, setFlashVisible] = useState(false);
|
||||
const playbackStateRef = useRef<'running' | 'paused'>('running');
|
||||
const flashTimerRef = 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;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || reducedMotion) {
|
||||
setFlashVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
triggerFlash(2000);
|
||||
}, [visible, reducedMotion, triggerFlash]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || reducedMotion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
triggerFlash(800);
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [visible, reducedMotion, triggerFlash]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SpaceTimerHudWidget
|
||||
timerLabel={timerLabel}
|
||||
goal={goal}
|
||||
isImmersionMode
|
||||
className="pr-[4.2rem]"
|
||||
/>
|
||||
<>
|
||||
<GoalFlashOverlay goal={goal} visible={flashVisible} />
|
||||
<SpaceTimerHudWidget
|
||||
timerLabel={timerLabel}
|
||||
goal={goal}
|
||||
isImmersionMode
|
||||
className="pr-[4.2rem]"
|
||||
onPlaybackStateChange={(state) => {
|
||||
if (reducedMotion) {
|
||||
playbackStateRef.current = state;
|
||||
return;
|
||||
}
|
||||
|
||||
if (playbackStateRef.current === 'paused' && state === 'running') {
|
||||
triggerFlash(1000);
|
||||
}
|
||||
|
||||
playbackStateRef.current = state;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { useToast } from '@/shared/ui';
|
||||
import {
|
||||
RECOVERY_30S_MODE_LABEL,
|
||||
Restart30sAction,
|
||||
@@ -12,6 +13,7 @@ interface SpaceTimerHudWidgetProps {
|
||||
goal: string;
|
||||
className?: string;
|
||||
isImmersionMode?: boolean;
|
||||
onPlaybackStateChange?: (state: 'running' | 'paused') => void;
|
||||
}
|
||||
|
||||
const HUD_ACTIONS = [
|
||||
@@ -25,8 +27,11 @@ export const SpaceTimerHudWidget = ({
|
||||
goal,
|
||||
className,
|
||||
isImmersionMode = false,
|
||||
onPlaybackStateChange,
|
||||
}: SpaceTimerHudWidgetProps) => {
|
||||
const { pushToast } = useToast();
|
||||
const { isBreatheMode, hintMessage, triggerRestart } = useRestart30s();
|
||||
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -43,7 +48,7 @@ export const SpaceTimerHudWidget = ({
|
||||
/>
|
||||
<section
|
||||
className={cn(
|
||||
'relative z-10 flex h-16 items-center justify-between gap-3 rounded-2xl px-3.5 transition-colors',
|
||||
'relative z-10 flex min-h-[4.65rem] items-center justify-between gap-3 rounded-2xl px-3.5 py-2 transition-colors',
|
||||
isImmersionMode
|
||||
? 'border border-white/12 bg-black/22 backdrop-blur-md'
|
||||
: 'border border-white/12 bg-black/24 backdrop-blur-md',
|
||||
@@ -71,15 +76,30 @@ export const SpaceTimerHudWidget = ({
|
||||
{timerLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 min-w-0 rounded-lg border border-white/10 bg-black/16 px-2.5 py-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-white/55">Goal</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
pushToast({ title: '완료(더미) — 다음 조각으로 갈까요?' });
|
||||
}}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-white/16 bg-white/[0.05] text-[10px] text-white/78 transition-colors hover:bg-white/[0.12]"
|
||||
aria-label="목표 완료"
|
||||
title="목표 완료"
|
||||
>
|
||||
✅
|
||||
</button>
|
||||
</div>
|
||||
<p className={cn('mt-0.5 truncate text-sm', isImmersionMode ? 'text-white/90' : 'text-white/88')}>
|
||||
{normalizedGoal}
|
||||
</p>
|
||||
</div>
|
||||
{hintMessage ? (
|
||||
<p className={cn('truncate text-[11px]', isImmersionMode ? 'text-white/50' : 'text-white/50')}>
|
||||
<p className={cn('mt-1 truncate text-[10px]', isImmersionMode ? 'text-white/52' : 'text-white/52')}>
|
||||
{hintMessage}
|
||||
</p>
|
||||
) : (
|
||||
<p className={cn('truncate text-[11px]', isImmersionMode ? 'text-white/65' : 'text-white/65')}>
|
||||
목표: {goal}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -89,6 +109,15 @@ export const SpaceTimerHudWidget = ({
|
||||
key={action.id}
|
||||
type="button"
|
||||
title={action.label}
|
||||
onClick={() => {
|
||||
if (action.id === 'start') {
|
||||
onPlaybackStateChange?.('running');
|
||||
}
|
||||
|
||||
if (action.id === 'pause') {
|
||||
onPlaybackStateChange?.('paused');
|
||||
}
|
||||
}}
|
||||
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',
|
||||
isImmersionMode
|
||||
|
||||
Reference in New Issue
Block a user