본문으로 건너뛰기

Phase 1: PullToRefresh 컴포넌트 + 홈 화면 통합

참조 패턴: apps/dha-sleep-app-web/components/screens/home/home-screen.tsx (기존 HomeCard 페치 패턴) 변경 파일: 1 NEW + 2 EDIT

변경 파일

파일유형설명
apps/dha-sleep-app-web/components/ui/pull-to-refresh.tsxNEWPullToRefresh 래퍼 컴포넌트
apps/dha-sleep-app-web/components/screens/home/home-screen.tsxEDITPullToRefresh 래퍼 적용 + refresh 핸들러 추가
apps/dha-sleep-app-web/app/globals.cssEDIToverscroll-behavior CSS 추가

1-1. PullToRefresh 컴포넌트 [NEW]

파일: apps/dha-sleep-app-web/components/ui/pull-to-refresh.tsx

역할: 터치 이벤트 기반의 pull-to-refresh 래퍼 컴포넌트. 자식 컴포넌트를 감싸고, 아래로 당기면 onRefresh 콜백을 실행한다.

전체 파일 내용

'use client';

import React, { useCallback, useRef, useState } from 'react';
import { motion, useMotionValue, useTransform } from 'framer-motion';
import { Loader2 } from 'lucide-react';

interface PullToRefreshProps {
onRefresh: () => Promise<void>;
children: React.ReactNode;
/** Pull distance threshold to trigger refresh (px). Default: 80 */
threshold?: number;
/** Maximum pull distance (px). Default: 120 */
maxPull?: number;
/** Whether pull-to-refresh is enabled. Default: true */
enabled?: boolean;
}

export function PullToRefresh({
onRefresh,
children,
threshold = 80,
maxPull = 120,
enabled = true,
}: PullToRefreshProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const startYRef = useRef<number | null>(null);
const pullDistanceRef = useRef(0);
const isPullingRef = useRef(false);

const pullY = useMotionValue(0);
const indicatorOpacity = useTransform(pullY, [0, threshold * 0.5, threshold], [0, 0.5, 1]);
const indicatorScale = useTransform(pullY, [0, threshold], [0.5, 1]);
const indicatorRotate = useTransform(pullY, [0, threshold], [0, 180]);

const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
if (!enabled || isRefreshing) return;

// Only activate when at the top of the scroll container
const container = containerRef.current;
if (!container || container.scrollTop > 0) return;

startYRef.current = e.touches[0].clientY;
isPullingRef.current = false;
},
[enabled, isRefreshing],
);

const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
if (!enabled || isRefreshing || startYRef.current === null) return;

const currentY = e.touches[0].clientY;
const deltaY = currentY - startYRef.current;

// Only handle downward pull
if (deltaY <= 0) {
pullY.set(0);
pullDistanceRef.current = 0;
isPullingRef.current = false;
return;
}

isPullingRef.current = true;

// Apply resistance: pull distance diminishes as user pulls further
const resistance = 0.5;
const adjustedDelta = Math.min(deltaY * resistance, maxPull);

pullDistanceRef.current = adjustedDelta;
pullY.set(adjustedDelta);

// Prevent default scroll when pulling
if (adjustedDelta > 0) {
e.preventDefault();
}
},
[enabled, isRefreshing, maxPull, pullY],
);

const handleTouchEnd = useCallback(async () => {
if (!enabled || isRefreshing || startYRef.current === null) return;

startYRef.current = null;

if (pullDistanceRef.current >= threshold && isPullingRef.current) {
// Trigger refresh
setIsRefreshing(true);
pullY.set(threshold * 0.6); // Hold at indicator position

try {
await onRefresh();
} finally {
setIsRefreshing(false);
pullY.set(0);
pullDistanceRef.current = 0;
isPullingRef.current = false;
}
} else {
// Snap back
pullY.set(0);
pullDistanceRef.current = 0;
isPullingRef.current = false;
}
}, [enabled, isRefreshing, threshold, onRefresh, pullY]);

return (
<div
ref={containerRef}
className="relative h-full w-full overscroll-none"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Refresh indicator */}
<motion.div
className="pointer-events-none absolute left-0 right-0 z-50 flex items-center justify-center"
style={{
y: useTransform(pullY, (v) => v - 40),
opacity: isRefreshing ? 1 : indicatorOpacity,
}}
aria-label="Pull to refresh indicator"
role="status"
>
<motion.div
className="flex h-10 w-10 items-center justify-center rounded-full bg-[var(--color-bg-secondary)] shadow-md"
style={{ scale: isRefreshing ? 1 : indicatorScale }}
>
{isRefreshing ? (
<Loader2 className="h-5 w-5 animate-spin text-[var(--color-text-secondary)]" />
) : (
<motion.svg
className="h-5 w-5 text-[var(--color-text-secondary)]"
style={{ rotate: indicatorRotate }}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="6" x2="12" y2="18" />
</motion.svg>
)}
</motion.div>
</motion.div>

{/* Content with pull translation */}
<motion.div className="h-full" style={{ y: pullY }}>
{children}
</motion.div>
</div>
);
}

참조 패턴: Framer Motion useMotionValue + useTransform 사용 패턴 — hero-section.tsx의 motion 애니메이션 패턴 참조


1-2. HomeScreen에 PullToRefresh 적용 [EDIT]

파일: apps/dha-sleep-app-web/components/screens/home/home-screen.tsx

역할: PullToRefresh 래퍼로 메인 콘텐츠를 감싸고, refresh 시 HomeCard + Prefetch 데이터를 갱신

변경 내용

변경 1: Import 추가 (line 3)

// 변경 전 (line 3):
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';

// 변경 후:
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { PullToRefresh } from '@/components/ui/pull-to-refresh';

기존 import 바로 아래에 추가

변경 2: refresh 핸들러 추가 (line 305 이후, handleOpenSleepDiary 뒤)

// 변경 전 (line 302-306):
const handleOpenSleepDiary = useCallback(() => {
console.log('[HomeScreen] Opening sleep diary via chat');
router.push('/chat?action=sleepDiary');
}, [router]);

// 변경 후:
const handleOpenSleepDiary = useCallback(() => {
console.log('[HomeScreen] Opening sleep diary via chat');
router.push('/chat?action=sleepDiary');
}, [router]);

// Pull-to-refresh handler: refreshes HomeCard + Prefetch data
const handlePullToRefresh = useCallback(async () => {
console.log('[HomeScreen] Pull-to-refresh triggered');

// Reset background retry state for fresh fetch
useHomeCardStore.getState().reset();

await Promise.all([
fetchHomeCard(chatMode, labAgentId),
prefetchAll(undefined, chatMode),
]);

console.log('[HomeScreen] Pull-to-refresh completed');
}, [fetchHomeCard, chatMode, labAgentId, prefetchAll]);

변경 3: PullToRefresh 래퍼 적용 (line 311-419)

// 변경 전 (line 311-419):
return (
<motion.div
className="relative flex min-h-full flex-col bg-[var(--color-bg-primary)] overflow-visible"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{/* Character Image - ... */}
<div className="pointer-events-none absolute bottom-36 right-0 z-0">
...
</div>

<main className="relative z-[1] flex flex-1 flex-col">
...
</main>

{/* Bottom Section: Suggested Questions + Input */}
<div
className="fixed left-0 right-0 z-10"
...
>
...
</div>

{/* Session Drawer */}
<SessionDrawer ... />
</motion.div>
);

// 변경 후:
return (
<PullToRefresh onRefresh={handlePullToRefresh}>
<motion.div
className="relative flex min-h-full flex-col bg-[var(--color-bg-primary)] overflow-visible"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{/* Character Image - Toss/Kakao style: bottom-right, behind UI, full width visible */}
<div className="pointer-events-none absolute bottom-36 right-0 z-0">
<Image
src="/images/img_ssam_home.png"
alt={t('home.samAlt')}
width={300}
height={350}
className="object-contain opacity-90 dark:opacity-70"
priority
/>
</div>

<main className="relative z-[1] flex flex-1 flex-col">
{/* Top Bar with Hamburger Menu */}
<div>
<HomeTopBar onMenuOpen={handleOpenChatHistory} />
</div>

<div className="flex flex-1 flex-col px-5 pt-6">
{/* Hero Section (agent-board style) */}
<HeroSection content={content} />
</div>
</main>

{/* Bottom Section: Suggested Questions + Input */}
<div
className="fixed left-0 right-0 z-10"
style={{
bottom: isKeyboardVisible ? `${keyboardHeight}px` : '0px',
paddingBottom: isKeyboardVisible ? '0px' : 'env(safe-area-inset-bottom)',
}}
>
{/* Quick Actions & Suggested Questions - horizontal scroll chips */}
<div className="pb-3">
<div className="flex gap-2 overflow-x-auto scrollbar-hide snap-x snap-mandatory pl-5">
{/* Sleep Diary Quick Action */}
<button
onClick={handleOpenSleepDiary}
className="shrink-0 snap-start flex items-center gap-1.5 rounded-full border border-indigo-200 dark:border-indigo-700 bg-indigo-50 dark:bg-indigo-900/30 px-4 py-2 text-sm font-medium text-indigo-600 dark:text-indigo-300 transition-colors hover:bg-indigo-100 dark:hover:bg-indigo-900/50 active:scale-95"
type="button"
aria-label={t('accessibility.sleepRecordButton')}
>
<Moon className="h-4 w-4" />
<span>{t('home.sleepRecord')}</span>
</button>

{/* Dynamic Actions (from HomeCard or default template) */}
{dynamicActions.map((action, index) => (
<button
key={`action-${index}`}
onClick={() => handleDynamicAction(action)}
className="shrink-0 snap-start rounded-full border border-[var(--color-line-primary)] dark:border-[var(--color-line-secondary)] bg-[var(--color-bg-secondary)] dark:bg-[var(--color-bg-tertiary)] px-4 py-2 text-sm text-[var(--color-text-secondary)] dark:text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-bg-tertiary)] hover:text-[var(--color-text-primary)] active:scale-95"
type="button"
>
{action.icon && <span className="mr-1">{action.icon}</span>}
{action.label}
</button>
))}

{/* Static Suggested Questions (always available as additional options) */}
{suggestedQuestions.map((question, index) => (
<button
key={`suggest-${index}`}
onClick={() => handleSuggestedQuestion(question)}
className={cn(
"shrink-0 snap-start rounded-full border border-[var(--color-line-primary)] dark:border-[var(--color-line-secondary)] bg-[var(--color-bg-secondary)] dark:bg-[var(--color-bg-tertiary)] px-4 py-2 text-sm text-[var(--color-text-secondary)] dark:text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-bg-tertiary)] hover:text-[var(--color-text-primary)] active:scale-95",
index === suggestedQuestions.length - 1 && "mr-5"
)}
type="button"
>
{question}
</button>
))}
</div>
</div>

{/* Chat Input - with background to cover character */}
<div className="px-2 pb-2 bg-[var(--color-bg-primary)]">
<NativeChatInput
input={homeInput}
setInput={setHomeInput}
onSend={handleHomeSend}
onStop={noop}
isStreaming={false}
mode={chatMode}
onModeChange={setChatMode}
labAgentId={labAgentId}
onLabAgentChange={setLabAgentId}
labAgents={labAgents}
storageKey={null}
placeholder={t('home.inputPlaceholder')}
/>
</div>
</div>

{/* Session Drawer - for chat history and settings access from home */}
<SessionDrawer
onSelectSession={handleSelectSession}
onNewChat={handleNewChat}
onSettings={showSettings}
/>
</motion.div>
</PullToRefresh>
);

1-3. globals.css overscroll 방지 [EDIT]

파일: apps/dha-sleep-app-web/app/globals.css

역할: 브라우저 기본 pull-to-refresh(Chrome)와 충돌 방지

변경 내용

/* 변경 전 (line 182-183 이후, touch-action 블록 뒤):
[role="button"] {
touch-action: manipulation;
}

/* 변경 후 — 아래에 추가: */
[role="button"] {
touch-action: manipulation;
}

/* Prevent browser's built-in pull-to-refresh to allow custom implementation */
.overscroll-none {
overscroll-behavior-y: none;
}

Tailwind CSS v4에서 overscroll-none 유틸리티 클래스를 사용하지만, Capacitor WebView에서 브라우저 기본 overscroll을 확실히 방지하기 위해 명시적 CSS도 추가한다.


검증 포인트

이 Phase 완료 후 확인 사항:

  1. 빌드 성공: nx build dha-sleep-app-web 에러 없이 완료
  2. Pull 제스처: 홈 화면 상단에서 아래로 스와이프 시 인디케이터 표시
  3. Refresh 동작: threshold(80px) 이상 당기면 HomeCard + Prefetch 데이터 갱신
  4. 스냅백 동작: threshold 미만에서 손을 떼면 원위치로 복귀
  5. 중복 방지: 새로고침 진행 중 추가 당김 무시
  6. 접근성: 인디케이터에 aria-label, role="status" 적용
  7. Reduced motion: prefers-reduced-motion 설정 시 애니메이션 최소화 (globals.css 기존 규칙으로 자동 적용)