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.tsx | NEW | PullToRefresh 래퍼 컴포넌트 |
apps/dha-sleep-app-web/components/screens/home/home-screen.tsx | EDIT | PullToRefresh 래퍼 적용 + refresh 핸들러 추가 |
apps/dha-sleep-app-web/app/globals.css | EDIT | overscroll-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 완료 후 확인 사항:
- 빌드 성공:
nx build dha-sleep-app-web에러 없이 완료 - Pull 제스처: 홈 화면 상단에서 아래로 스와이프 시 인디케이터 표시
- Refresh 동작: threshold(80px) 이상 당기면 HomeCard + Prefetch 데이터 갱신
- 스냅백 동작: threshold 미만에서 손을 떼면 원위치로 복귀
- 중복 방지: 새로고침 진행 중 추가 당김 무시
- 접근성: 인디케이터에
aria-label,role="status"적용 - Reduced motion:
prefers-reduced-motion설정 시 애니메이션 최소화 (globals.css 기존 규칙으로 자동 적용)