diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 935a500..cec492c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -22,7 +22,7 @@ android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" android:launchMode="singleTask" - android:windowSoftInputMode="adjustResize" + android:windowSoftInputMode="adjustPan" android:exported="true"> diff --git a/src/components/ui/CustomBottomSheet.tsx b/src/components/ui/CustomBottomSheet.tsx index f398713..22cac36 100644 --- a/src/components/ui/CustomBottomSheet.tsx +++ b/src/components/ui/CustomBottomSheet.tsx @@ -18,6 +18,7 @@ interface CustomBottomSheetProps { translateY: SharedValue; height?: number; collapsedVisibleHeight?: number; + maxTopSnap?: number; cornerRadius?: number; backgroundColor?: string; showIndicator?: boolean; @@ -29,6 +30,7 @@ export const CustomBottomSheet = ({ translateY, height, collapsedVisibleHeight = 28, + maxTopSnap, cornerRadius, backgroundColor, showIndicator = true, @@ -41,6 +43,7 @@ export const CustomBottomSheet = ({ const SNAP_MIN = SHEET_HEIGHT - collapsedVisibleHeight; const SNAP_MID = SHEET_HEIGHT - 310; const SNAP_HIGH = 0; + const SNAP_TOP_LIMIT = Math.max(SNAP_HIGH, Math.min(SNAP_MID, maxTopSnap ?? SNAP_HIGH)); const gesture = Gesture.Pan() .onStart(() => { @@ -49,7 +52,7 @@ export const CustomBottomSheet = ({ .onUpdate((e) => { const nextY = startY.value + e.translationY; // 이동 범위 제한 (620 ~ 66 사이에서만 움직임) - translateY.value = Math.max(SNAP_HIGH, Math.min(SNAP_MIN, nextY)); + translateY.value = Math.max(SNAP_TOP_LIMIT, Math.min(SNAP_MIN, nextY)); }) .onEnd((e) => { const currentY = translateY.value; @@ -59,7 +62,7 @@ export const CustomBottomSheet = ({ // 2. 속도가 빠를 때 방향에 따라 스냅 if (velocityY < -500) { // 위로 휙 올릴 때: 현재 위치보다 한 단계 위로 - targetY = currentY > SNAP_MID ? SNAP_MID : SNAP_HIGH; + targetY = currentY > SNAP_MID ? SNAP_MID : SNAP_TOP_LIMIT; } else if (velocityY > 500) { // 아래로 휙 내릴 때: 현재 위치보다 한 단계 아래로 targetY = currentY < SNAP_MID ? SNAP_MID : SNAP_MIN; @@ -67,13 +70,13 @@ export const CustomBottomSheet = ({ // 3. 속도가 느릴 때 가장 가까운 지점으로 자석처럼 붙기 const distanceToMin = Math.abs(currentY - SNAP_MIN); const distanceToMid = Math.abs(currentY - SNAP_MID); - const distanceToHigh = Math.abs(currentY - SNAP_HIGH); + const distanceToTop = Math.abs(currentY - SNAP_TOP_LIMIT); - const minDistance = Math.min(distanceToMin, distanceToMid, distanceToHigh); + const minDistance = Math.min(distanceToMin, distanceToMid, distanceToTop); if (minDistance === distanceToMin) targetY = SNAP_MIN; else if (minDistance === distanceToMid) targetY = SNAP_MID; - else targetY = SNAP_HIGH; + else targetY = SNAP_TOP_LIMIT; } // 부드럽게 이동 diff --git a/src/screens/WishlistScreen.tsx b/src/screens/WishlistScreen.tsx index 69a1458..4ab8f40 100644 --- a/src/screens/WishlistScreen.tsx +++ b/src/screens/WishlistScreen.tsx @@ -1,20 +1,27 @@ import React, { useCallback, useRef, useEffect } from 'react'; -import { View, TextInput, TouchableOpacity, Text, Dimensions } from 'react-native'; +import { View, TextInput, TouchableOpacity, Text, Keyboard, Platform } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useFocusEffect } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { SearchArrowIcon, SearchingIcon, MyLocation, EmptyLocation, EmptyWish, WishStar } from '@/assets/icons'; -import BottomSheet from '@gorhom/bottom-sheet'; -import CustomBottomSheet from '@/components/ui/CustomBottomSheet'; -import { SearchContainer } from '@/components/ui'; +import { MyLocation, WishStar } from '@/assets/icons'; import { WishModal } from './wishList/components/WishModal'; import type { RootStackParamList } from '@/navigation/types'; import MapView, { PROVIDER_GOOGLE } from 'react-native-maps'; -import { useState } from "react"; +import { useState } from 'react'; import { RouteIcon, AlertIcon } from '@/assets/icons'; -import { COLORS } from '@/constants'; import { BackHandler } from 'react-native'; -import { CategoryChip, PlaceCard, PlaceCardProps } from '@/screens/wishList/components'; +import { + CategoryChip, + PlaceCard, + PlaceCardProps, + WishTabSave, + WishTabTrending, + WishTabWishlist, + WishlistBottomSheet, + WishlistSearchBar, + WishlistSearchOverlay, +} from '@/screens/wishList/components'; +import type { WishlistBottomSheetTabId } from '@/screens/wishList/components'; import Animated, { useSharedValue, useAnimatedStyle, @@ -22,346 +29,385 @@ import Animated, { withTiming, Easing, } from 'react-native-reanimated'; -import { ScrollView } from 'react-native-gesture-handler'; import { Shadow } from 'react-native-shadow-2'; + // ============ Types ============ type NavigationProp = NativeStackNavigationProp; - -// ============ Component ============ +type TabId = WishlistBottomSheetTabId; +type LikeTabId = Exclude; +//더미 데이터 - 실제 API 연동 시 제거 예정 +const TRENDING_PLACES: PlaceCardProps['place'][] = [ + { + id: 'place_1', + title: '센소지 아사쿠사', + location: '도쿄, 일본', + description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', + categories: ['관광지', '문화', '역사'], + image: require('@/assets/images/thumnail.png'), + }, +]; + +const SAVED_PLACES: PlaceCardProps['place'][] = [ + { + id: 'placeS_1', + title: '센소지 아사쿠사', + location: '도쿄, 일본', + description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', + categories: ['관광지', '문화', '역사'], + image: require('@/assets/images/thumnail.png'), + }, + { + id: 'placeS_2', + title: '센소지 아사쿠사', + location: '도쿄, 일본', + description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', + categories: ['관광지', '문화', '역사'], + image: require('@/assets/images/thumnail.png'), + }, +]; + +const WISHLIST_PLACES: PlaceCardProps['place'][] = [ + { + id: 'placeW_1', + title: '센소지 아사쿠사', + location: '도쿄, 일본', + description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', + categories: ['관광지', '문화', '역사'], + image: require('@/assets/images/thumnail.png'), + }, + { + id: 'placeW_2', + title: '센소지 아사쿠사', + location: '도쿄, 일본', + description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', + categories: ['관광지', '문화', '역사'], + image: require('@/assets/images/thumnail.png'), + }, + { + id: 'placeW_3', + title: '센소지 아사쿠사', + location: '도쿄, 일본', + description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', + categories: ['관광지', '문화', '역사'], + image: require('@/assets/images/thumnail.png'), + }, + { + id: 'placeW_4', + title: '센소지 아사쿠사', + location: '도쿄, 일본', + description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', + categories: ['관광지', '문화', '역사'], + image: require('@/assets/images/thumnail.png'), + }, + { + id: 'placeW_5', + title: '센소지 아사쿠사', + location: '도쿄, 일본', + description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', + categories: ['관광지', '문화', '역사'], + image: require('@/assets/images/thumnail.png'), + }, + { + id: 'placeW_6', + title: '센소지 아사쿠사', + location: '도쿄, 일본', + description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', + categories: ['관광지', '문화', '역사'], + image: require('@/assets/images/thumnail.png'), + }, +]; + +const SEARCH_PLACES: PlaceCardProps['place'][] = [ + { + id: 'place_6', + title: '센소지 아사쿠사', + location: '도쿄, 일본', + description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', + categories: ['관광지', '문화', '역사'], + image: require('@/assets/images/thumnail.png'), + }, + { + id: 'place_1', + title: '센소지 아사쿠사', + location: '도쿄, 일본', + description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', + categories: ['관광지', '문화', '역사'], + image: require('@/assets/images/thumnail.png'), + }, +]; + +const TABS: { id: TabId; label: string }[] = [ + { id: 'trending', label: '실시간 추천' }, + { id: 'saved', label: '저장된 장소' }, + { id: 'wishlist', label: '위시 리스트' }, +]; + +// 바텀시트 스냅 포인트 const WishlistScreen: React.FC = () => { - const BOTTOM_SHEET_MIN_HEIGHT = 28; // 바텀시트 기본 높이 + const BOTTOM_SHEET_MIN_HEIGHT = 28; const SHEET_HEIGHT = 654; + const SECOND_SNAP_VISIBLE_HEIGHT = 310; + const INITIAL_CATEGORY: TabId = 'trending'; const SNAP_LOW = SHEET_HEIGHT - 28; - const translateY = useSharedValue(SHEET_HEIGHT - BOTTOM_SHEET_MIN_HEIGHT); - const SEARCH_BUTTON_BOTTOM = BOTTOM_SHEET_MIN_HEIGHT + 10; // 바텀시트 위에 10px 여유 - // 파생 값 - const tabs = React.useMemo( - () => [ - { id: 'trending', label: '실시간 추천' }, - { id: 'saved', label: '저장된 장소' }, - { id: 'wishlist', label: '위시 리스트' }, - ], - [], - ); - const TRENDING_PLACES: PlaceCardProps['place'][] = [ - { - id: 'place_1', - title: '센소지 아사쿠사', - location: '도쿄, 일본', - description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', - categories: ['관광지', '문화', '역사'], - image: require('@/assets/images/thumnail.png'), - }, - ] - const SAVED_PLACES: PlaceCardProps['place'][] = [ - { - id: 'place_1', - title: '센소지 아사쿠사', - location: '도쿄, 일본', - description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', - categories: ['관광지', '문화', '역사'], - image: require('@/assets/images/thumnail.png'), - }, - { - id: 'place_2', - title: '센소지 아사쿠사', - location: '도쿄, 일본', - description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', - categories: ['관광지', '문화', '역사'], - image: require('@/assets/images/thumnail.png'), - }, - ] - const WISHLIST_PLACES: PlaceCardProps['place'][] = [ - { - id: 'place_1', - title: '센소지 아사쿠사', - location: '도쿄, 일본', - description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', - categories: ['관광지', '문화', '역사'], - image: require('@/assets/images/thumnail.png'), - }, - { - id: 'place_2', - title: '센소지 아사쿠사', - location: '도쿄, 일본', - description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', - categories: ['관광지', '문화', '역사'], - image: require('@/assets/images/thumnail.png'), - }, - - { - id: 'place_3', - title: '센소지 아사쿠사', - location: '도쿄, 일본', - description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', - categories: ['관광지', '문화', '역사'], - image: require('@/assets/images/thumnail.png'), - }, - { - id: 'place_4', - title: '센소지 아사쿠사', - location: '도쿄, 일본', - description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', - categories: ['관광지', '문화', '역사'], - image: require('@/assets/images/thumnail.png'), - }, - { - id: 'place_5', - title: '센소지 아사쿠사', - location: '도쿄, 일본', - description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', - categories: ['관광지', '문화', '역사'], - image: require('@/assets/images/thumnail.png'), - }, - - - { - id: 'place_6', - title: '센소지 아사쿠사', - location: '도쿄, 일본', - description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', - categories: ['관광지', '문화', '역사'], - image: require('@/assets/images/thumnail.png'), - }, - ] - const search_places: PlaceCardProps['place'][] = [ - { - id: 'place_6', - title: '센소지 아사쿠사', - location: '도쿄, 일본', - description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', - categories: ['관광지', '문화', '역사'], - image: require('@/assets/images/thumnail.png'), - }, - { - id: 'place_1', - title: '센소지 아사쿠사', - location: '도쿄, 일본', - description: '도쿄는 일본의 수도이자 전통과 현대가 조화를 이루는 매력적인 도시입니다.', - categories: ['관광지', '문화', '역사'], - image: require('@/assets/images/thumnail.png'), - }, - ] - - - // 하트 다중 선택용 상태 관리 - const [likedItemIds, setLikedItemIds] = useState>(new Set()); - - const toggleLike = useCallback((id: string) => { - setLikedItemIds((prevIds) => { - const newIds = new Set(prevIds); - if (newIds.has(id)) { - newIds.delete(id); - } else { - newIds.add(id); - } - return newIds; + const SNAP_FULL = 35; + const SNAP_TRENDING = SHEET_HEIGHT - SECOND_SNAP_VISIBLE_HEIGHT; + const SEARCH_BUTTON_BOTTOM = BOTTOM_SHEET_MIN_HEIGHT + 10; + + const translateY = useSharedValue(SNAP_LOW); + + const [likedIdsByTab, setLikedIdsByTab] = useState>>(() => ({ + saved: new Set(), + wishlist: new Set(WISHLIST_PLACES.map((place) => place.id)), + })); + + const toggleLike = useCallback((tab: LikeTabId, id: string) => { + setLikedIdsByTab((prev) => { + const next = new Set(prev[tab]); + next.has(id) ? next.delete(id) : next.add(id); + return { ...prev, [tab]: next }; }); }, []); - const [isLiked, setIsLiked] = useState(false); + + const isLikedInTab = useCallback( + (tab: LikeTabId, id: string) => likedIdsByTab[tab].has(id), + [likedIdsByTab], + ); + const navigation = useNavigation(); - const bottomSheetRef = useRef(null); - const [selectedCategory, setSelectedCategory] = useState('trending'); + const [selectedCategory, setSelectedCategory] = useState(INITIAL_CATEGORY); const [isSearchFocused, setIsSearchFocused] = useState(false); const [showAddModal, setShowAddModal] = useState(false); const [showExitModal, setShowExitModal] = useState(false); - const [activeTab, setActiveTab] = React.useState('info'); const [isSheetExpanded, setIsSheetExpanded] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); const searchInputRef = useRef(null); - const handleSheetChange = useCallback((expanded: boolean) => { - setIsSheetExpanded(expanded); - }, []); + const refocusRafRef = useRef(null); + const isSearchFocusedRef = useRef(false); + const isKeyboardVisibleRef = useRef(false); + const isInitialTabEffect = useRef(true); + const showAddModalRef = useRef(false); + const showExitModalRef = useRef(false); - const handleTabChange = useCallback((tabId: string) => { - setActiveTab(tabId); - }, []); + useEffect(() => { + showAddModalRef.current = showAddModal; + }, [showAddModal]); + useEffect(() => { + showExitModalRef.current = showExitModal; + }, [showExitModal]); - const handleGoBack = useCallback(() => { + useEffect(() => { + isSearchFocusedRef.current = isSearchFocused; + }, [isSearchFocused]); - setShowExitModal(true); + useEffect(() => { + const showSub = Keyboard.addListener('keyboardDidShow', () => { + isKeyboardVisibleRef.current = true; + }); + const hideSub = Keyboard.addListener('keyboardDidHide', () => { + isKeyboardVisibleRef.current = false; + }); + + return () => { + showSub.remove(); + hideSub.remove(); + }; }, []); - const handleExpand = useCallback(() => { - bottomSheetRef.current?.expand(); + const clearPendingRefocus = useCallback(() => { + if (refocusRafRef.current !== null) { + cancelAnimationFrame(refocusRafRef.current); + refocusRafRef.current = null; + } }, []); + useEffect(() => { + return () => clearPendingRefocus(); + }, [clearPendingRefocus]); + // 바텀시트 애니메이션 함수 + const animateSheetTo = useCallback( + (targetY: number) => { + const currentY = translateY.value; + const distance = Math.abs(targetY - currentY); + const isMovingDown = targetY > currentY; + + if (isMovingDown) { + const duration = Math.max(260, Math.min(620, distance * 0.95)); + translateY.value = withTiming(targetY, { duration, easing: Easing.out(Easing.cubic) }); + } else { + const duration = Math.max(240, Math.min(700, distance * 1.02)); + translateY.value = withTiming(targetY, { duration, easing: Easing.out(Easing.cubic) }); + } + setIsSheetExpanded(targetY !== SNAP_LOW); + }, + [SNAP_LOW, translateY], + ); + // 바텀시트 상태 변화 핸들러 + const handleSheetChange = useCallback((expanded: boolean) => { + setIsSheetExpanded(expanded); + }, []); + // 검색 입력창에 포커스 주기 + const focusSearchInput = useCallback(() => { + const input = searchInputRef.current; + if (!input) return; + + clearPendingRefocus(); + + if (Platform.OS === 'android' && input.isFocused()) { + if (isKeyboardVisibleRef.current) return; + input.blur(); + refocusRafRef.current = requestAnimationFrame(() => { + refocusRafRef.current = null; + if (!isSearchFocusedRef.current) return; + input.focus(); + }); + return; + } - const handleCollapse = useCallback(() => { - bottomSheetRef.current?.collapse(); + input.focus(); + }, [clearPendingRefocus]); + // 검색 입력창에 포커스 될 때 → 검색어 상태 업데이트 + 키보드 올리기 + const handleSearchFocus = useCallback(() => { + isSearchFocusedRef.current = true; + setIsSearchFocused(true); + }, []); + // 검색 입력창에서 포커스 벗어날 때 → 검색어 초기화 + 키보드 내리기 + const handleSearchBlur = useCallback(() => { + clearPendingRefocus(); + isSearchFocusedRef.current = false; + isKeyboardVisibleRef.current = false; + searchInputRef.current?.blur(); + setIsSearchFocused(false); + Keyboard.dismiss(); + }, [clearPendingRefocus]); + const handleSearchInputBlur = useCallback(() => { + // Intentionally keep search mode active; only hide keyboard on blur. }, []); + // 뒤로가기 버튼 핸들러: 검색 중이면 검색 종료, 그 외에는 모달 열기 + const handleGoBack = useCallback(() => { + if (isSearchFocused) { + handleSearchBlur(); + return; + } + setShowExitModal(true); + }, [isSearchFocused, handleSearchBlur]); + // AI 추천 일정짜기 버튼 핸들러 → TripDetail로 이동 + 모달 닫기 const handleAiPlan = useCallback(() => { - navigation.navigate('TripDetail'); + navigation.navigate('TripDetail'); setShowAddModal(false); - // 여기에 AI 일정 생성 페이지로 이동하는 로직 추가 - }, []); - + }, [navigation]); + // 직접 일정짜기 버튼 핸들러 → TripDetail로 이동 + 모달 닫기 const handleManualPlan = useCallback(() => { - navigation.navigate('TripDetail'); + navigation.navigate('TripDetail'); setShowAddModal(false); - // 여기에 직접 작성 페이지로 이동하는 로직 추가 - }, []); - - const handleComplete = useCallback(() => { - setShowAddModal(true); - }, []); - + }, [navigation]); + // 완료 버튼 핸들러 → 모달 열기 + const handleComplete = useCallback(() => setShowAddModal(true), []); + // 지도 영역 누르면 바텀시트 내려가기 const handleMapPress = useCallback(() => { - if (isSheetExpanded) { - translateY.value = withTiming(SHEET_HEIGHT - 28, { - duration: 400, - easing: Easing.out(Easing.exp), - }); - setIsSheetExpanded(false); + if (isSheetExpanded) animateSheetTo(SNAP_LOW); + }, [animateSheetTo, isSheetExpanded, SNAP_LOW]); + + // 좋아요 토글 핸들러 + 상태 조회 함수 (탭별) + const handleToggleSaved = useCallback((id: string) => toggleLike('saved', id), [toggleLike]); + const handleToggleWishlist = useCallback( + (id: string) => toggleLike('wishlist', id), + [toggleLike], + ); + const isSavedLiked = useCallback((id: string) => isLikedInTab('saved', id), [isLikedInTab]); + const isWishlistLiked = useCallback((id: string) => isLikedInTab('wishlist', id), [isLikedInTab]); + // 탭 변경 시 바텀시트 애니메이션 + useEffect(() => { + if (isInitialTabEffect.current) { + isInitialTabEffect.current = false; + return; } - }, [isSheetExpanded, translateY]); - + const targetY = selectedCategory === 'trending' ? SNAP_TRENDING : SNAP_FULL; + animateSheetTo(targetY); + }, [selectedCategory, animateSheetTo, SNAP_TRENDING, SNAP_FULL]); + // 바텀시트 애니메이션 스타일 const mapUIAnimatedStyle = useAnimatedStyle(() => { - 'worklet' - const opacity = interpolate( - translateY.value, - [SNAP_LOW - 5, SNAP_LOW], // SNAP_LOW 지점에 가까워질 때만 나타남 - [0, 1], - 'clamp' - ); - - return { - opacity, - // 버튼의 기본 위치를 70 지점보다 살짝 위로 설정 - pointerEvents: opacity < 0.1 ? 'none' : 'auto', - }; + 'worklet'; + const opacity = interpolate(translateY.value, [SNAP_LOW - 5, SNAP_LOW], [0, 1], 'clamp'); + return { opacity, pointerEvents: opacity < 0.1 ? 'none' : 'auto' }; }); - useEffect(() => { - const backAction = () => { - //모달이 떠 있으면 동작 막기 - if (showExitModal || showAddModal) return true; - - // 모달 띄우기 - setShowExitModal(true); - - return true; - }; + // 뒤로가기 버튼 핸들링 + 모달 상태 초기화 + useFocusEffect( + useCallback(() => { + setShowAddModal(false); + setShowExitModal(false); + + if (selectedCategory === 'trending' && translateY.value < SNAP_TRENDING) { + animateSheetTo(SNAP_TRENDING); + } - // 핸들러 등록 - const backHandler = BackHandler.addEventListener( - 'hardwareBackPress', - backAction, - ); - - return () => backHandler.remove(); - }, [showExitModal, showAddModal]); - //바텀시트 안에 콘텐츠 - // 1. 데이터가 있을 때 나오는 화면 (리스트) - const renderContent = (): React.ReactElement | null => { - switch (selectedCategory) { - case 'trending': - return ( - <> - 현재 핫한 장소 - - {TRENDING_PLACES.map((place) => ( - - ))} - - - ); - case 'saved': - return ( - - {SAVED_PLACES.map((place) => ( - - ))} - - ); - case 'wishlist': - return ( - - {WISHLIST_PLACES.map((place) => ( - - ))} - - ); - default: - return null; - } - }; + const backAction = () => { + if (showExitModalRef.current || showAddModalRef.current) return true; + if (isSearchFocused) { + handleSearchBlur(); + return true; + } + setShowExitModal(true); + return true; + }; + + const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction); + return () => backHandler.remove(); + }, [ + selectedCategory, + SNAP_TRENDING, + animateSheetTo, + translateY, + isSearchFocused, + handleSearchBlur, + ]), + ); - // 데이터가 없을 때 나오는 화면 - const renderEmptyContent = (): React.ReactElement | null => { + //탭 컨텐츠 + const renderTabContent = () => { switch (selectedCategory) { case 'trending': - return ( - <> - 현재 핫한 장소 - - {TRENDING_PLACES.map((place) => ( - - ))} - - + toggleLike('wishlist', id)} + /> ); - //저장된 장소 데이터 없을 경우 case 'saved': return ( - - - 저장한 장소가 없어요 - 마음에 드는 장소를 저장해주세요. - + ); - //위시리스트 데이터 없을 경우 case 'wishlist': return ( - - - 위시리스트에 장소가 없어요 - 가고싶은 장소를 위시리스트에 추가해주세요. - + ); default: return null; } }; - // 2. 렌더링 return ( + /> - {/* 지도 영역만 누르면 바텀시트 내려가기 */} + {/* 지도 영역 누르면 바텀시트 내려가기 */} { onPress={handleMapPress} /> - - - {/* 왼쪽: 뒤로가기 버튼 */} - - - - {/* 2. 검색 입력창 */} - - setIsSearchFocused(true)} - onBlur={() => setIsSearchFocused(false)} - /> - - {/* 3. 검색 아이콘 */} - - - - - + - {/* 3. 지도 위 UI를 Animated.View로 감싸고 mapUIAnimatedStyle 적용 */} - + - + - + style={{ borderRadius: 100 }}> + - - - - {/* 왼쪽: 탭 메뉴들 */} - - {tabs.map((tab) => ( - setSelectedCategory(tab.id)} - isSelected={selectedCategory === tab.id} - className={`mr-2 px-4 py-2 rounded-2xl ${selectedCategory === tab.id ? "bg-main" : "bg-chip" - }`} - /> - ))} - - - {/* 오른쪽: 완료 버튼 (혼자 우측 정렬) */} - - - - - - - {(() => { - if (selectedCategory === 'trending') { - return renderContent(); - } - - - const activeData = selectedCategory === 'saved' ? SAVED_PLACES : WISHLIST_PLACES; - - return activeData.length === 0 - ? renderEmptyContent() - : renderContent(); - })()} - - - - - {/* 검색창이 포커스 되었을 때 */} + onStateChange={handleSheetChange} + maxTopSnap={selectedCategory === 'trending' ? SNAP_TRENDING : SNAP_FULL} + tabs={TABS} + selectedCategory={selectedCategory} + onSelectCategory={setSelectedCategory} + onPressComplete={handleComplete} + renderTabContent={renderTabContent} + /> + )} {isSearchFocused && ( - - - { - setIsSearchFocused(false); - - - }} - onBlur={() => setIsSearchFocused(false)} - style={{ - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'white', - }} - /> - {/**검색바 공간 차지 */} - - - {search_places.map((place) => ( - ))} - - + + isLikedInTab(selectedCategory === 'trending' ? 'wishlist' : selectedCategory, id) + } + onToggleLike={(id) => + toggleLike(selectedCategory === 'trending' ? 'wishlist' : selectedCategory, id) + } + /> )} - {/* 1. 완료 모 */} + {/* 완료 모달 */} setShowAddModal(false)} @@ -532,13 +497,13 @@ const WishlistScreen: React.FC = () => { primaryBtnClass="w-full py-3 bg-main" primaryTextClass="text-white" secondaryLabel="직접 일정짜기" - secondaryBtnClass="w-full py-3 border border-main " + secondaryBtnClass="w-full py-3 border border-main" secondaryTextClass="text-main" onPrimaryPress={handleAiPlan} onSecondaryPress={handleManualPlan} /> - {/* 2. 뒤로가기 모달*/} + {/* 뒤로가기 모달 */} setShowExitModal(false)} @@ -553,7 +518,7 @@ const WishlistScreen: React.FC = () => { primaryBtnClass="flex-1 bg-main py-3" primaryTextClass="text-white" secondaryLabel="나가기" - secondaryBtnClass=" flex-1 bg-chip py-3" + secondaryBtnClass="flex-1 bg-chip py-3" secondaryTextClass="text-gray" onPrimaryPress={() => setShowExitModal(false)} onSecondaryPress={() => navigation.goBack()} diff --git a/src/screens/wishList/components/CategoryChip.tsx b/src/screens/wishList/components/CategoryChip.tsx index dde6411..a9a618d 100644 --- a/src/screens/wishList/components/CategoryChip.tsx +++ b/src/screens/wishList/components/CategoryChip.tsx @@ -1,52 +1,43 @@ import React, { useCallback } from 'react'; import { View, Text, Pressable } from 'react-native'; - -// ============ Types ============ -export interface CategoryChipProps { - label: string; - onPress?: () => void; - isSelected?: boolean; - className?: string; - textClassName?: string; -} +import type { CategoryChipProps } from '@/screens/wishList/types'; // ============ Component ============ export const CategoryChip = React.memo( - ({ label, onPress, isSelected = false, className, textClassName }) => { - const handlePress = useCallback(() => { - onPress?.(); - }, [onPress]); - - - const defaultContainerStyle = 'items-center justify-center'; - - // 선택 여부에 따른 색상만 자동으로 처리 - const bgStyle = isSelected ? 'bg-main' : 'bg-chip'; - const textStyle = isSelected ? 'text-white' : 'text-gray'; - - // 렌더링 함수 - const renderContent = ( - {label} - ); - - if (onPress) { - return ( - - {renderContent} - - ); - } - - return ( - - {renderContent} - - ); - }, + ({ label, onPress, isSelected = false, className, textClassName }) => { + const handlePress = useCallback(() => { + onPress?.(); + }, [onPress]); + + const defaultContainerStyle = 'items-center justify-center'; + + // 선택 여부에 따른 색상만 자동으로 처리 + const bgStyle = isSelected ? 'bg-main' : 'bg-chip'; + const textStyle = isSelected ? 'text-white' : 'text-gray'; + + // 렌더링 함수 + const renderContent = ( + {label} + ); + + if (onPress) { + return ( + + {renderContent} + + ); + } + + return ( + + {renderContent} + + ); + }, ); CategoryChip.displayName = 'CategoryChip'; -export default CategoryChip; \ No newline at end of file +export default CategoryChip; diff --git a/src/screens/wishList/components/PlaceCard.tsx b/src/screens/wishList/components/PlaceCard.tsx index f32f2b0..d02457e 100644 --- a/src/screens/wishList/components/PlaceCard.tsx +++ b/src/screens/wishList/components/PlaceCard.tsx @@ -1,103 +1,80 @@ import React from 'react'; -import { View, Text, Image, TouchableOpacity, ImageSourcePropType } from 'react-native'; +import { View, Text, Image, TouchableOpacity } from 'react-native'; import { ContentContainer } from '@/components/ui'; import { CategoryChip, WishContentContainer } from '@/screens/wishList/components'; import { PlaceIcon, HeartIcon, ActiveHeartIcon, VectorIcon } from '@/assets/icons'; - - -export interface PlaceCardProps { - place: { - id: string; - title: string; - location?: string; - description: string; - image: ImageSourcePropType; - categories?: string[]; - }; - - isLiked: boolean; - onToggleLike: (id: string) => void; - isTrending?: boolean; -} +import type { PlaceCardProps } from '@/screens/wishList/types'; export const PlaceCard = React.memo( - ({ - place, - isLiked, - onToggleLike, - isTrending = false, - }) => { - - - if (isTrending) { - return ( - - - - - - - - {place.title} - - {place.description} - - {/* 카테고리 칩 */} - {place.categories && ( - - {place.categories.map((cat, idx) => ( - - ))} - - )} - - onToggleLike(place.id)}> - - - - + ({ place, isLiked, onToggleLike, isTrending = false }) => { + if (isTrending) { + return ( + + + + + + + + {place.title} + + + {place.description} + + + {/* 카테고리 칩 */} + {place.categories && ( + + {place.categories.map((cat, idx) => ( + + ))} + + )} + + onToggleLike(place.id)}> + + + + + + ); + } + return ( + + + + + + + + {place.title} + + + - ); - } - return ( - - - - - - - - {place.title} - - - - - {place.location} - - - {place.description} - - - onToggleLike(place.id)} - > - {isLiked ? : } - - - + {place.location} + + + + {place.description} + + - ); - }, + onToggleLike(place.id)}> + {isLiked ? : } + + + + + ); + }, ); PlaceCard.displayName = 'PlaceCard'; -export default PlaceCard; \ No newline at end of file +export default PlaceCard; diff --git a/src/screens/wishList/components/WishContentContainer.tsx b/src/screens/wishList/components/WishContentContainer.tsx index 8d6c47e..3ab730a 100644 --- a/src/screens/wishList/components/WishContentContainer.tsx +++ b/src/screens/wishList/components/WishContentContainer.tsx @@ -1,51 +1,47 @@ import React, { useMemo } from 'react'; import { View } from 'react-native'; import { Shadow } from 'react-native-shadow-2'; - -// ============ Types ============ -export interface WishContentContainerProps { - children: React.ReactNode; - className?: string; -} +import type { WishContentContainerProps } from '@/screens/wishList/types'; // ============ Component ============ export const WishContentContainer = React.memo( - ({ children, className }) => { - // 파생 값 - const { widthClass, otherClasses } = useMemo(() => { - if (!className) return { widthClass: null, otherClasses: undefined }; - - // tokens 기반으로 width 클래스(w-*, w-[*])만 분리해 안전하게 제거 - const tokens = className.split(/\s+/).filter(Boolean); - const widthTokenIndex = tokens.findIndex((t) => /^w-(?:\[[^\]]+\]|\d+)$/.test(t)); - - if (widthTokenIndex === -1) { - return { widthClass: null, otherClasses: className }; - } - - const widthClass = tokens[widthTokenIndex] ?? null; - const otherClasses = tokens.filter((_, idx) => idx !== widthTokenIndex).join(' ').trim(); - return { widthClass, otherClasses }; - }, [className]); - - // 렌더링 - return ( - - - - {children} - - - - ); - }, + ({ children, className }) => { + // 파생 값 + const { widthClass, otherClasses } = useMemo(() => { + if (!className) return { widthClass: null, otherClasses: undefined }; + + // tokens 기반으로 width 클래스(w-*, w-[*])만 분리해 안전하게 제거 + const tokens = className.split(/\s+/).filter(Boolean); + const widthTokenIndex = tokens.findIndex((t) => /^w-(?:\[[^\]]+\]|\d+)$/.test(t)); + + if (widthTokenIndex === -1) { + return { widthClass: null, otherClasses: className }; + } + + const widthClass = tokens[widthTokenIndex] ?? null; + const otherClasses = tokens + .filter((_, idx) => idx !== widthTokenIndex) + .join(' ') + .trim(); + return { widthClass, otherClasses }; + }, [className]); + + // 렌더링 + return ( + + + {children} + + + ); + }, ); WishContentContainer.displayName = 'WishContentContainer'; diff --git a/src/screens/wishList/components/WishModal.tsx b/src/screens/wishList/components/WishModal.tsx index 3ae19b4..822469a 100644 --- a/src/screens/wishList/components/WishModal.tsx +++ b/src/screens/wishList/components/WishModal.tsx @@ -1,109 +1,80 @@ import { X } from '@/assets/icons'; import React from 'react'; import { View, Text, Pressable, Modal } from 'react-native'; - -// ============ Types ============ -export interface WishModalProps { - isVisible: boolean; - onClose: () => void; - icon?: React.ReactNode; - ModalIcon?: string; - title: string; - showCloseButton: boolean; - // 버튼 레이아웃 스타일 - buttonContainerClass?: string; - - // 메인 버튼 스타일 - primaryLabel: string; - ModalContainer: string; - onPrimaryPress: () => void; - primaryBtnClass?: string; - primaryIcon?: React.ReactNode; - primaryTextClass?: string; - primaryTitleTextClass?: string; - // 서브 버튼 스타일 - secondaryLabel: string; - onSecondaryPress: () => void; - secondaryBtnClass?: string; - secondaryTextClass?: string; -} +import type { WishModalProps } from '@/screens/wishList/types'; // ============ Component ============ export const WishModal = React.memo( - ({ - isVisible, - onClose, - showCloseButton, - icon, - ModalIcon, - ModalContainer, - title, - buttonContainerClass, - primaryLabel, - onPrimaryPress, - primaryIcon, - primaryBtnClass, - primaryTextClass, - secondaryLabel, - onSecondaryPress, - secondaryBtnClass, - secondaryTextClass, - primaryTitleTextClass - }) => { - return ( - - - e.stopPropagation()} className="w-full"> - - - {/* 아이콘 영역 */} - {icon && {icon}} - - - {/* X 버튼 */} - {showCloseButton ? - - - : null - } - {/* 타이틀 영역 */} - - {title} - + ({ + isVisible, + onClose, + showCloseButton, + icon, + ModalIcon, + ModalContainer, + title, + buttonContainerClass, + primaryLabel, + onPrimaryPress, + primaryIcon, + primaryBtnClass, + primaryTextClass, + secondaryLabel, + onSecondaryPress, + secondaryBtnClass, + secondaryTextClass, + primaryTitleTextClass, + }) => { + return ( + + + e.stopPropagation()} className="w-full"> + + {/* 아이콘 영역 */} + {icon && {icon}} + {/* X 버튼 */} + {showCloseButton ? ( + + + + ) : null} + {/* 타이틀 영역 */} + + {title} + - {/* 버튼 영역: 외부에서 주입한 클래스에 따라 가로/세로 결정 */} - - - {/* Primary 버튼 */} - {primaryIcon && {primaryIcon}} - - {primaryLabel} - - - - {/* Secondary 버튼 */} - - - {secondaryLabel} - - - + {/* 버튼 영역: 외부에서 주입한 클래스에 따라 가로/세로 결정 */} + + {/* Primary 버튼 */} + + {primaryIcon && {primaryIcon}} + + {primaryLabel} + + - - - + {/* Secondary 버튼 */} + + + {secondaryLabel} + - - ); - } + + + + + + ); + }, ); WishModal.displayName = 'WishModal'; -export default WishModal; \ No newline at end of file +export default WishModal; diff --git a/src/screens/wishList/components/WishlistBottomSheet.tsx b/src/screens/wishList/components/WishlistBottomSheet.tsx new file mode 100644 index 0000000..08f13c4 --- /dev/null +++ b/src/screens/wishList/components/WishlistBottomSheet.tsx @@ -0,0 +1,59 @@ +import React, { memo } from 'react'; +import { ScrollView, View } from 'react-native'; +import CustomBottomSheet from '@/components/ui/CustomBottomSheet'; +import { CategoryChip } from '@/screens/wishList/components'; +import type { WishlistBottomSheetProps } from '@/screens/wishList/types'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +const WishlistBottomSheetComponent: React.FC = ({ + translateY, + onStateChange, + maxTopSnap, + tabs, + selectedCategory, + onSelectCategory, + onPressComplete, + renderTabContent, + +}) => { + const insets = useSafeAreaInsets(); + return ( + + + + {tabs.map((tab) => ( + onSelectCategory(tab.id)} + isSelected={selectedCategory === tab.id} + className={`mr-2 rounded-2xl px-4 py-2 ${selectedCategory === tab.id ? 'bg-main' : 'bg-chip'}`} + /> + ))} + + + + + + + {renderTabContent()} + + + + ); +}; + +WishlistBottomSheetComponent.displayName = 'WishlistBottomSheet'; + +export const WishlistBottomSheet = memo(WishlistBottomSheetComponent); diff --git a/src/screens/wishList/components/WishlistSearchBar.tsx b/src/screens/wishList/components/WishlistSearchBar.tsx new file mode 100644 index 0000000..d577493 --- /dev/null +++ b/src/screens/wishList/components/WishlistSearchBar.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { TextInput, TouchableOpacity, View } from 'react-native'; +import { SearchArrowIcon, SearchingIcon } from '@/assets/icons'; +import { SearchContainer } from '@/components/ui'; +import { COLORS } from '@/constants'; +import type { WishlistSearchBarProps } from '@/screens/wishList/types'; + +export const WishlistSearchBar = React.memo( + ({ searchInputRef, searchQuery, onChangeText, onFocus, onBlur, onFocusInput, onPressBack }) => { + return ( + + + + + + + + + + ); + }, +); + +WishlistSearchBar.displayName = 'WishlistSearchBar'; diff --git a/src/screens/wishList/components/WishlistSearchOverlay.tsx b/src/screens/wishList/components/WishlistSearchOverlay.tsx new file mode 100644 index 0000000..a3bb629 --- /dev/null +++ b/src/screens/wishList/components/WishlistSearchOverlay.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { Keyboard, KeyboardAvoidingView, Platform, View } from 'react-native'; +import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'; +import PlaceCard from './PlaceCard'; +import type { PlaceCardProps, WishlistBottomSheetTabId } from '@/screens/wishList/components'; +import { ScrollView } from 'react-native-gesture-handler'; + +interface WishlistSearchOverlayProps { + isVisible: boolean; + selectedCategory: WishlistBottomSheetTabId; + places: PlaceCardProps['place'][]; + isLiked: (id: string) => boolean; + onToggleLike: (id: string) => void; +} + +export const WishlistSearchOverlay = React.memo( + ({ isVisible, selectedCategory, places, isLiked, onToggleLike }) => { + const animatedOpacity = useSharedValue(isVisible ? 1 : 0); + const [keyboardHeight, setKeyboardHeight] = React.useState(0); + + React.useEffect(() => { + animatedOpacity.value = withTiming(isVisible ? 1 : 0, { duration: 180 }); + }, [animatedOpacity, isVisible]); + + React.useEffect(() => { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + + const showSub = Keyboard.addListener(showEvent, (event) => { + setKeyboardHeight(event.endCoordinates.height); + }); + const hideSub = Keyboard.addListener(hideEvent, () => { + setKeyboardHeight(0); + }); + + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: animatedOpacity.value, + })); + + return ( + + + + + + {places.map((place) => ( + + ))} + + + + + ); + }, +); + +WishlistSearchOverlay.displayName = 'WishlistSearchOverlay'; diff --git a/src/screens/wishList/components/index.ts b/src/screens/wishList/components/index.ts index 6fb3124..2124b45 100644 --- a/src/screens/wishList/components/index.ts +++ b/src/screens/wishList/components/index.ts @@ -1,7 +1,24 @@ // destinationDetail 스크린 전용 서브컴포넌트 barrel export export { CategoryChip } from './CategoryChip'; -export type { CategoryChipProps } from './CategoryChip'; export { WishContentContainer } from './WishContentContainer'; -export type { WishContentContainerProps } from './WishContentContainer'; export { default as PlaceCard } from './PlaceCard'; -export type { PlaceCardProps } from './PlaceCard' +export { WishTabTrending } from './tab/WishTabTrending'; +export { WishTabSave } from './tab/WishTabSave'; +export { WishTabWishlist } from './tab/WishTabWishlist'; +export { WishlistBottomSheet } from './WishlistBottomSheet'; +export { WishlistSearchBar } from './WishlistSearchBar'; +export { WishlistSearchOverlay } from './WishlistSearchOverlay'; +export type { + CategoryChipProps, + PlaceCardProps, + WishContentContainerProps, + WishModalProps, + WishTabSaveProps, + WishTabTrendingProps, + WishTabWishlistProps, + WishPlace, + WishlistBottomSheetProps, + WishlistBottomSheetTab, + WishlistBottomSheetTabId, + WishlistSearchBarProps, +} from '../types'; diff --git a/src/screens/wishList/components/tab/WishTabSave.tsx b/src/screens/wishList/components/tab/WishTabSave.tsx new file mode 100644 index 0000000..94f5539 --- /dev/null +++ b/src/screens/wishList/components/tab/WishTabSave.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import { EmptyLocation } from '@/assets/icons'; +import { PlaceCard } from '@/screens/wishList/components'; +import type { WishPlace, WishTabSaveProps } from '@/screens/wishList/types'; + +const SavePlaceItem = React.memo<{ + item: WishPlace; + isLiked: boolean; + onToggleLike: (id: string) => void; +}>(({ item, isLiked, onToggleLike }) => ( + +)); +SavePlaceItem.displayName = 'SavePlaceItem'; +// 빈 상태 — 변하지 않으므로 memo로 완전히 고정 +const WishSaveEmptyState = React.memo(() => ( + + + + + + 저장한 장소가 없어요 + + + + 마음에 드는 장소를 저장해주세요. + + + +)); +WishSaveEmptyState.displayName = 'WishSaveEmptyState'; + +export const WishTabSave = React.memo(({ places, isLiked, onToggleLike }) => { + if (places.length === 0) { + return ; + } + + return ( + + {places.map((item) => ( + + ))} + + ); +}); + +WishTabSave.displayName = 'WishTabSave'; diff --git a/src/screens/wishList/components/tab/WishTabTrending.tsx b/src/screens/wishList/components/tab/WishTabTrending.tsx new file mode 100644 index 0000000..1ebe9f7 --- /dev/null +++ b/src/screens/wishList/components/tab/WishTabTrending.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import { PlaceCard } from '@/screens/wishList/components'; +import type { WishPlace, WishTabTrendingProps } from '@/screens/wishList/types'; + +// .map() 사용 — 아이템 수가 적으므로 가상화 불필요 +const TrendingPlaceItem = React.memo<{ item: WishPlace; onToggleLike: (id: string) => void }>( + ({ item, onToggleLike }) => ( + + ), +); +TrendingPlaceItem.displayName = 'TrendingPlaceItem'; + +export const WishTabTrending = React.memo(({ places, onToggleLike }) => { + return ( + <> + + 현재 핫한 장소 + + + {places.map((item) => ( + + ))} + + + ); +}); + +WishTabTrending.displayName = 'WishTabTrending'; diff --git a/src/screens/wishList/components/tab/WishTabWishlist.tsx b/src/screens/wishList/components/tab/WishTabWishlist.tsx new file mode 100644 index 0000000..8f89b6f --- /dev/null +++ b/src/screens/wishList/components/tab/WishTabWishlist.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import { EmptyWish } from '@/assets/icons'; +import { PlaceCard } from '@/screens/wishList/components'; +import type { WishPlace, WishTabWishlistProps } from '@/screens/wishList/types'; + +// FlatList 아이템 → .map()으로 간단히 렌더링 +const WishlistPlaceItem = React.memo<{ + item: WishPlace; + isLiked: boolean; + onToggleLike: (id: string) => void; +}>(({ item, isLiked, onToggleLike }) => ( + +)); +WishlistPlaceItem.displayName = 'WishlistPlaceItem'; + +// 빈 상태 — 변하지 않으므로 memo로 완전히 고정 +const WishlistEmptyState = React.memo(() => ( + + + + + 위시리스트에 장소가 없어요 + + 가고싶은 장소를 위시리스트에 추가해주세요. + + +)); +WishlistEmptyState.displayName = 'WishlistEmptyState'; + +export const WishTabWishlist = React.memo( + ({ places, isLiked, onToggleLike }) => { + if (places.length === 0) { + return ; + } + + return ( + + {places.map((item) => ( + + ))} + + ); + }, +); + +WishTabWishlist.displayName = 'WishTabWishlist'; diff --git a/src/screens/wishList/index.ts b/src/screens/wishList/index.ts index 14f01d0..7c9af15 100644 --- a/src/screens/wishList/index.ts +++ b/src/screens/wishList/index.ts @@ -1,2 +1,3 @@ // DestinationDetail 스크린 전용 서브컴포넌트를 여기에 export 합니다. export * from './components'; +export type * from './types'; diff --git a/src/screens/wishList/types.ts b/src/screens/wishList/types.ts new file mode 100644 index 0000000..1184201 --- /dev/null +++ b/src/screens/wishList/types.ts @@ -0,0 +1,98 @@ +import type { ImageSourcePropType } from 'react-native'; +import type { TextInput } from 'react-native'; +import type { SharedValue } from 'react-native-reanimated'; + +export type WishlistBottomSheetTabId = 'trending' | 'saved' | 'wishlist'; + +export interface WishPlace { + id: string; + title: string; + location?: string; + description: string; + image: ImageSourcePropType; + categories?: string[]; +} + +export interface CategoryChipProps { + label: string; + onPress?: () => void; + isSelected?: boolean; + className?: string; + textClassName?: string; +} + +export interface WishContentContainerProps { + children: React.ReactNode; + className?: string; +} + +export interface PlaceCardProps { + place: WishPlace; + isLiked: boolean; + onToggleLike: (id: string) => void; + isTrending?: boolean; +} + +export interface WishTabTrendingProps { + places: WishPlace[]; + onToggleLike: (id: string) => void; +} + +export interface WishTabSaveProps { + places: WishPlace[]; + isLiked: (id: string) => boolean; + onToggleLike: (id: string) => void; +} + +export interface WishTabWishlistProps { + places: WishPlace[]; + isLiked: (id: string) => boolean; + onToggleLike: (id: string) => void; +} + +export interface WishModalProps { + isVisible: boolean; + onClose: () => void; + icon?: React.ReactNode; + ModalIcon?: string; + title: string; + showCloseButton: boolean; + buttonContainerClass?: string; + primaryLabel: string; + ModalContainer: string; + onPrimaryPress: () => void; + primaryBtnClass?: string; + primaryIcon?: React.ReactNode; + primaryTextClass?: string; + primaryTitleTextClass?: string; + secondaryLabel: string; + onSecondaryPress: () => void; + secondaryBtnClass?: string; + secondaryTextClass?: string; +} + +export interface WishlistBottomSheetTab { + id: WishlistBottomSheetTabId; + label: string; +} + +export interface WishlistBottomSheetProps { + translateY: SharedValue; + onStateChange: (expanded: boolean) => void; + maxTopSnap?: number; + tabs: WishlistBottomSheetTab[]; + selectedCategory: WishlistBottomSheetTabId; + onSelectCategory: (tabId: WishlistBottomSheetTabId) => void; + onPressComplete: () => void; + renderTabContent: () => React.ReactNode; +} + +export interface WishlistSearchBarProps { + searchInputRef: React.RefObject; + searchQuery: string; + onChangeText: (text: string) => void; + onFocus: () => void; + onBlur: () => void; + onFocusInput: () => void; + onPressBack: () => void; +}