From 135e3bb8d228ee72c44f4a97d453941962c02e1f Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 02:27:55 +0900 Subject: [PATCH 01/24] =?UTF-8?q?style:=20ui=20=EA=B0=9C=EC=84=A0-?= =?UTF-8?q?=ED=83=AD=20=EA=B5=AC=EC=84=B1=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useLocationManager.ts | 2 +- src/navigation/RouteStackNavigator.tsx | 12 ++- src/navigation/RunTabNavigator.tsx | 115 ++++++++++++++++++++++ src/navigation/TabNavigator.tsx | 43 ++++---- src/pages/Detail/index.tsx | 25 ++--- src/pages/Draw/index.tsx | 7 +- src/pages/Records/index.tsx | 30 ++++++ src/pages/Route/_components/RouteGrid.tsx | 99 ++++++++++++++----- src/pages/Route/index.tsx | 28 +++--- src/pages/Run/index.tsx | 9 +- src/store/draw.ts | 1 + src/types/navigation.types.ts | 4 +- 12 files changed, 288 insertions(+), 87 deletions(-) create mode 100644 src/navigation/RunTabNavigator.tsx create mode 100644 src/pages/Records/index.tsx diff --git a/src/hooks/useLocationManager.ts b/src/hooks/useLocationManager.ts index 986ec12..f1118fa 100644 --- a/src/hooks/useLocationManager.ts +++ b/src/hooks/useLocationManager.ts @@ -78,7 +78,7 @@ export function useLocationManager() { }; return { - initialLocation: currentUserLocation.current, // 현재 위치가 없으면 null + initialLocation: currentUserLocation.current, // 현재 위치만 사용 locationLoading: locationLoading, location, errorMsg, diff --git a/src/navigation/RouteStackNavigator.tsx b/src/navigation/RouteStackNavigator.tsx index 7df54f1..ef57d48 100644 --- a/src/navigation/RouteStackNavigator.tsx +++ b/src/navigation/RouteStackNavigator.tsx @@ -20,10 +20,18 @@ export type RouteStackParamList = { const Stack = createNativeStackNavigator(); -export default function RouteStackNavigator() { +interface RouteStackNavigatorProps { + onStartRun?: (courseId: number) => void; +} + +export default function RouteStackNavigator({ + onStartRun, +}: RouteStackNavigatorProps) { return ( - + + {(props) => } + diff --git a/src/navigation/RunTabNavigator.tsx b/src/navigation/RunTabNavigator.tsx new file mode 100644 index 0000000..2cc3a4d --- /dev/null +++ b/src/navigation/RunTabNavigator.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; +import { View } from 'react-native'; +import styled from '@emotion/native'; +import { Play } from 'lucide-react-native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import TabNavigation from '@/components/TabNavigation'; +import Header from '@/components/Header'; + +import Route from '@/pages/Route'; +import Draw from '@/pages/Draw'; +import RouteSave from '@/pages/RouteSave'; +import Detail from '@/pages/Detail'; +import Run from '@/pages/Run'; + +const Stack = createNativeStackNavigator(); + +type RunTabId = 'quickstart' | 'courseselection'; + +const tabs: Array<{ id: RunTabId; title: string }> = [ + { id: 'quickstart', title: '바로가기' }, + { id: 'courseselection', title: '코스 선택하기' }, +]; + +// 바로가기 메인 컴포넌트 +function QuickStartMain({ navigation }: { navigation: any }) { + const [activeTab, setActiveTab] = useState('quickstart'); + + const handleStartPress = () => { + navigation.navigate('Run', {}); + }; + + const handleTabPress = (tabId: RunTabId) => { + setActiveTab(tabId); + }; + + const handleCourseStart = (courseId: number) => { + navigation.navigate('Run', { courseId }); + }; + + const renderContent = () => { + if (activeTab === 'quickstart') { + return ( + + + + 러닝 시작 + + + ); + } else { + return ; + } + }; + + return ( + +
+ + tabs={tabs} + activeTab={activeTab} + onTabPress={handleTabPress} + /> + {renderContent()} + + ); +} + +export default function RunTabNavigator() { + return ( + + + + + + + + ); +} + +const Container = styled.View({ + flex: 1, + backgroundColor: '#ffffff', +}); + +const QuickStartContainer = styled.View({ + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#f8f9fa', +}); + +const StartButton = styled.TouchableOpacity({ + backgroundColor: '#ff6b35', + paddingHorizontal: 48, + paddingVertical: 24, + borderRadius: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, +}); + +const PlayIcon = styled(Play)({ + // styled component for Play icon +}); + +const StartButtonText = styled.Text({ + color: '#ffffff', + fontSize: 18, + fontWeight: 'bold', +}); diff --git a/src/navigation/TabNavigator.tsx b/src/navigation/TabNavigator.tsx index 60b6107..e8a9fd1 100644 --- a/src/navigation/TabNavigator.tsx +++ b/src/navigation/TabNavigator.tsx @@ -1,17 +1,12 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { getFocusedRouteNameFromRoute } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { - Star, - AudioWaveform, - Play, - Laugh, - Settings, -} from 'lucide-react-native'; +import { Star, FileText, Play, Laugh, Settings } from 'lucide-react-native'; import WebCommunity from '@/pages/WebCommunity'; import Home from '@/pages/Home'; -import RouteStackNavigator from '@/navigation/RouteStackNavigator'; +import Records from '@/pages/Records'; +import RunTabNavigator from '@/navigation/RunTabNavigator'; import Run from '@/pages/Run'; import type { TabParamList } from '@/types/navigation.types'; @@ -58,17 +53,26 @@ export default function TabNavigator() { }} /> ( + + ), + }} + /> + { - const routeName = getFocusedRouteNameFromRoute(route) ?? 'RouteMain'; + const routeName = + getFocusedRouteNameFromRoute(route) ?? 'QuickStartMain'; return { - tabBarIcon: ({ color, size }) => ( - - ), + tabBarIcon: ({ color, size }) => , tabBarStyle: { ...baseTabBarStyle, display: + routeName === 'Run' || routeName === 'Draw' || routeName === 'RouteSave' || routeName === 'Detail' @@ -78,17 +82,6 @@ export default function TabNavigator() { }; }} /> - , - tabBarStyle: { - ...baseTabBarStyle, - display: 'none', - }, - }} - /> , - BottomTabScreenProps ->; +type Props = { + route: any; + navigation: any; +}; export default function Detail({ route, navigation }: Props) { const { courseId } = route.params; @@ -61,21 +61,8 @@ export default function Detail({ route, navigation }: Props) { useRunStore.getState().setCurrentCourse(courseId, courseData); } - try { - const routeStackNavigator = navigation.getParent(); - if (routeStackNavigator && 'jumpTo' in routeStackNavigator) { - (routeStackNavigator as any).jumpTo('Run'); - return; - } - - const tabNavigator = navigation.getParent()?.getParent(); - if (tabNavigator && 'jumpTo' in tabNavigator) { - (tabNavigator as any).jumpTo('Run'); - return; - } - } catch (error) { - // 네비게이션 에러 시 무시 - } + // RunTabNavigator 내부의 Run 스크린으로 이동 + navigation.navigate('Run', { courseId }); }; const { isPressing, pressProgress, animatedValue, startPress, stopPress } = diff --git a/src/pages/Draw/index.tsx b/src/pages/Draw/index.tsx index a090bd0..c6c7448 100644 --- a/src/pages/Draw/index.tsx +++ b/src/pages/Draw/index.tsx @@ -44,6 +44,7 @@ export default function Draw() { locationLoading, flyToCurrentUserLocation, handleUserLocationUpdate, + refreshLocation, } = useLocationManager(); const { composedGesture } = useMapGestures(mapRef); const { processImage } = useMapCapture(mapRef, cameraRef); @@ -52,7 +53,11 @@ export default function Draw() { useFocusEffect( useCallback(() => { clearAll(); - }, [clearAll]), + // 위치가 없으면 강제로 위치 요청 + if (!initialLocation && !locationLoading) { + refreshLocation(); + } + }, [clearAll, initialLocation, locationLoading, refreshLocation]), ); const handleBackPress = () => { diff --git a/src/pages/Records/index.tsx b/src/pages/Records/index.tsx new file mode 100644 index 0000000..46028d6 --- /dev/null +++ b/src/pages/Records/index.tsx @@ -0,0 +1,30 @@ +import styled from '@emotion/native'; +import { FileText } from 'lucide-react-native'; +import Header from '@/components/Header'; + +export default function Records() { + return ( + +
{}} /> + + 기록 페이지 + + + ); +} + +const Screen = styled.View({ + flex: 1, + backgroundColor: '#ffffff', +}); + +const Content = styled.View({ + flex: 1, + justifyContent: 'center', + alignItems: 'center', +}); + +const Text = styled.Text({ + fontSize: 16, + color: '#666666', +}); diff --git a/src/pages/Route/_components/RouteGrid.tsx b/src/pages/Route/_components/RouteGrid.tsx index 5badc32..8dd6922 100644 --- a/src/pages/Route/_components/RouteGrid.tsx +++ b/src/pages/Route/_components/RouteGrid.tsx @@ -5,8 +5,10 @@ import { Text, TouchableOpacity, RefreshControl, + View, } from 'react-native'; import { useCallback } from 'react'; +import { Play } from 'lucide-react-native'; import Card from '@/components/Card'; import useRouteStore from '@/store/route'; import { @@ -22,9 +24,13 @@ import { interface RouteGridProps { onRouteCardPress: (courseId: number) => void; + onStartRun: (courseId: number) => void; } -export default function RouteGrid({ onRouteCardPress }: RouteGridProps) { +export default function RouteGrid({ + onRouteCardPress, + onStartRun, +}: RouteGridProps) { const { activeTab, courses, @@ -90,40 +96,58 @@ export default function RouteGrid({ onRouteCardPress }: RouteGridProps) { if (activeTab === 'completed') { const completedItem = item as CompletedCourseItem; return ( - onRouteCardPress(completedItem.id)} - /> + + onRouteCardPress(completedItem.id)} + /> + onStartRun(completedItem.id)}> + + 러닝 시작 + + ); } else if (activeTab === 'liked') { const bookmarkedItem = item as BookmarkedCourseItem; return ( - onRouteCardPress(bookmarkedItem.id)} - /> + + onRouteCardPress(bookmarkedItem.id)} + /> + onStartRun(bookmarkedItem.id)}> + + 러닝 시작 + + ); } else { const courseItem = item as CourseSearchItem; return ( - onRouteCardPress(courseItem.id)} - /> + + onRouteCardPress(courseItem.id)} + /> + onStartRun(courseItem.id)}> + + 러닝 시작 + + ); } }, - [activeTab, onRouteCardPress], + [activeTab, onRouteCardPress, onStartRun], ); const currentData = getCurrentData(); @@ -261,3 +285,30 @@ const RetryButtonText = styled.Text({ fontSize: 14, fontWeight: '600', }); + +const CardContainer = styled.View({ + flex: 1, + marginBottom: 16, +}); + +const RunButton = styled.TouchableOpacity({ + backgroundColor: '#ff6b35', + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 4, + marginTop: 8, +}); + +const PlayIcon = styled(Play)({ + // styled component for Play icon +}); + +const RunButtonText = styled.Text({ + color: '#ffffff', + fontSize: 12, + fontWeight: '600', +}); diff --git a/src/pages/Route/index.tsx b/src/pages/Route/index.tsx index 108295c..efd5085 100644 --- a/src/pages/Route/index.tsx +++ b/src/pages/Route/index.tsx @@ -10,6 +10,8 @@ import FloatingButton from '@/components/FloatingButton'; import RouteGrid from './_components/RouteGrid'; import type { RouteTabId, TabParamList } from '@/types/navigation.types'; import type { RouteStackParamList } from '@/navigation/RouteStackNavigator'; +import { useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { BookmarkedCourseItem, CourseSearchItem, @@ -21,10 +23,10 @@ import { useCompletedCourses, } from '@/hooks/api/useRouteApi'; -type Props = CompositeScreenProps< - NativeStackScreenProps, - BottomTabScreenProps ->; +type Props = { + navigation: any; + onStartRun?: (courseId: number) => void; +}; const tabs: Array<{ id: RouteTabId; title: string }> = [ { id: 'created', title: '생성한 경로' }, @@ -32,7 +34,7 @@ const tabs: Array<{ id: RouteTabId; title: string }> = [ { id: 'liked', title: '좋아요한 경로' }, ]; -export default function Route({ navigation }: Props) { +export default function Route({ navigation, onStartRun }: Props) { const { activeTab, setActiveTab, @@ -73,6 +75,12 @@ export default function Route({ navigation }: Props) { navigation.navigate('Draw', undefined); }; + const handleStartRun = (courseId: number) => { + if (onStartRun) { + onStartRun(courseId); + } + }; + const handleTabPress = (tabId: RouteTabId) => { setActiveTab(tabId); @@ -88,17 +96,15 @@ export default function Route({ navigation }: Props) { return ( -
tabs={tabs} activeTab={activeTab} onTabPress={handleTabPress} /> - + ); diff --git a/src/pages/Run/index.tsx b/src/pages/Run/index.tsx index 0ac37c4..71a9e18 100644 --- a/src/pages/Run/index.tsx +++ b/src/pages/Run/index.tsx @@ -18,7 +18,7 @@ import ControlContainer from './_components/ControlContainer'; import Modal from '@/components/Modal'; import Mapbox from '@rnmapbox/maps'; -type Props = NativeStackScreenProps; +type Props = NativeStackScreenProps; export default function Run({ route, navigation }: Props) { // 전역 Store에서 현재 코스 데이터 가져오기 @@ -79,7 +79,12 @@ export default function Run({ route, navigation }: Props) { handleCancelExit, handleRetryExit, handleConfirmExit, - } = useRunModals({ navigation, mapRef, cameraRef, courseId }); + } = useRunModals({ + navigation: navigation as any, + mapRef, + cameraRef, + courseId, + }); useFocusEffect( useCallback(() => { diff --git a/src/store/draw.ts b/src/store/draw.ts index 9d2c958..8bd388c 100644 --- a/src/store/draw.ts +++ b/src/store/draw.ts @@ -66,6 +66,7 @@ const useDrawStore = create((set) => ({ completedDrawings: [], drawnCoordinates: [], drawMode: 'none', + isLoading: false, }), })); diff --git a/src/types/navigation.types.ts b/src/types/navigation.types.ts index cba9baf..1fbd9b8 100644 --- a/src/types/navigation.types.ts +++ b/src/types/navigation.types.ts @@ -16,8 +16,8 @@ export type RootStackParamList = { export type TabParamList = { Home: Record; - Route: Record; - Run: { courseId?: number } | Record; + Records: Record; + RunTab: Record; Community: Record; Settings: Record; }; From 43520c78d599255a61f76adc8995a5b654db9b69 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 04:51:32 +0900 Subject: [PATCH 02/24] =?UTF-8?q?style:=20UI=20=EC=83=89=EC=83=81=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Card, FloatingButton, Modal, TabNavigation, Detail, Run, DrawMap 등 여러 컴포넌트의 색상 변경 - LinearGradient를 사용하여 버튼 및 텍스트 배경 스타일 추가 - 색상 일관성을 위해 테마 색상 조정 --- src/components/Card.tsx | 4 +- src/components/FloatingButton.tsx | 24 ++- src/components/Modal.tsx | 29 +++- src/components/TabNavigation.tsx | 4 +- src/constants/colors.ts | 100 ++++++------ src/navigation/RunTabNavigator.tsx | 142 +++++++++++++++--- src/pages/Auth/index.tsx | 11 +- src/pages/Detail/index.tsx | 28 +++- src/pages/Draw/_components/DrawMap.tsx | 6 +- src/pages/Home/index.tsx | 2 +- src/pages/Route/_components/RouteGrid.tsx | 51 +------ src/pages/Route/index.tsx | 14 +- .../Run/_components/ControlContainer.tsx | 2 +- src/pages/Run/_components/RunMap.tsx | 8 +- src/pages/Run/_components/StatsContainer.tsx | 20 ++- src/pages/Run/index.tsx | 8 +- 16 files changed, 289 insertions(+), 164 deletions(-) diff --git a/src/components/Card.tsx b/src/components/Card.tsx index d871e4b..a029c67 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -77,7 +77,7 @@ export default function Card({ )} {content?.hasStar && ( - + )} @@ -254,7 +254,7 @@ const LoadingContainer = styled.View({ }); const LoadingText = styled.Text({ - color: '#007AFF', + color: '#2d2d2d', fontSize: 12, fontWeight: '500', }); diff --git a/src/components/FloatingButton.tsx b/src/components/FloatingButton.tsx index d09923a..1e1b683 100644 --- a/src/components/FloatingButton.tsx +++ b/src/components/FloatingButton.tsx @@ -2,6 +2,7 @@ import styled from '@emotion/native'; import { LucideIcon } from 'lucide-react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ViewStyle } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; interface FloatingButtonProps { icon: LucideIcon; @@ -20,12 +21,24 @@ export default function FloatingButton({ }: FloatingButtonProps) { const insets = useSafeAreaInsets(); + // style prop에서 backgroundColor가 있는지 확인 + const hasCustomBackground = Array.isArray(style) + ? style.some((s) => s && typeof s === 'object' && 'backgroundColor' in s) + : style && typeof style === 'object' && 'backgroundColor' in style; + return ( + {!hasCustomBackground && ( + + )} ); @@ -36,7 +49,6 @@ const ButtonContainer = styled.TouchableOpacity({ right: 20, width: 56, height: 56, - backgroundColor: '#ff6b35', borderRadius: 28, justifyContent: 'center', alignItems: 'center', @@ -46,4 +58,14 @@ const ButtonContainer = styled.TouchableOpacity({ shadowOpacity: 0.3, shadowRadius: 8, zIndex: 1000, + overflow: 'hidden', +}); + +const ButtonGradient = styled(LinearGradient)({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 28, }); diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 8a0bdc9..da6b372 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,5 +1,6 @@ import { Modal as RNModal, Text, TouchableOpacity, View } from 'react-native'; import styled from '@emotion/native'; +import { LinearGradient } from 'expo-linear-gradient'; import { theme } from '@/styles/theme'; interface ModalProps { @@ -38,6 +39,11 @@ export default function Modal({ {message} + {cancelText} + {loading ? '저장 중...' : confirmText} @@ -101,16 +112,24 @@ const ModalButton = styled(TouchableOpacity)<{ flex: 1; padding: 12px 16px; border-radius: 8px; - background-color: ${({ isPrimary, disabled }) => { - if (disabled) return theme.colors.gray[300]; - return isPrimary ? theme.colors.primary[500] : theme.colors.gray[100]; - }}; align-items: center; + position: relative; + overflow: hidden; opacity: ${({ disabled }) => (disabled ? 0.6 : 1)}; `; +const ModalButtonGradient = styled(LinearGradient)` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 8px; +`; + const ModalButtonText = styled(Text)<{ isPrimary?: boolean }>` font-size: 16px; font-weight: 500; - color: ${({ isPrimary }) => (isPrimary ? '#ffffff' : theme.colors.gray[700])}; + color: #ffffff; + z-index: 1; `; diff --git a/src/components/TabNavigation.tsx b/src/components/TabNavigation.tsx index 870d76d..78c625f 100644 --- a/src/components/TabNavigation.tsx +++ b/src/components/TabNavigation.tsx @@ -46,7 +46,7 @@ const TabButton = styled.TouchableOpacity<{ alignItems: 'center', paddingVertical: 8, borderBottomWidth: isActive ? 2 : 0, - borderBottomColor: '#ff6b35', + borderBottomColor: '#2d2d2d', })); const TabText = styled.Text<{ @@ -54,5 +54,5 @@ const TabText = styled.Text<{ }>(({ isActive }) => ({ fontSize: 14, fontWeight: isActive ? '600' : '400', - color: isActive ? '#ff6b35' : '#666666', + color: isActive ? '#2d2d2d' : '#666666', })); diff --git a/src/constants/colors.ts b/src/constants/colors.ts index a474d61..6d81e1b 100644 --- a/src/constants/colors.ts +++ b/src/constants/colors.ts @@ -10,64 +10,64 @@ export type ColorKey = export const COLOR_TOKENS: Record> = { primary: { - 50: '#eff6ff', - 100: '#dbeafe', - 200: '#bfdbfe', - 300: '#93c5fd', - 400: '#60a5fa', - 500: '#3b82f6', - 600: '#2563eb', - 700: '#1d4ed8', - 800: '#1e40af', - 900: '#1e3a8a', + 50: '#f9fafb', + 100: '#f3f4f6', + 200: '#e5e7eb', + 300: '#d1d5db', + 400: '#9ca3af', + 500: '#6b7280', + 600: '#4b5563', + 700: '#374151', + 800: '#1f2937', + 900: '#111827', }, secondary: { - 50: '#f5f3ff', - 100: '#ede9fe', - 200: '#ddd6fe', - 300: '#c4b5fd', - 400: '#a78bfa', - 500: '#8b5cf6', - 600: '#7c3aed', - 700: '#6d28d9', - 800: '#5b21b6', - 900: '#4c1d95', + 50: '#f9fafb', + 100: '#f3f4f6', + 200: '#e5e7eb', + 300: '#d1d5db', + 400: '#9ca3af', + 500: '#6b7280', + 600: '#4b5563', + 700: '#374151', + 800: '#1f2937', + 900: '#111827', }, success: { - 50: '#ecfdf5', - 100: '#d1fae5', - 200: '#a7f3d0', - 300: '#6ee7b7', - 400: '#34d399', - 500: '#10b981', - 600: '#059669', - 700: '#047857', - 800: '#065f46', - 900: '#064e3b', + 50: '#f9fafb', + 100: '#f3f4f6', + 200: '#e5e7eb', + 300: '#d1d5db', + 400: '#9ca3af', + 500: '#6b7280', + 600: '#4b5563', + 700: '#374151', + 800: '#1f2937', + 900: '#111827', }, warning: { - 50: '#fffbeb', - 100: '#fef3c7', - 200: '#fde68a', - 300: '#fcd34d', - 400: '#fbbf24', - 500: '#f59e0b', - 600: '#d97706', - 700: '#b45309', - 800: '#92400e', - 900: '#78350f', + 50: '#f9fafb', + 100: '#f3f4f6', + 200: '#e5e7eb', + 300: '#d1d5db', + 400: '#9ca3af', + 500: '#6b7280', + 600: '#4b5563', + 700: '#374151', + 800: '#1f2937', + 900: '#111827', }, error: { - 50: '#fef2f2', - 100: '#fee2e2', - 200: '#fecaca', - 300: '#fca5a5', - 400: '#f87171', - 500: '#ef4444', - 600: '#dc2626', - 700: '#b91c1c', - 800: '#991b1b', - 900: '#7f1d1d', + 50: '#f9fafb', + 100: '#f3f4f6', + 200: '#e5e7eb', + 300: '#d1d5db', + 400: '#9ca3af', + 500: '#6b7280', + 600: '#4b5563', + 700: '#374151', + 800: '#1f2937', + 900: '#111827', }, gray: { 50: '#f9fafb', diff --git a/src/navigation/RunTabNavigator.tsx b/src/navigation/RunTabNavigator.tsx index 2cc3a4d..fd32322 100644 --- a/src/navigation/RunTabNavigator.tsx +++ b/src/navigation/RunTabNavigator.tsx @@ -1,10 +1,15 @@ -import { useState } from 'react'; -import { View } from 'react-native'; +import { useState, useRef, useCallback } from 'react'; +import { View, Animated } from 'react-native'; import styled from '@emotion/native'; import { Play } from 'lucide-react-native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { useFocusEffect } from '@react-navigation/native'; +import { LinearGradient } from 'expo-linear-gradient'; +import Mapbox from '@rnmapbox/maps'; import TabNavigation from '@/components/TabNavigation'; import Header from '@/components/Header'; +import { useLocationManager } from '@/hooks/useLocationManager'; +import { useLongPress } from '@/hooks/useLongPress'; import Route from '@/pages/Route'; import Draw from '@/pages/Draw'; @@ -24,31 +29,90 @@ const tabs: Array<{ id: RunTabId; title: string }> = [ // 바로가기 메인 컴포넌트 function QuickStartMain({ navigation }: { navigation: any }) { const [activeTab, setActiveTab] = useState('quickstart'); + const mapRef = useRef(null); + const cameraRef = useRef(null); + + const { + initialLocation, + locationLoading, + flyToCurrentUserLocation, + handleUserLocationUpdate, + refreshLocation, + } = useLocationManager(); + + // 탭이 포커스될 때마다 위치 새로고침 + useFocusEffect( + useCallback(() => { + if (!initialLocation && !locationLoading) { + refreshLocation(); + } + }, [initialLocation, locationLoading, refreshLocation]), + ); const handleStartPress = () => { navigation.navigate('Run', {}); }; + const { isPressing, pressProgress, animatedValue, startPress, stopPress } = + useLongPress({ + onComplete: handleStartPress, + }); + const handleTabPress = (tabId: RunTabId) => { setActiveTab(tabId); }; - const handleCourseStart = (courseId: number) => { - navigation.navigate('Run', { courseId }); - }; - const renderContent = () => { if (activeTab === 'quickstart') { return ( - - - 러닝 시작 - + {initialLocation && ( + + + + + )} + + + + + + + + {isPressing ? '러닝 시작 중...' : '러닝 시작'} + + + + ); } else { - return ; + return ; } }; @@ -84,24 +148,52 @@ const Container = styled.View({ const QuickStartContainer = styled.View({ flex: 1, + position: 'relative', +}); + +const MapView = styled(Mapbox.MapView)({ + flex: 1, +}); + +const StartButtonContainer = styled.View({ + position: 'absolute', + bottom: 100, + left: 20, + right: 20, +}); + +const StartButton = styled.TouchableOpacity({ + height: 56, + borderRadius: 8, justifyContent: 'center', alignItems: 'center', - backgroundColor: '#f8f9fa', + position: 'relative', + overflow: 'hidden', }); -const StartButton = styled.TouchableOpacity({ - backgroundColor: '#ff6b35', - paddingHorizontal: 48, - paddingVertical: 24, - borderRadius: 16, +const StartButtonGradient = styled(LinearGradient)({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 8, +}); + +const StartButtonProgress = styled(Animated.View)({ + position: 'absolute', + top: 0, + left: 0, + height: '100%', + backgroundColor: '#4ECDC4', + borderRadius: 8, +}); + +const StartButtonContent = styled.View({ flexDirection: 'row', alignItems: 'center', - gap: 12, - elevation: 4, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.25, - shadowRadius: 4, + gap: 8, + zIndex: 1, }); const PlayIcon = styled(Play)({ @@ -110,6 +202,6 @@ const PlayIcon = styled(Play)({ const StartButtonText = styled.Text({ color: '#ffffff', - fontSize: 18, - fontWeight: 'bold', + fontSize: 16, + fontWeight: '600', }); diff --git a/src/pages/Auth/index.tsx b/src/pages/Auth/index.tsx index 93af1e7..fee10f0 100644 --- a/src/pages/Auth/index.tsx +++ b/src/pages/Auth/index.tsx @@ -11,6 +11,7 @@ import { initializeGoogleSignIn, signInWithGoogle, } from '@/services/auth.service'; +import { useLocationTracking } from '@/hooks/useLocationTracking'; import type { RootStackParamList } from '@/types/navigation.types'; export default function Auth() { @@ -19,6 +20,9 @@ export default function Auth() { const navigation = useNavigation>(); + // 로그인 시 위치 미리 받아오기 + const { refreshLocation } = useLocationTracking(); + useEffect(() => { initializeGoogleSignIn(); }, []); @@ -30,6 +34,11 @@ export default function Auth() { const { accessToken, user } = await signInWithGoogle(); setAuth(accessToken, user); + + // 로그인 성공 후 위치 미리 받아오기 + console.log('📍 로그인 성공! 위치 미리 받아오기 시작...'); + refreshLocation(); + // navigation.reset({ index: 0, routes: [{ name: 'TabNavigator' }] }); } catch (error: unknown) { console.error('로그인 오류:', error); @@ -54,7 +63,7 @@ export default function Auth() { } finally { setIsSubmitting(false); } - }, [isSubmitting, setAuth, navigation]); + }, [isSubmitting, setAuth, navigation, refreshLocation]); return ( diff --git a/src/pages/Detail/index.tsx b/src/pages/Detail/index.tsx index 6d2bf1f..9cc8d6b 100644 --- a/src/pages/Detail/index.tsx +++ b/src/pages/Detail/index.tsx @@ -13,6 +13,7 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { BottomTabScreenProps } from '@react-navigation/bottom-tabs'; import { Share, ArrowLeft, Edit3 } from 'lucide-react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { LinearGradient } from 'expo-linear-gradient'; import styled from '@emotion/native'; import Header from '@/components/Header'; @@ -94,7 +95,7 @@ export default function Detail({ route, navigation }: Props) { title="경로 상세" /> - + 경로 정보를 불러오는 중... @@ -164,7 +165,7 @@ export default function Detail({ route, navigation }: Props) { )} {imageLoading && imageUrl && !imageError && ( - + )} @@ -248,7 +249,7 @@ export default function Detail({ route, navigation }: Props) { - + + diff --git a/src/pages/Route/_components/RouteGrid.tsx b/src/pages/Route/_components/RouteGrid.tsx index 8dd6922..81e838b 100644 --- a/src/pages/Route/_components/RouteGrid.tsx +++ b/src/pages/Route/_components/RouteGrid.tsx @@ -8,7 +8,6 @@ import { View, } from 'react-native'; import { useCallback } from 'react'; -import { Play } from 'lucide-react-native'; import Card from '@/components/Card'; import useRouteStore from '@/store/route'; import { @@ -24,13 +23,9 @@ import { interface RouteGridProps { onRouteCardPress: (courseId: number) => void; - onStartRun: (courseId: number) => void; } -export default function RouteGrid({ - onRouteCardPress, - onStartRun, -}: RouteGridProps) { +export default function RouteGrid({ onRouteCardPress }: RouteGridProps) { const { activeTab, courses, @@ -107,10 +102,6 @@ export default function RouteGrid({ mode="only-image" onPress={() => onRouteCardPress(completedItem.id)} /> - onStartRun(completedItem.id)}> - - 러닝 시작 - ); } else if (activeTab === 'liked') { @@ -123,10 +114,6 @@ export default function RouteGrid({ mode="only-image" onPress={() => onRouteCardPress(bookmarkedItem.id)} /> - onStartRun(bookmarkedItem.id)}> - - 러닝 시작 - ); } else { @@ -139,15 +126,11 @@ export default function RouteGrid({ mode="only-image" onPress={() => onRouteCardPress(courseItem.id)} /> - onStartRun(courseItem.id)}> - - 러닝 시작 - ); } }, - [activeTab, onRouteCardPress, onStartRun], + [activeTab, onRouteCardPress], ); const currentData = getCurrentData(); @@ -216,15 +199,15 @@ export default function RouteGrid({ } ListFooterComponent={() => ( <> {loading && ( - + )} {error && currentData.length > 0 && ( @@ -274,7 +257,7 @@ const ErrorText = styled.Text({ }); const RetryButton = styled(TouchableOpacity)({ - backgroundColor: '#007AFF', + backgroundColor: '#2d2d2d', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8, @@ -290,25 +273,3 @@ const CardContainer = styled.View({ flex: 1, marginBottom: 16, }); - -const RunButton = styled.TouchableOpacity({ - backgroundColor: '#ff6b35', - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 8, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 4, - marginTop: 8, -}); - -const PlayIcon = styled(Play)({ - // styled component for Play icon -}); - -const RunButtonText = styled.Text({ - color: '#ffffff', - fontSize: 12, - fontWeight: '600', -}); diff --git a/src/pages/Route/index.tsx b/src/pages/Route/index.tsx index efd5085..e9b7a9c 100644 --- a/src/pages/Route/index.tsx +++ b/src/pages/Route/index.tsx @@ -25,7 +25,6 @@ import { type Props = { navigation: any; - onStartRun?: (courseId: number) => void; }; const tabs: Array<{ id: RouteTabId; title: string }> = [ @@ -34,7 +33,7 @@ const tabs: Array<{ id: RouteTabId; title: string }> = [ { id: 'liked', title: '좋아요한 경로' }, ]; -export default function Route({ navigation, onStartRun }: Props) { +export default function Route({ navigation }: Props) { const { activeTab, setActiveTab, @@ -75,12 +74,6 @@ export default function Route({ navigation, onStartRun }: Props) { navigation.navigate('Draw', undefined); }; - const handleStartRun = (courseId: number) => { - if (onStartRun) { - onStartRun(courseId); - } - }; - const handleTabPress = (tabId: RouteTabId) => { setActiveTab(tabId); @@ -101,10 +94,7 @@ export default function Route({ navigation, onStartRun }: Props) { activeTab={activeTab} onTabPress={handleTabPress} /> - + ); diff --git a/src/pages/Run/_components/ControlContainer.tsx b/src/pages/Run/_components/ControlContainer.tsx index 63a3e78..cc780e4 100644 --- a/src/pages/Run/_components/ControlContainer.tsx +++ b/src/pages/Run/_components/ControlContainer.tsx @@ -133,7 +133,7 @@ const ControlButton = styled.TouchableOpacity<{ width: 48, height: 48, borderRadius: 24, - backgroundColor: isPrimary ? theme.colors.primary[500] : 'transparent', + backgroundColor: isPrimary ? '#2d2d2d' : 'transparent', justifyContent: 'center', alignItems: 'center', marginHorizontal: 4, diff --git a/src/pages/Run/_components/RunMap.tsx b/src/pages/Run/_components/RunMap.tsx index aff08d2..af40973 100644 --- a/src/pages/Run/_components/RunMap.tsx +++ b/src/pages/Run/_components/RunMap.tsx @@ -54,7 +54,7 @@ function RunMap({ + 달린 시간 {localStats.runningTime} @@ -149,7 +155,6 @@ const StatLabel = styled.Text(({ theme }) => ({ })); const RunningTimeContainer = styled.View(({ theme }) => ({ - backgroundColor: theme.colors.primary[500], borderRadius: 12, paddingVertical: 16, paddingHorizontal: 20, @@ -157,18 +162,31 @@ const RunningTimeContainer = styled.View(({ theme }) => ({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', + position: 'relative', + overflow: 'hidden', })); +const RunningTimeGradient = styled(LinearGradient)({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 12, +}); + const RunningTimeLabel = styled.Text({ fontSize: 16, fontWeight: '600', color: '#ffffff', + zIndex: 1, }); const RunningTimeValue = styled.Text({ fontSize: 24, fontWeight: '700', color: '#ffffff', + zIndex: 1, }); export default React.memo(StatsContainer); diff --git a/src/pages/Run/index.tsx b/src/pages/Run/index.tsx index 71a9e18..9b49786 100644 --- a/src/pages/Run/index.tsx +++ b/src/pages/Run/index.tsx @@ -197,7 +197,7 @@ export default function Run({ route, navigation }: Props) { 위치를 가져올 수 없습니다 - 새로고침 + 새로고침 )} @@ -277,8 +277,8 @@ const DeviationAlert = styled.View<{ severity: 'low' | 'medium' | 'high' }>( severity === 'high' ? '#ef4444' : severity === 'medium' - ? '#f59e0b' - : '#3b82f6', + ? '#f87171' + : '#fca5a5', paddingHorizontal: 20, paddingVertical: 16, borderRadius: 12, @@ -314,5 +314,5 @@ const RefreshButton = styled.TouchableOpacity({ padding: 12, borderRadius: 8, borderWidth: 1, - borderColor: '#007AFF', + borderColor: '#2d2d2d', }); From 6907c9a9aea31f608be66625889226ab01f2ee3e Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 04:51:53 +0900 Subject: [PATCH 03/24] =?UTF-8?q?style:=20FloatingButton=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=9D=98=20=EB=B0=B0=EA=B2=BD=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LinearGradient 제거 및 ButtonContainer에 배경색 '#ff6b35' 추가 - 불필요한 코드 및 주석 정리 --- src/components/FloatingButton.tsx | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/components/FloatingButton.tsx b/src/components/FloatingButton.tsx index 1e1b683..d09923a 100644 --- a/src/components/FloatingButton.tsx +++ b/src/components/FloatingButton.tsx @@ -2,7 +2,6 @@ import styled from '@emotion/native'; import { LucideIcon } from 'lucide-react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ViewStyle } from 'react-native'; -import { LinearGradient } from 'expo-linear-gradient'; interface FloatingButtonProps { icon: LucideIcon; @@ -21,24 +20,12 @@ export default function FloatingButton({ }: FloatingButtonProps) { const insets = useSafeAreaInsets(); - // style prop에서 backgroundColor가 있는지 확인 - const hasCustomBackground = Array.isArray(style) - ? style.some((s) => s && typeof s === 'object' && 'backgroundColor' in s) - : style && typeof style === 'object' && 'backgroundColor' in style; - return ( - {!hasCustomBackground && ( - - )} ); @@ -49,6 +36,7 @@ const ButtonContainer = styled.TouchableOpacity({ right: 20, width: 56, height: 56, + backgroundColor: '#ff6b35', borderRadius: 28, justifyContent: 'center', alignItems: 'center', @@ -58,14 +46,4 @@ const ButtonContainer = styled.TouchableOpacity({ shadowOpacity: 0.3, shadowRadius: 8, zIndex: 1000, - overflow: 'hidden', -}); - -const ButtonGradient = styled(LinearGradient)({ - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - borderRadius: 28, }); From 79f697a4d39ba63b3f46245c00a9242c7592e26c Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 04:52:47 +0900 Subject: [PATCH 04/24] =?UTF-8?q?fix:=20useRunModals=EC=97=90=EC=84=9C=20n?= =?UTF-8?q?avigation=20=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - navigation prop의 타입을 'Run'에서 'RunTab'으로 변경하여 정확한 네비게이션 경로 반영 --- src/hooks/useRunModals.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useRunModals.ts b/src/hooks/useRunModals.ts index 5970ff8..c8b6bae 100644 --- a/src/hooks/useRunModals.ts +++ b/src/hooks/useRunModals.ts @@ -14,7 +14,7 @@ import type { RunningRecordRequest } from '@/types/run.types'; import type { AxiosErrorResponse } from '@/types/api.types'; type Props = { - navigation: NativeStackNavigationProp; + navigation: NativeStackNavigationProp; mapRef?: RefObject; cameraRef?: RefObject; courseId?: number; From 79338bace21d3398a539d9343990cf9903b56b4e Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 05:22:00 +0900 Subject: [PATCH 05/24] =?UTF-8?q?feat:=20=EC=B6=94=EC=B2=9C=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Card 컴포넌트에 variant prop 추가하여 스타일 조정 - useRouteApi에 주변 경로 검색 기능 추가 - RunTabNavigator에서 추천 경로 로드 및 표시 기능 구현 - RecommendationContainer 컴포넌트 추가하여 추천 경로 리스트 표시 - useLongPress 훅에서 불필요한 상태 제거 및 코드 정리 - Home 페이지에서 추천 경로 관련 코드 정리 --- src/components/Card.tsx | 90 ++++++++++------- src/hooks/api/useRouteApi.ts | 31 ++++++ src/hooks/useLongPress.ts | 17 ---- src/navigation/RunTabNavigator.tsx | 49 ++++++++-- src/pages/Home/index.tsx | 9 +- src/pages/Route/index.tsx | 1 + .../_components/RecommendationContainer.tsx | 97 +++++++++++++++++++ src/services/courses.service.ts | 18 ++++ src/types/courses.types.ts | 5 +- 9 files changed, 251 insertions(+), 66 deletions(-) create mode 100644 src/pages/Run/_components/RecommendationContainer.tsx diff --git a/src/components/Card.tsx b/src/components/Card.tsx index a029c67..0d71222 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -21,6 +21,7 @@ interface CardProps { onPress?: () => void; style?: ViewStyle; fullWidth?: boolean; + variant?: 'default' | 'light'; } export default function Card({ @@ -31,6 +32,7 @@ export default function Card({ onPress, style, fullWidth = false, + variant = 'default', }: CardProps) { const { width: screenWidth } = Dimensions.get('window'); const [imageError, setImageError] = useState(false); @@ -52,6 +54,7 @@ export default function Card({ - {content?.cardTitle && {content.cardTitle}} + {content?.cardTitle && ( + {content.cardTitle} + )} {imageSource && ( - {content.title && {content.title}} - {content.subtitle && {content.subtitle}} + {content.title && ( + {content.title} + )} + {content.subtitle && ( + {content.subtitle} + )} {content.stats && ( {content.stats.map((stat, index) => ( - {stat.label} - {stat.value} + {stat.label} + {stat.value} ))} @@ -130,6 +140,7 @@ export default function Card({ (({ fullWidth, mode }) => ({ - backgroundColor: 'rgba(255, 255, 255, 0.1)', - borderColor: 'rgba(255, 255, 255, 0.2)', + variant: 'default' | 'light'; +}>(({ fullWidth, mode, variant }) => ({ + backgroundColor: variant === 'light' ? '#ffffff' : 'rgba(255, 255, 255, 0.1)', + borderColor: + variant === 'light' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.2)', borderWidth: 1, borderRadius: 12, padding: mode === 'only-image' ? 0 : 16, @@ -153,12 +166,14 @@ const CardContainer = styled(TouchableOpacity)<{ aspectRatio: fullWidth ? undefined : 1, })); -const CardTitle = styled.Text({ - color: '#ffffff', - fontSize: 16, - fontWeight: '600', - marginBottom: 8, -}); +const CardTitle = styled.Text<{ variant?: 'default' | 'light' }>( + ({ variant }) => ({ + color: variant === 'light' ? '#000000' : '#ffffff', + fontSize: 16, + fontWeight: '600', + marginBottom: 8, + }), +); const ContentRow = styled.View({ flexDirection: 'row', @@ -185,18 +200,21 @@ const CardContentContainer = styled.View({ flex: 1, }); -const Title = styled.Text({ - color: '#ffffff', +const Title = styled.Text<{ variant?: 'default' | 'light' }>(({ variant }) => ({ + color: variant === 'light' ? '#000000' : '#ffffff', fontSize: 18, fontWeight: 'bold', marginBottom: 4, -}); +})); -const Subtitle = styled.Text({ - color: 'rgba(255, 255, 255, 0.7)', - fontSize: 14, - marginBottom: 8, -}); +const Subtitle = styled.Text<{ variant?: 'default' | 'light' }>( + ({ variant }) => ({ + color: + variant === 'light' ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)', + fontSize: 14, + marginBottom: 8, + }), +); const StatsContainer = styled.View({ flexDirection: 'row', @@ -207,17 +225,23 @@ const StatItem = styled.View({ alignItems: 'center', }); -const StatLabel = styled.Text({ - color: 'rgba(255, 255, 255, 0.8)', - fontSize: 12, - marginBottom: 2, -}); +const StatLabel = styled.Text<{ variant?: 'default' | 'light' }>( + ({ variant }) => ({ + color: + variant === 'light' ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.8)', + fontSize: 12, + marginBottom: 2, + }), +); -const StatValue = styled.Text({ - color: 'rgba(255, 255, 255, 0.8)', - fontSize: 14, - fontWeight: '600', -}); +const StatValue = styled.Text<{ variant?: 'default' | 'light' }>( + ({ variant }) => ({ + color: + variant === 'light' ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.8)', + fontSize: 14, + fontWeight: '600', + }), +); const StarIcon = styled(Star)({ position: 'absolute', diff --git a/src/hooks/api/useRouteApi.ts b/src/hooks/api/useRouteApi.ts index a24cc3e..3abea5d 100644 --- a/src/hooks/api/useRouteApi.ts +++ b/src/hooks/api/useRouteApi.ts @@ -3,10 +3,12 @@ import { searchUserCourses, searchBookmarkedCourses, searchCompletedCourses, + searchAdjacentCourses, } from '@/services/courses.service'; import useAuthStore from '@/store/auth'; import useRouteStore from '@/store/route'; import type { AxiosErrorResponse } from '@/types/api.types'; +import type { Position } from 'geojson'; export function useRouteData() { const { accessToken } = useAuthStore(); @@ -346,3 +348,32 @@ export function useCompletedCourses() { handleRefresh, }; } + +export function useAdjacentCourses() { + const { accessToken } = useAuthStore(); + + const searchAdjacent = useCallback( + async (location: Position, radius: number = 1000) => { + if (!accessToken) return []; + + try { + const locationString = `${location[0]},${location[1]}`; + const response = await searchAdjacentCourses( + { + location: locationString, + radius, + limit: 3, + }, + accessToken, + ); + return response.results; + } catch (error) { + console.error('주변 경로 검색 실패:', error); + return []; + } + }, + [accessToken], + ); + + return { searchAdjacent }; +} diff --git a/src/hooks/useLongPress.ts b/src/hooks/useLongPress.ts index ef6f4cc..fc1eba5 100644 --- a/src/hooks/useLongPress.ts +++ b/src/hooks/useLongPress.ts @@ -11,15 +11,12 @@ export function useLongPress({ onComplete, }: UseLongPressOptions) { const [isPressing, setIsPressing] = useState(false); - const [pressProgress, setPressProgress] = useState(0); const pressTimer = useRef(null); - const progressTimer = useRef(null); const animatedValue = useRef(new Animated.Value(0)).current; const startPress = useCallback(() => { setIsPressing(true); - setPressProgress(0); pressTimer.current = setTimeout(() => { onComplete(); @@ -31,29 +28,16 @@ export function useLongPress({ duration, useNativeDriver: false, }).start(); - - progressTimer.current = setInterval(() => { - setPressProgress((prev) => { - const newProgress = prev + 100 / (duration / 100); - return newProgress >= 100 ? 100 : newProgress; - }); - }, 100); }, [duration, onComplete, animatedValue]); const stopPress = useCallback(() => { setIsPressing(false); - setPressProgress(0); if (pressTimer.current) { clearTimeout(pressTimer.current); pressTimer.current = null; } - if (progressTimer.current) { - clearInterval(progressTimer.current); - progressTimer.current = null; - } - Animated.timing(animatedValue, { toValue: 0, duration: 200, @@ -63,7 +47,6 @@ export function useLongPress({ return { isPressing, - pressProgress, animatedValue, startPress, stopPress, diff --git a/src/navigation/RunTabNavigator.tsx b/src/navigation/RunTabNavigator.tsx index fd32322..4bf009d 100644 --- a/src/navigation/RunTabNavigator.tsx +++ b/src/navigation/RunTabNavigator.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { View, Animated } from 'react-native'; import styled from '@emotion/native'; import { Play } from 'lucide-react-native'; @@ -10,12 +10,15 @@ import TabNavigation from '@/components/TabNavigation'; import Header from '@/components/Header'; import { useLocationManager } from '@/hooks/useLocationManager'; import { useLongPress } from '@/hooks/useLongPress'; +import { useAdjacentCourses } from '@/hooks/api/useRouteApi'; +import type { CourseSearchItem } from '@/types/courses.types'; import Route from '@/pages/Route'; import Draw from '@/pages/Draw'; import RouteSave from '@/pages/RouteSave'; import Detail from '@/pages/Detail'; import Run from '@/pages/Run'; +import RecommendationContainer from '@/pages/Run/_components/RecommendationContainer'; const Stack = createNativeStackNavigator(); @@ -29,6 +32,9 @@ const tabs: Array<{ id: RunTabId; title: string }> = [ // 바로가기 메인 컴포넌트 function QuickStartMain({ navigation }: { navigation: any }) { const [activeTab, setActiveTab] = useState('quickstart'); + const [recommendations, setRecommendations] = useState( + [], + ); const mapRef = useRef(null); const cameraRef = useRef(null); @@ -40,23 +46,48 @@ function QuickStartMain({ navigation }: { navigation: any }) { refreshLocation, } = useLocationManager(); - // 탭이 포커스될 때마다 위치 새로고침 + const { searchAdjacent } = useAdjacentCourses(); + + // 추천 경로 로드 함수 + const loadRecommendations = useCallback(async () => { + if (initialLocation) { + try { + const results = await searchAdjacent(initialLocation, 1000); + setRecommendations(results); + } catch (error) { + console.error('추천 경로 로드 실패:', error); + } + } + }, [initialLocation, searchAdjacent]); + + // 탭이 포커스될 때마다 위치 새로고침 및 추천 경로 로드 useFocusEffect( useCallback(() => { if (!initialLocation && !locationLoading) { refreshLocation(); } - }, [initialLocation, locationLoading, refreshLocation]), + if (initialLocation) { + loadRecommendations(); + } + }, [ + initialLocation, + locationLoading, + refreshLocation, + loadRecommendations, + ]), ); const handleStartPress = () => { navigation.navigate('Run', {}); }; - const { isPressing, pressProgress, animatedValue, startPress, stopPress } = - useLongPress({ - onComplete: handleStartPress, - }); + const handleRecommendationPress = (item: CourseSearchItem) => { + navigation.navigate('Run', { courseId: item.id }); + }; + + const { isPressing, animatedValue, startPress, stopPress } = useLongPress({ + onComplete: handleStartPress, + }); const handleTabPress = (tabId: RunTabId) => { setActiveTab(tabId); @@ -82,6 +113,10 @@ function QuickStartMain({ navigation }: { navigation: any }) { )} + { @@ -14,10 +13,6 @@ export default function Home() { console.log('알림 버튼 클릭'); }; - const handleRecommendationPress = (item: RecommendationData) => { - console.log('추천 경로 클릭:', item.title); - }; - return ( - + {/* 추천 경로는 바로가기 화면으로 이동됨 */} @@ -45,8 +40,6 @@ export default function Home() { ); } -import type { RecommendationData } from '@/types/card.types'; - const Screen = styled.View({ flex: 1, }); diff --git a/src/pages/Route/index.tsx b/src/pages/Route/index.tsx index e9b7a9c..e960170 100644 --- a/src/pages/Route/index.tsx +++ b/src/pages/Route/index.tsx @@ -25,6 +25,7 @@ import { type Props = { navigation: any; + onStartRun?: (courseId: number) => void; }; const tabs: Array<{ id: RouteTabId; title: string }> = [ diff --git a/src/pages/Run/_components/RecommendationContainer.tsx b/src/pages/Run/_components/RecommendationContainer.tsx new file mode 100644 index 0000000..c7c3f2e --- /dev/null +++ b/src/pages/Run/_components/RecommendationContainer.tsx @@ -0,0 +1,97 @@ +import styled from '@emotion/native'; +import { FlatList, Dimensions } from 'react-native'; +import { useState, useEffect } from 'react'; +import Card from '@/components/Card'; +import type { CourseSearchItem } from '@/types/courses.types'; +import { reverseGeocode } from '@/lib/geocoding'; + +const { width: screenWidth } = Dimensions.get('window'); + +interface RecommendationContainerProps { + onRecommendationPress: (item: CourseSearchItem) => void; + recommendations: CourseSearchItem[]; +} + +export default function RecommendationContainer({ + onRecommendationPress, + recommendations, +}: RecommendationContainerProps) { + const [geocodedAddresses, setGeocodedAddresses] = useState< + Record + >({}); + + // 추천 경로가 변경될 때마다 지오코딩 수행 + useEffect(() => { + const geocodeRecommendations = async () => { + const newAddresses: Record = {}; + + for (const item of recommendations) { + if (!geocodedAddresses[item.id]) { + try { + const result = await reverseGeocode([ + item.departure.lon, + item.departure.lat, + ]); + newAddresses[item.id] = result.placeName; + } catch (error) { + console.error('지오코딩 실패:', error); + newAddresses[item.id] = '알 수 없는 위치'; + } + } + } + + if (Object.keys(newAddresses).length > 0) { + setGeocodedAddresses((prev) => ({ ...prev, ...newAddresses })); + } + }; + + if (recommendations.length > 0) { + geocodeRecommendations(); + } + }, [recommendations, geocodedAddresses]); + + return ( + 0 ? 1 : 0 }} + > + {recommendations.length > 0 && ( + { + const address = geocodedAddresses[item.id] || '로딩 중...'; + return ( + onRecommendationPress(item)} + fullWidth={true} + style={{ width: screenWidth - 32, marginRight: 32 }} + /> + ); + }} + keyExtractor={(item) => item.id.toString()} + horizontal + showsHorizontalScrollIndicator={false} + pagingEnabled={true} + snapToInterval={screenWidth} + decelerationRate="fast" + /> + )} + + ); +} + +const RecommendationOverlay = styled.View({ + position: 'absolute', + top: 20, + left: 0, + right: 0, + zIndex: 1000, + paddingHorizontal: 16, +}); diff --git a/src/services/courses.service.ts b/src/services/courses.service.ts index cd57502..6273733 100644 --- a/src/services/courses.service.ts +++ b/src/services/courses.service.ts @@ -79,3 +79,21 @@ export async function searchCompletedCourses( }); return response.data; } + +export async function searchAdjacentCourses( + params: { + location: string; // "127.0,37.5" 형식 + radius: number; + limit?: number; + cursor?: { id: number; distance: number } | null; + }, + accessToken: string, +): Promise { + const response = await api.get('/api/courses/search/adjacent', { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return response.data; +} diff --git a/src/types/courses.types.ts b/src/types/courses.types.ts index 1f5b338..ca24d7f 100644 --- a/src/types/courses.types.ts +++ b/src/types/courses.types.ts @@ -43,7 +43,10 @@ export interface CourseSearchItem { length: number; time: number; createdAt: string; - author: string; + author: { + nickname: string; + imageUrl: string; + }; bookmarked: boolean; completed: boolean; } From 6f996b7ca7cb15a8a633cf5580d29fd25ec341f8 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 05:23:20 +0900 Subject: [PATCH 06/24] =?UTF-8?q?fix:=20RecommendationContainer=EC=97=90?= =?UTF-8?q?=EC=84=9C=20departure=20=EC=A2=8C=ED=91=9C=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecommendationContainer 컴포넌트에서 departure 좌표를 배열 형식으로 변경하여 reverseGeocode 함수 호출 시 올바른 인자 전달 - courses.types.ts에서 departure 타입을 RouteCoordinate에서 [number, number]로 수정 --- src/pages/Run/_components/RecommendationContainer.tsx | 4 ++-- src/types/courses.types.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/Run/_components/RecommendationContainer.tsx b/src/pages/Run/_components/RecommendationContainer.tsx index c7c3f2e..39e21d3 100644 --- a/src/pages/Run/_components/RecommendationContainer.tsx +++ b/src/pages/Run/_components/RecommendationContainer.tsx @@ -29,8 +29,8 @@ export default function RecommendationContainer({ if (!geocodedAddresses[item.id]) { try { const result = await reverseGeocode([ - item.departure.lon, - item.departure.lat, + item.departure[0], + item.departure[1], ]); newAddresses[item.id] = result.placeName; } catch (error) { diff --git a/src/types/courses.types.ts b/src/types/courses.types.ts index ca24d7f..77b2fde 100644 --- a/src/types/courses.types.ts +++ b/src/types/courses.types.ts @@ -39,7 +39,7 @@ export interface CourseSearchItem { id: number; title: string; imageUrl: string; - departure: RouteCoordinate; + departure: [number, number]; length: number; time: number; createdAt: string; From 6de1cf0498a0bace0c7308404159254512697efc Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 05:23:58 +0900 Subject: [PATCH 07/24] =?UTF-8?q?fix:=20useLongPress=20=ED=9B=85=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useLongPress 훅에서 pressProgress 상태를 제거하여 코드 간결성 향상 --- src/pages/Detail/index.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pages/Detail/index.tsx b/src/pages/Detail/index.tsx index 9cc8d6b..6417530 100644 --- a/src/pages/Detail/index.tsx +++ b/src/pages/Detail/index.tsx @@ -66,10 +66,9 @@ export default function Detail({ route, navigation }: Props) { navigation.navigate('Run', { courseId }); }; - const { isPressing, pressProgress, animatedValue, startPress, stopPress } = - useLongPress({ - onComplete: handleDrawPress, - }); + const { isPressing, animatedValue, startPress, stopPress } = useLongPress({ + onComplete: handleDrawPress, + }); const { shareCourse } = useShare({ courseId, courseData }); useEffect(() => { From 2762037cf5fd3b310bcbfb3d85d333108625e829 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 05:29:38 +0900 Subject: [PATCH 08/24] =?UTF-8?q?feat:=20useRunModals=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20=EC=9D=B4=EB=8F=99=20=EA=B1=B0=EB=A6=AC=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EB=B0=8F=20API=20=EC=9D=91=EB=8B=B5=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 코스 선택 시 최소 이동 거리 체크 로직 추가하여 유효성 검사 강화 - 런닝 기록 저장 요청 및 응답에 대한 로그 추가로 디버깅 용이성 향상 - API 응답 상태코드 및 데이터 로그 추가하여 오류 처리 개선 --- src/hooks/useRunModals.ts | 41 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/hooks/useRunModals.ts b/src/hooks/useRunModals.ts index c8b6bae..518c790 100644 --- a/src/hooks/useRunModals.ts +++ b/src/hooks/useRunModals.ts @@ -82,6 +82,18 @@ export function useRunModals({ return; } + // 코스 선택 시 최소 이동 거리 체크 + if (courseId && routeCoordinates.length < 2) { + Toast.show({ + type: 'error', + text1: '저장 불가', + text2: + '선택한 코스와 실제 러닝 경로가 많이 다릅니다. 코스를 따라 달려보세요.', + }); + cleanupAndGoBack(); + return; + } + try { setUI({ savingRecord: true }); setError('save', null); @@ -131,7 +143,20 @@ export function useRunModals({ imageUrl, }; - await saveRunningRecord(runningRecord, courseId); + console.log('📤 [useRunModals] 런닝 기록 저장 요청 페이로드:', { + runningRecord, + courseId, + pathLength: path.length, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + pace: paceValue, + calories: stats.calories, + imageUrl, + }); + + const response = await saveRunningRecord(runningRecord, courseId); + + console.log('📥 [useRunModals] 런닝 기록 저장 응답:', response); Toast.show({ type: 'success', @@ -143,10 +168,20 @@ export function useRunModals({ } catch (error: unknown) { let errorMessage = '런닝 기록 저장에 실패했습니다.'; + // API 응답 로그 추가 + console.error('🚨 [useRunModals] 런닝 기록 저장 실패:', error); + if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as AxiosErrorResponse; const status = axiosError.status; + console.error('🚨 [useRunModals] API 응답 상태코드:', status); + console.error('🚨 [useRunModals] API 응답 데이터:', axiosError.data); + console.error( + '🚨 [useRunModals] API 응답 상태텍스트:', + axiosError.statusText, + ); + if (status === 400) { const errorData = axiosError.data; if (errorData?.message) { @@ -156,10 +191,14 @@ export function useRunModals({ } } else if (status === 401) { errorMessage = '로그인이 필요합니다.'; + } else if (status === 409) { + errorMessage = + '선택한 코스와 실제 러닝 경로가 많이 다릅니다. 코스를 따라 달려보세요.'; } else if (status === 500) { errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; } } else if (error instanceof Error) { + console.error('🚨 [useRunModals] 일반 에러:', error.message); errorMessage = error.message; } From ddfa5cb48ccc3a02cf37066a431826e9f48ad1aa Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 07:21:30 +0900 Subject: [PATCH 09/24] =?UTF-8?q?style:=20UI=20=EC=9A=94=EC=86=8C=EC=9D=98?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=20=EB=B0=8F=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RunTabNavigator에서 StartButtonContainer의 위치를 아래로 20px 조정 - TabNavigator에서 탭 바 높이를 8px 증가시키고 배경색을 반투명으로 변경 - TabNavigator의 스타일에 패딩 및 테두리 추가 - Route 페이지의 FloatingButton에 기본 스타일 적용 - RecommendationContainer에서 카드의 서브타이틀 형식 수정 --- src/navigation/RunTabNavigator.tsx | 2 +- src/navigation/TabNavigator.tsx | 13 +++++++++++-- src/pages/Route/index.tsx | 18 +++++++++++++++++- .../_components/RecommendationContainer.tsx | 3 +-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/navigation/RunTabNavigator.tsx b/src/navigation/RunTabNavigator.tsx index 4bf009d..5bb4e53 100644 --- a/src/navigation/RunTabNavigator.tsx +++ b/src/navigation/RunTabNavigator.tsx @@ -192,7 +192,7 @@ const MapView = styled(Mapbox.MapView)({ const StartButtonContainer = styled.View({ position: 'absolute', - bottom: 100, + bottom: 120, left: 20, right: 20, }); diff --git a/src/navigation/TabNavigator.tsx b/src/navigation/TabNavigator.tsx index e8a9fd1..fbf09d6 100644 --- a/src/navigation/TabNavigator.tsx +++ b/src/navigation/TabNavigator.tsx @@ -1,6 +1,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { getFocusedRouteNameFromRoute } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { View } from 'react-native'; import { Star, FileText, Play, Laugh, Settings } from 'lucide-react-native'; import WebCommunity from '@/pages/WebCommunity'; @@ -14,13 +15,13 @@ import WebMyPage from '@/pages/WebMyPage'; const Tab = createBottomTabNavigator(); -const TAB_BAR_HEIGHT = 60; +const TAB_BAR_HEIGHT = 68; export default function TabNavigator() { const insets = useSafeAreaInsets(); const baseTabBarStyle = { - backgroundColor: 'transparent', + backgroundColor: 'rgba(255, 255, 255, 0.2)', borderTopWidth: 0, elevation: 0, shadowOpacity: 0, @@ -30,6 +31,14 @@ export default function TabNavigator() { right: 0, height: TAB_BAR_HEIGHT + insets.bottom, paddingBottom: insets.bottom, + paddingTop: 8, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.6)', + shadowColor: '#000', + shadowOffset: { width: 0, height: -2 }, + shadowRadius: 8, }; return ( diff --git a/src/pages/Route/index.tsx b/src/pages/Route/index.tsx index e960170..3a0fcc5 100644 --- a/src/pages/Route/index.tsx +++ b/src/pages/Route/index.tsx @@ -22,6 +22,7 @@ import { useBookmarkedCourses, useCompletedCourses, } from '@/hooks/api/useRouteApi'; +import { theme } from '@/styles/theme'; type Props = { navigation: any; @@ -96,7 +97,11 @@ export default function Route({ navigation }: Props) { onTabPress={handleTabPress} /> - + ); } @@ -105,3 +110,14 @@ const Screen = styled.View({ flex: 1, backgroundColor: '#ffffff', }); + +const styles = { + defaultButton: { + backgroundColor: '#111827', + }, + activeButton: { + backgroundColor: theme.colors.secondary[500], + elevation: 12, + shadowOpacity: 0.5, + }, +}; diff --git a/src/pages/Run/_components/RecommendationContainer.tsx b/src/pages/Run/_components/RecommendationContainer.tsx index 39e21d3..6b6a5fd 100644 --- a/src/pages/Run/_components/RecommendationContainer.tsx +++ b/src/pages/Run/_components/RecommendationContainer.tsx @@ -63,9 +63,8 @@ export default function RecommendationContainer({ Date: Sun, 14 Sep 2025 07:25:56 +0900 Subject: [PATCH 10/24] =?UTF-8?q?style:=20RunTabNavigator=20=EB=B0=8F=20De?= =?UTF-8?q?tail=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=9D=98=20=EB=B0=B0?= =?UTF-8?q?=EA=B2=BD=EC=83=89=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RunTabNavigator의 StartButtonProgress와 Detail 페이지의 DrawButtonProgress 배경색을 '#4ECDC4'에서 '#000000'으로 변경하여 UI 일관성 향상 --- src/navigation/RunTabNavigator.tsx | 2 +- src/pages/Detail/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/navigation/RunTabNavigator.tsx b/src/navigation/RunTabNavigator.tsx index 5bb4e53..7cf826b 100644 --- a/src/navigation/RunTabNavigator.tsx +++ b/src/navigation/RunTabNavigator.tsx @@ -220,7 +220,7 @@ const StartButtonProgress = styled(Animated.View)({ top: 0, left: 0, height: '100%', - backgroundColor: '#4ECDC4', + backgroundColor: '#000000', borderRadius: 8, }); diff --git a/src/pages/Detail/index.tsx b/src/pages/Detail/index.tsx index 6417530..078a846 100644 --- a/src/pages/Detail/index.tsx +++ b/src/pages/Detail/index.tsx @@ -418,7 +418,7 @@ const DrawButtonProgress = styled(Animated.View)({ top: 0, left: 0, height: '100%', - backgroundColor: '#4ECDC4', + backgroundColor: '#000000', borderRadius: 8, }); From 328fc6d50cdea524505dd6446da014e11feffe83 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 08:27:10 +0900 Subject: [PATCH 11/24] =?UTF-8?q?feat:=20=EB=9F=B0=EB=8B=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=8F=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?API=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useRunningRecords 훅을 추가하여 런닝 기록을 로드하고 관리하는 기능 구현 - useRunningDashboard 훅을 추가하여 런닝 대시보드를 로드하는 기능 구현 - Records 페이지에 시간 범위 선택기, 통계 표시 및 기록 목록 컴포넌트 추가 - RecordsList 및 StatsDisplay 컴포넌트 생성하여 UI 구성 - 시간 범위 계산 및 표시를 위한 유틸리티 함수 추가 - API 호출을 위한 서비스 함수(searchRunningRecords, getRunningDashboard) 추가 - 관련 타입 정의(records.types.ts) 추가하여 타입 안정성 향상 --- src/hooks/api/useRecordsApi.ts | 294 ++++++++++++++++++ src/pages/Records/_components/RecordsList.tsx | 160 ++++++++++ .../Records/_components/StatsDisplay.tsx | 94 ++++++ .../Records/_components/TimeRangeSelector.tsx | 197 ++++++++++++ src/pages/Records/index.tsx | 165 +++++++++- src/services/running.service.ts | 32 ++ src/types/records.types.ts | 34 ++ src/utils/formatters.ts | 16 +- 8 files changed, 971 insertions(+), 21 deletions(-) create mode 100644 src/hooks/api/useRecordsApi.ts create mode 100644 src/pages/Records/_components/RecordsList.tsx create mode 100644 src/pages/Records/_components/StatsDisplay.tsx create mode 100644 src/pages/Records/_components/TimeRangeSelector.tsx create mode 100644 src/types/records.types.ts diff --git a/src/hooks/api/useRecordsApi.ts b/src/hooks/api/useRecordsApi.ts new file mode 100644 index 0000000..f9e02f1 --- /dev/null +++ b/src/hooks/api/useRecordsApi.ts @@ -0,0 +1,294 @@ +import { useCallback, useState, useEffect } from 'react'; +import { + searchRunningRecords, + getRunningDashboard, +} from '@/services/running.service'; +import useAuthStore from '@/store/auth'; +import type { AxiosErrorResponse } from '@/types/api.types'; +import type { + RunningRecord, + RunningRecordsRequest, + RunningDashboard, + RunningDashboardRequest, + TimeRange, +} from '@/types/records.types'; + +export function useRunningRecords() { + const { accessToken } = useAuthStore(); + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [cursor, setCursor] = useState<{ id: number } | null>(null); + + const loadRecords = useCallback( + async (params: RunningRecordsRequest, reset = false) => { + if (!accessToken) return; + + console.log('📊 [useRunningRecords] loadRecords 호출:', { + params, + reset, + loading, + }); + + if (loading) { + console.log('📊 [useRunningRecords] 이미 로딩 중이므로 스킵'); + return; + } + + setLoading(true); + setError(null); + + try { + const requestParams = { + ...params, + limit: 20, + }; + + // reset이 아닌 경우에만 cursor 추가 + if (!reset && params.cursor) { + requestParams.cursor = params.cursor; + } + + console.log('📊 [useRunningRecords] API 요청 파라미터:', requestParams); + const response = await searchRunningRecords(requestParams, accessToken); + + console.log('📊 [useRunningRecords] API 응답:', response); + + if (reset) { + setRecords(response.results); + } else { + setRecords((prev) => [...prev, ...response.results]); + } + + // nextCursor가 현재 데이터의 마지막 ID와 같으면 더 이상 데이터가 없음 + const lastResultId = response.results[response.results.length - 1]?.id; + const hasMoreData = + response.nextCursor && response.nextCursor.id !== lastResultId; + + console.log('📊 [useRunningRecords] 데이터 종료 조건 확인:', { + lastResultId, + nextCursor: response.nextCursor, + hasMoreData, + resultsLength: response.results.length, + }); + + if (hasMoreData) { + setCursor(response.nextCursor); + setHasMore(true); + } else { + setCursor(null); + setHasMore(false); + } + } catch (error: unknown) { + console.error('📊 [useRunningRecords] API 오류:', error); + let errorMessage = '런닝 기록을 불러오는데 실패했습니다.'; + + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as AxiosErrorResponse; + const status = axiosError.status; + + if (status === 500) { + errorMessage = + '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; + } else if (status === 401) { + errorMessage = '로그인이 필요합니다.'; + } else if (status === 403) { + errorMessage = '접근 권한이 없습니다.'; + } + } else if (error instanceof Error) { + errorMessage = error.message; + } + + setError(errorMessage); + } finally { + setLoading(false); + } + }, + [accessToken, cursor], + ); + + const handleLoadMore = useCallback(() => { + if (hasMore && !loading && !error && cursor) { + console.log('📊 [useRunningRecords] handleLoadMore 호출:', { + hasMore, + loading, + error, + cursor, + }); + loadRecords({ cursor }, false); + } + }, [hasMore, loading, error, cursor, loadRecords]); + + const handleRefresh = useCallback(async () => { + setError(null); + setCursor(null); + await loadRecords({}, true); + }, [loadRecords]); + + return { + records, + loading, + error, + hasMore, + loadRecords, + handleLoadMore, + handleRefresh, + }; +} + +export function useRunningDashboard() { + const { accessToken } = useAuthStore(); + const [dashboard, setDashboard] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadDashboard = useCallback( + async (params: RunningDashboardRequest) => { + if (!accessToken) return; + + console.log('📊 [useRunningDashboard] loadDashboard 호출:', { + params, + loading, + }); + + if (loading) { + console.log('📊 [useRunningDashboard] 이미 로딩 중이므로 스킵'); + return; + } + + setLoading(true); + setError(null); + + try { + const response = await getRunningDashboard(params, accessToken); + console.log('📊 [useRunningDashboard] API 응답:', response); + setDashboard(response); + } catch (error: unknown) { + console.error('📊 [useRunningDashboard] API 오류:', error); + let errorMessage = '런닝 통계를 불러오는데 실패했습니다.'; + + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as AxiosErrorResponse; + const status = axiosError.status; + + if (status === 500) { + errorMessage = + '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; + } else if (status === 401) { + errorMessage = '로그인이 필요합니다.'; + } else if (status === 403) { + errorMessage = '접근 권한이 없습니다.'; + } + } else if (error instanceof Error) { + errorMessage = error.message; + } + + setError(errorMessage); + } finally { + setLoading(false); + } + }, + [accessToken], + ); + + return { + dashboard, + loading, + error, + loadDashboard, + }; +} + +// 기간 범위 계산 유틸리티 +export function getTimeRangeParams( + timeRange: TimeRange, + selectedWeek?: number, + selectedMonth?: number, + selectedYear?: number, +): RunningDashboardRequest { + const now = new Date(); + + switch (timeRange) { + case 'week': { + const weekNumber = selectedWeek || 1; + const year = selectedYear || now.getFullYear(); + const month = selectedMonth || now.getMonth() + 1; + + // 해당 월의 첫 번째 날 + const firstDayOfMonth = new Date(year, month - 1, 1); + const firstDayOfWeek = firstDayOfMonth.getDay(); + + // 해당 주의 시작일 계산 + const startOfWeek = new Date(firstDayOfMonth); + startOfWeek.setDate( + firstDayOfMonth.getDate() - firstDayOfWeek + (weekNumber - 1) * 7, + ); + startOfWeek.setHours(0, 0, 0, 0); + + const endOfWeek = new Date(startOfWeek); + endOfWeek.setDate(startOfWeek.getDate() + 6); + endOfWeek.setHours(23, 59, 59, 999); + + return { + since: startOfWeek.toISOString(), + until: endOfWeek.toISOString(), + }; + } + case 'month': { + const month = selectedMonth || now.getMonth() + 1; + const year = selectedYear || now.getFullYear(); + + const startOfMonth = new Date(year, month - 1, 1); + const endOfMonth = new Date(year, month, 0, 23, 59, 59, 999); + + return { + since: startOfMonth.toISOString(), + until: endOfMonth.toISOString(), + }; + } + case 'year': { + const year = selectedYear || now.getFullYear(); + const startOfYear = new Date(year, 0, 1); + const endOfYear = new Date(year, 11, 31, 23, 59, 59, 999); + + return { + since: startOfYear.toISOString(), + until: endOfYear.toISOString(), + }; + } + case 'all': + default: + return {}; + } +} + +export function getTimeRangeDisplayText( + timeRange: TimeRange, + selectedWeek?: number, + selectedMonth?: number, + selectedYear?: number, +): string { + const now = new Date(); + + switch (timeRange) { + case 'week': { + const weekNumber = selectedWeek || 1; + const year = selectedYear || now.getFullYear(); + const month = selectedMonth || now.getMonth() + 1; + return `${year}년 ${month}월 ${weekNumber}째 주`; + } + case 'month': { + const month = selectedMonth || now.getMonth() + 1; + const year = selectedYear || now.getFullYear(); + return `${year}년 ${month}월`; + } + case 'year': { + const year = selectedYear || now.getFullYear(); + return `${year}년`; + } + case 'all': + default: + return '전체'; + } +} diff --git a/src/pages/Records/_components/RecordsList.tsx b/src/pages/Records/_components/RecordsList.tsx new file mode 100644 index 0000000..083f6b5 --- /dev/null +++ b/src/pages/Records/_components/RecordsList.tsx @@ -0,0 +1,160 @@ +import styled from '@emotion/native'; +import { + FlatList, + ActivityIndicator, + TouchableOpacity, + RefreshControl, +} from 'react-native'; +import { useCallback } from 'react'; +import type { RunningRecord } from '@/types/records.types'; + +interface RecordsListProps { + records: RunningRecord[]; + loading: boolean; + error: string | null; + hasMore: boolean; + onLoadMore: () => void; + onRefresh: () => void; + onRecordPress?: (record: RunningRecord) => void; +} + +export default function RecordsList({ + records, + loading, + error, + hasMore, + onLoadMore, + onRefresh, + onRecordPress, +}: RecordsListProps) { + const renderRecord = useCallback( + ({ item }: { item: RunningRecord }) => ( + onRecordPress?.(item)}> + + + ), + [onRecordPress], + ); + + const keyExtractor = useCallback( + (item: RunningRecord, index: number) => `${item.id}-${index}`, + [], + ); + + const handleEndReached = useCallback(() => { + console.log('📊 [RecordsList] handleEndReached 호출:', { + hasMore, + loading, + }); + if (hasMore && !loading) { + onLoadMore(); + } + }, [hasMore, loading, onLoadMore]); + + const renderFooter = useCallback(() => { + if (!loading || records.length === 0) return null; + + return ( + + + + ); + }, [loading, records.length]); + + const renderEmpty = useCallback(() => { + if (loading) return null; + + return ( + + 런닝 기록이 없습니다 + 첫 번째 런닝을 시작해보세요! + + ); + }, [loading]); + + return ( + + 런닝 기록 + } + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + refreshControl={ + + } + ListFooterComponent={renderFooter} + ListEmptyComponent={renderEmpty} + /> + + ); +} + +const Container = styled.View({ + marginTop: 20, +}); + +const SectionTitle = styled.Text({ + fontSize: 18, + fontWeight: '600', + color: '#2d2d2d', + paddingHorizontal: 16, + marginBottom: 12, +}); + +const RecordItem = styled(TouchableOpacity)({ + width: 360, // 120 * 3 = 360 + height: 360, // 120 * 3 = 360 + borderRadius: 12, + overflow: 'hidden', + marginBottom: 120, +}); + +const RecordImage = styled.Image({ + width: '100%', + height: '100%', + resizeMode: 'cover', +}); + +const Separator = styled.View({ + width: 12, +}); + +const LoadingContainer = styled.View({ + padding: 20, + alignItems: 'center', +}); + +const EmptyContainer = styled.View({ + padding: 40, + alignItems: 'center', + justifyContent: 'center', + width: '100%', + minHeight: 200, +}); + +const EmptyText = styled.Text({ + fontSize: 16, + fontWeight: '500', + color: '#666666', + marginBottom: 8, +}); + +const EmptySubText = styled.Text({ + fontSize: 14, + color: '#999999', +}); diff --git a/src/pages/Records/_components/StatsDisplay.tsx b/src/pages/Records/_components/StatsDisplay.tsx new file mode 100644 index 0000000..ebc42b8 --- /dev/null +++ b/src/pages/Records/_components/StatsDisplay.tsx @@ -0,0 +1,94 @@ +import styled from '@emotion/native'; +import { formatDistance, formatTime, formatPace } from '@/utils/formatters'; +import type { RunningDashboard } from '@/types/records.types'; + +interface StatsDisplayProps { + dashboard: RunningDashboard; +} + +export default function StatsDisplay({ dashboard }: StatsDisplayProps) { + return ( + + + + + {formatDistance(dashboard.totalDistance)} + + + + + + + {dashboard.nRecords}회 + 총 횟수 + + + + {formatTime(dashboard.totalDuration)} + 총 시간 + + + + + {Math.round(dashboard.totalCalories * 100) / 100}kcal + + 총 칼로리 + + + + {formatPace(dashboard.meanPace)} + 평균 페이스 + + + + ); +} + +const Container = styled.View({ + paddingHorizontal: 16, + paddingVertical: 12, +}); + +const MainStatsContainer = styled.View({ + marginBottom: 20, +}); + +const TotalDistanceContainer = styled.View({ + flexDirection: 'row', + alignItems: 'baseline', + gap: 8, +}); + +const TotalDistanceText = styled.Text({ + fontSize: 32, + fontWeight: '700', + color: '#2d2d2d', +}); + +const StatsGrid = styled.View({ + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, +}); + +const StatBox = styled.View({ + flex: 1, + minWidth: '45%', + backgroundColor: '#f8f9fa', + padding: 16, + borderRadius: 12, + alignItems: 'center', +}); + +const StatValue = styled.Text({ + fontSize: 18, + fontWeight: '600', + color: '#2d2d2d', + marginBottom: 4, +}); + +const StatLabel = styled.Text({ + fontSize: 12, + fontWeight: '400', + color: '#666666', +}); diff --git a/src/pages/Records/_components/TimeRangeSelector.tsx b/src/pages/Records/_components/TimeRangeSelector.tsx new file mode 100644 index 0000000..b2e563f --- /dev/null +++ b/src/pages/Records/_components/TimeRangeSelector.tsx @@ -0,0 +1,197 @@ +import styled from '@emotion/native'; +import { ChevronDown } from 'lucide-react-native'; +import { useState } from 'react'; +import type { TimeRange } from '@/types/records.types'; +import { theme } from '@/styles/theme'; + +interface TimeRangeSelectorProps { + activeRange: TimeRange; + onRangeChange: (range: TimeRange) => void; + displayText: string; + onDropdownPress: () => void; + showDropdown: boolean; + onDetailSelection: (value: number) => void; + selectedWeek: number; + selectedMonth: number; + selectedYear: number; +} + +const timeRangeTabs: { id: TimeRange; title: string }[] = [ + { id: 'week', title: '주' }, + { id: 'month', title: '월' }, + { id: 'year', title: '년' }, + { id: 'all', title: '전체' }, +]; + +export default function TimeRangeSelector({ + activeRange, + onRangeChange, + displayText, + onDropdownPress, + showDropdown, + onDetailSelection, + selectedWeek, + selectedMonth, + selectedYear, +}: TimeRangeSelectorProps) { + return ( + + + {timeRangeTabs.map((tab) => ( + onRangeChange(tab.id)} + > + {tab.title} + + ))} + + + + {displayText} + + + + {showDropdown && ( + + {activeRange === 'week' && ( + <> + {Array.from({ length: 4 }, (_, i) => i + 1).map((week) => ( + onDetailSelection(week)} + > + + {week}째 주 + + + ))} + + )} + {activeRange === 'month' && ( + <> + {Array.from({ length: 12 }, (_, i) => i + 1).map((month) => ( + onDetailSelection(month)} + > + + {month}월 + + + ))} + + )} + {activeRange === 'year' && ( + <> + {Array.from( + { length: 5 }, + (_, i) => new Date().getFullYear() - 2 + i, + ).map((year) => ( + onDetailSelection(year)} + > + + {year}년 + + + ))} + + )} + {activeRange === 'all' && ( + onRangeChange('all')}> + 전체 + + )} + + )} + + ); +} + +const Container = styled.View({ + position: 'relative', + paddingHorizontal: 16, + paddingVertical: 12, +}); + +const TabContainer = styled.View({ + flexDirection: 'row', + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: '#e5e7eb', + borderRadius: 8, + padding: 4, + marginBottom: 12, +}); + +const TabButton = styled.TouchableOpacity<{ + isActive: boolean; +}>(({ isActive }) => ({ + flex: 1, + alignItems: 'center', + paddingVertical: 8, + borderRadius: 6, + backgroundColor: isActive ? `${theme.colors.gray[900]}` : 'transparent', +})); + +const TabText = styled.Text<{ + isActive: boolean; +}>(({ isActive }) => ({ + fontSize: 14, + fontWeight: isActive ? '600' : '400', + color: isActive ? '#ffffff' : '#666666', +})); + +const PeriodContainer = styled.TouchableOpacity({ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 8, +}); + +const PeriodText = styled.Text({ + fontSize: 16, + fontWeight: '500', + color: '#2d2d2d', +}); + +const DropdownContainer = styled.View({ + position: 'absolute', + top: '100%', + left: 16, + right: 16, + backgroundColor: '#ffffff', + borderRadius: 8, + marginTop: 8, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 10, + zIndex: 1000, +}); + +const DropdownItem = styled.TouchableOpacity({ + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', +}); + +const DropdownText = styled.Text<{ + isActive: boolean; +}>(({ isActive }) => ({ + fontSize: 14, + fontWeight: isActive ? '600' : '400', + color: isActive ? '#2d2d2d' : '#666666', +})); diff --git a/src/pages/Records/index.tsx b/src/pages/Records/index.tsx index 46028d6..e9bae3d 100644 --- a/src/pages/Records/index.tsx +++ b/src/pages/Records/index.tsx @@ -1,14 +1,160 @@ import styled from '@emotion/native'; import { FileText } from 'lucide-react-native'; +import { useState, useEffect, useCallback } from 'react'; +import { ScrollView } from 'react-native'; import Header from '@/components/Header'; +import TimeRangeSelector from './_components/TimeRangeSelector'; +import StatsDisplay from './_components/StatsDisplay'; +import RecordsList from './_components/RecordsList'; +import { LoadingOverlay, ErrorOverlay } from '@/components/Overlay'; +import { + useRunningRecords, + useRunningDashboard, + getTimeRangeParams, + getTimeRangeDisplayText, +} from '@/hooks/api/useRecordsApi'; +import type { TimeRange } from '@/types/records.types'; export default function Records() { + const [activeRange, setActiveRange] = useState('month'); + const [showDropdown, setShowDropdown] = useState(false); + const [selectedWeek, setSelectedWeek] = useState(1); + const [selectedMonth, setSelectedMonth] = useState( + new Date().getMonth() + 1, + ); + const [selectedYear, setSelectedYear] = useState( + new Date().getFullYear(), + ); + + const timeRangeParams = getTimeRangeParams( + activeRange, + selectedWeek, + selectedMonth, + selectedYear, + ); + const displayText = getTimeRangeDisplayText( + activeRange, + selectedWeek, + selectedMonth, + selectedYear, + ); + + const { + records, + loading: recordsLoading, + error: recordsError, + hasMore, + loadRecords, + handleLoadMore, + handleRefresh, + } = useRunningRecords(); + + const { + dashboard, + loading: dashboardLoading, + error: dashboardError, + loadDashboard, + } = useRunningDashboard(); + + // 초기 데이터 로드 + useEffect(() => { + console.log('📊 [Records] useEffect 실행:', { + activeRange, + selectedWeek, + selectedMonth, + selectedYear, + timeRangeParams, + }); + loadDashboard(timeRangeParams); + loadRecords(timeRangeParams, true); + }, [activeRange, selectedWeek, selectedMonth, selectedYear]); + + // 기간 변경 시 데이터 새로고침 + const handleRangeChange = useCallback((range: TimeRange) => { + setActiveRange(range); + setShowDropdown(false); + }, []); + + const handleDropdownPress = useCallback(() => { + setShowDropdown(!showDropdown); + }, [showDropdown]); + + const handleRecordPress = useCallback((record: any) => { + // TODO: 기록 상세 페이지로 이동 + console.log('Record pressed:', record); + }, []); + + const handleDetailSelection = useCallback( + (value: number) => { + switch (activeRange) { + case 'week': + setSelectedWeek(value); + break; + case 'month': + setSelectedMonth(value); + break; + case 'year': + setSelectedYear(value); + break; + } + setShowDropdown(false); + }, + [activeRange], + ); + + const isLoading = recordsLoading || dashboardLoading; + const hasError = recordsError || dashboardError; + + // 초기 로딩 상태 (데이터가 없고 로딩 중일 때만) + const isInitialLoading = isLoading && !dashboard && records.length === 0; + + // 새로고침 상태 (데이터가 있을 때만) + const isRefreshing = isLoading && (dashboard || records.length > 0); + return ( -
{}} /> - - 기록 페이지 - +
+ + + + + {dashboard && } + + + + + {isInitialLoading && } + {hasError && ( + { + loadDashboard(timeRangeParams); + loadRecords(timeRangeParams, true); + }} + /> + )} ); } @@ -17,14 +163,3 @@ const Screen = styled.View({ flex: 1, backgroundColor: '#ffffff', }); - -const Content = styled.View({ - flex: 1, - justifyContent: 'center', - alignItems: 'center', -}); - -const Text = styled.Text({ - fontSize: 16, - color: '#666666', -}); diff --git a/src/services/running.service.ts b/src/services/running.service.ts index 541e278..e226042 100644 --- a/src/services/running.service.ts +++ b/src/services/running.service.ts @@ -3,6 +3,12 @@ import type { RunningRecordRequest, RunningRecordResponse, } from '@/types/run.types'; +import type { + RunningRecordsRequest, + RunningRecordsResponse, + RunningDashboard, + RunningDashboardRequest, +} from '@/types/records.types'; export async function createRunningRecord( data: RunningRecordRequest, @@ -30,3 +36,29 @@ export async function createRunningRecord( throw error; } } + +export async function searchRunningRecords( + params: RunningRecordsRequest, + accessToken: string, +): Promise { + const response = await api.get('/api/running/records', { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return response.data; +} + +export async function getRunningDashboard( + params: RunningDashboardRequest, + accessToken: string, +): Promise { + const response = await api.get('/api/running/dashboards', { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return response.data; +} diff --git a/src/types/records.types.ts b/src/types/records.types.ts new file mode 100644 index 0000000..256b875 --- /dev/null +++ b/src/types/records.types.ts @@ -0,0 +1,34 @@ +export interface RunningRecord { + id: number; + artUrl: string; + imageUrl: string; +} + +export interface RunningRecordsResponse { + results: RunningRecord[]; + nextCursor: { + id: number; + }; +} + +export interface RunningRecordsRequest { + since?: string; + until?: string; + cursor?: { id: number } | null; + limit?: number; +} + +export interface RunningDashboard { + nRecords: number; + totalDistance: number; + totalDuration: number; + totalCalories: number; + meanPace: number; +} + +export interface RunningDashboardRequest { + since?: string; + until?: string; +} + +export type TimeRange = 'week' | 'month' | 'year' | 'all'; diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index 1d870dc..1d98ae6 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -9,25 +9,29 @@ export const formatDate = (dateString: string): string => { export const formatDistance = (distance: number): string => { if (distance < 1000) { - return `${distance}m`; + return `${Math.round(distance * 100) / 100}m`; } - return `${(distance / 1000).toFixed(1)}km`; + return `${Math.round((distance / 1000) * 100) / 100}km`; }; export const formatTime = (time: number): string => { const hours = Math.floor(time / 3600); const minutes = Math.floor((time % 3600) / 60); + const seconds = Math.round((time % 60) * 100) / 100; if (hours > 0) { - return `${hours}시간 ${minutes}분`; + return `${hours}시간 ${minutes}분 ${seconds}초`; } - return `${minutes}분`; + if (minutes > 0) { + return `${minutes}분 ${seconds}초`; + } + return `${seconds}초`; }; export const formatPace = (paceSeconds: number): string => { if (paceSeconds === 0) return '0\'00"'; const minutes = Math.floor(paceSeconds / 60); - const seconds = Math.floor(paceSeconds % 60); - return `${minutes}'${seconds.toString().padStart(2, '0')}"`; + const seconds = Math.round((paceSeconds % 60) * 100) / 100; + return `${minutes}'${seconds.toFixed(2).padStart(5, '0')}"`; }; From 3c38d367fad893c32b438ad88eb33c413483bf1e Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 08:51:27 +0900 Subject: [PATCH 12/24] =?UTF-8?q?feat:=20=EC=B6=94=EC=B2=9C=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=B9=B4=EB=93=9C=EC=97=90=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecommendationContainer에 안내 카드 추가하여 사용자에게 런닝 시작을 유도 - FlatList에서 데이터 배열을 안내 카드와 추천 경로로 구성하여 표시 방식 개선 - Card 컴포넌트의 스타일을 조정하여 UI 일관성 향상 - GuideCard 및 관련 텍스트 스타일 추가하여 안내 카드의 가독성 및 디자인 개선 --- src/components/Card.tsx | 8 +- .../_components/RecommendationContainer.tsx | 113 +++++++++++++----- 2 files changed, 88 insertions(+), 33 deletions(-) diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 0d71222..92ac583 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -161,9 +161,9 @@ const CardContainer = styled(TouchableOpacity)<{ borderWidth: 1, borderRadius: 12, padding: mode === 'only-image' ? 0 : 16, - flex: fullWidth ? undefined : mode === 'only-image' ? 1 : 1, + flex: fullWidth ? undefined : mode === 'only-image' ? 1 : undefined, width: fullWidth ? '100%' : undefined, - aspectRatio: fullWidth ? undefined : 1, + minHeight: mode === 'image-with-text' ? 96 : undefined, // 80px 이미지 + 16px 패딩 })); const CardTitle = styled.Text<{ variant?: 'default' | 'light' }>( @@ -190,8 +190,8 @@ const ImageContainer = styled.View({ const CardImage = styled(Image)<{ mode: CardMode; }>(({ mode }) => ({ - width: mode === 'only-image' ? '100%' : 100, - height: mode === 'only-image' ? '100%' : 100, + width: mode === 'only-image' ? '100%' : 80, + height: mode === 'only-image' ? '100%' : 80, borderRadius: mode === 'only-image' ? 12 : 8, marginRight: mode === 'image-with-text' ? 12 : 0, })); diff --git a/src/pages/Run/_components/RecommendationContainer.tsx b/src/pages/Run/_components/RecommendationContainer.tsx index 6b6a5fd..995022f 100644 --- a/src/pages/Run/_components/RecommendationContainer.tsx +++ b/src/pages/Run/_components/RecommendationContainer.tsx @@ -50,38 +50,60 @@ export default function RecommendationContainer({ } }, [recommendations, geocodedAddresses]); + // 안내 카드 데이터 + const guideCard = { + id: 'guide', + title: '🏃‍♂️ 런닝을 시작해보세요!', + subtitle: + '바로 달리기 또는 추천 경로를 선택하여 런닝을 시작할 수 있습니다.', + }; + + // 데이터 배열 생성 (안내 카드 + 추천 경로) + const data = [guideCard, ...recommendations]; + return ( - 0 ? 1 : 0 }} - > - {recommendations.length > 0 && ( - { - const address = geocodedAddresses[item.id] || '로딩 중...'; + + { + // 첫 번째 카드는 안내 문구 + if (index === 0) { + const guideItem = item as typeof guideCard; return ( - onRecommendationPress(item)} - fullWidth={true} - style={{ width: screenWidth - 32, marginRight: 32 }} - /> + + {guideItem.title} + {guideItem.subtitle} + ); - }} - keyExtractor={(item) => item.id.toString()} - horizontal - showsHorizontalScrollIndicator={false} - pagingEnabled={true} - snapToInterval={screenWidth} - decelerationRate="fast" - /> - )} + } + + // 추천 경로 카드 + const courseItem = item as CourseSearchItem; + const address = geocodedAddresses[courseItem.id] || '로딩 중...'; + return ( + onRecommendationPress(courseItem)} + fullWidth={true} + style={{ width: screenWidth - 32, marginRight: 32 }} + /> + ); + }} + keyExtractor={(item, index) => + index === 0 ? 'guide' : item.id.toString() + } + horizontal + showsHorizontalScrollIndicator={false} + pagingEnabled={true} + snapToInterval={screenWidth} + decelerationRate="fast" + /> ); } @@ -94,3 +116,36 @@ const RecommendationOverlay = styled.View({ zIndex: 1000, paddingHorizontal: 16, }); + +const GuideCard = styled.View({ + backgroundColor: 'rgba(255, 255, 255, 0.95)', + borderRadius: 16, + padding: 24, + alignItems: 'center', + justifyContent: 'center', + minHeight: 120, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 5, +}); + +const GuideTitle = styled.Text({ + fontSize: 20, + fontWeight: '700', + color: '#2d2d2d', + textAlign: 'center', + marginBottom: 12, +}); + +const GuideSubtitle = styled.Text({ + fontSize: 14, + fontWeight: '400', + color: '#666666', + textAlign: 'center', + lineHeight: 20, +}); From 72f025e4273c1f00f0614610e9b7664dbed07abb Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 09:18:44 +0900 Subject: [PATCH 13/24] =?UTF-8?q?style:=20Card=20=EB=B0=8F=20Recommendatio?= =?UTF-8?q?nContainer=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=9D=98=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CardContainer의 flex 속성을 수정하여 UI 일관성 향상 - CardImage의 크기를 조정하여 이미지 비율 개선 - GuideCard의 최소 높이를 조정하여 디자인 최적화 --- src/components/Card.tsx | 8 ++++---- src/pages/Run/_components/RecommendationContainer.tsx | 10 +--------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 92ac583..0d71222 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -161,9 +161,9 @@ const CardContainer = styled(TouchableOpacity)<{ borderWidth: 1, borderRadius: 12, padding: mode === 'only-image' ? 0 : 16, - flex: fullWidth ? undefined : mode === 'only-image' ? 1 : undefined, + flex: fullWidth ? undefined : mode === 'only-image' ? 1 : 1, width: fullWidth ? '100%' : undefined, - minHeight: mode === 'image-with-text' ? 96 : undefined, // 80px 이미지 + 16px 패딩 + aspectRatio: fullWidth ? undefined : 1, })); const CardTitle = styled.Text<{ variant?: 'default' | 'light' }>( @@ -190,8 +190,8 @@ const ImageContainer = styled.View({ const CardImage = styled(Image)<{ mode: CardMode; }>(({ mode }) => ({ - width: mode === 'only-image' ? '100%' : 80, - height: mode === 'only-image' ? '100%' : 80, + width: mode === 'only-image' ? '100%' : 100, + height: mode === 'only-image' ? '100%' : 100, borderRadius: mode === 'only-image' ? 12 : 8, marginRight: mode === 'image-with-text' ? 12 : 0, })); diff --git a/src/pages/Run/_components/RecommendationContainer.tsx b/src/pages/Run/_components/RecommendationContainer.tsx index 995022f..a2129fb 100644 --- a/src/pages/Run/_components/RecommendationContainer.tsx +++ b/src/pages/Run/_components/RecommendationContainer.tsx @@ -123,15 +123,7 @@ const GuideCard = styled.View({ padding: 24, alignItems: 'center', justifyContent: 'center', - minHeight: 120, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 4, - }, - shadowOpacity: 0.1, - shadowRadius: 8, - elevation: 5, + minHeight: 100, }); const GuideTitle = styled.Text({ From 249229d29cd0158d3ec328ed1d0adf055ed198d1 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 09:20:04 +0900 Subject: [PATCH 14/24] =?UTF-8?q?fix:=20RunTabNavigator=20=EB=B0=8F=20Deta?= =?UTF-8?q?il=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=9D=98=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RunTabNavigator에서 '바로가기'를 '바로 달리기'로 변경하여 명확한 의미 전달 - Detail 페이지에서 버튼 텍스트를 '그리기...'에서 '달리기...'로 수정하여 사용자 경험 개선 --- src/navigation/RunTabNavigator.tsx | 2 +- src/pages/Detail/index.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/navigation/RunTabNavigator.tsx b/src/navigation/RunTabNavigator.tsx index 7cf826b..a9e6472 100644 --- a/src/navigation/RunTabNavigator.tsx +++ b/src/navigation/RunTabNavigator.tsx @@ -25,7 +25,7 @@ const Stack = createNativeStackNavigator(); type RunTabId = 'quickstart' | 'courseselection'; const tabs: Array<{ id: RunTabId; title: string }> = [ - { id: 'quickstart', title: '바로가기' }, + { id: 'quickstart', title: '바로 달리기' }, { id: 'courseselection', title: '코스 선택하기' }, ]; diff --git a/src/pages/Detail/index.tsx b/src/pages/Detail/index.tsx index 078a846..7913620 100644 --- a/src/pages/Detail/index.tsx +++ b/src/pages/Detail/index.tsx @@ -273,10 +273,10 @@ export default function Detail({ route, navigation }: Props) { {isPressing ? isCompletedCourse ? '인증하기...' - : '그리기...' + : '달리기 ...' : isCompletedCourse ? '인증하기' - : '그리기'} + : '달리기'} From 87cfe76949afe4d103ad5d1fd854eb44d766ca1a Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 09:45:01 +0900 Subject: [PATCH 15/24] =?UTF-8?q?feat:=20=EC=A7=80=EB=8F=84=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90=20onMapReady=20=EC=BD=9C?= =?UTF-8?q?=EB=B0=B1=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Map 컴포넌트에 onMapReady 프로퍼티 추가하여 지도 로딩 완료 시 콜백 실행 - DrawMap 컴포넌트에서 onMapReady를 사용하여 지도 준비 상태 관리 - useMapGestures 훅에서 불필요한 상태 초기화 코드 제거 - Draw 페이지에서 탭 이탈 시 그린 선 초기화 로직 추가 - 스타일 개선: 그린 선 및 완료된 선의 색상 변경 --- src/components/Map.tsx | 8 ++- src/hooks/useMapGestures.ts | 1 - src/pages/Draw/_components/DrawMap.tsx | 68 ++++++++++++++------------ src/pages/Draw/index.tsx | 13 ++++- src/store/draw.ts | 2 - 5 files changed, 55 insertions(+), 37 deletions(-) diff --git a/src/components/Map.tsx b/src/components/Map.tsx index 6fe60cb..19dc9f1 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -9,6 +9,7 @@ interface MapProps { cameraRef: RefObject; initialLocation: Position | null; onUserLocationUpdate?: (location: Mapbox.Location) => void; + onMapReady?: () => void; children?: React.ReactNode; showUserLocation?: boolean; } @@ -18,6 +19,7 @@ export default function Map({ cameraRef, initialLocation, onUserLocationUpdate, + onMapReady, children, showUserLocation = true, }: MapProps) { @@ -25,7 +27,11 @@ export default function Map({ const safeInitialLocation = initialLocation || [127.0276, 37.4979]; return ( - + ) { .enabled(drawMode === 'draw') .onBegin(() => { pointsInCurrentGesture.current = []; - setDrawnCoordinates([]); }) .onUpdate(async (event) => { if (drawMode !== 'draw' || !mapRef.current) return; diff --git a/src/pages/Draw/_components/DrawMap.tsx b/src/pages/Draw/_components/DrawMap.tsx index 1a837ae..f3e1ec8 100644 --- a/src/pages/Draw/_components/DrawMap.tsx +++ b/src/pages/Draw/_components/DrawMap.tsx @@ -1,4 +1,5 @@ import { View } from 'react-native'; +import { useState } from 'react'; import styled from '@emotion/native'; import Mapbox from '@rnmapbox/maps'; import { theme } from '@/styles/theme'; @@ -16,6 +17,7 @@ export default function DrawMap({ }: DrawMapProps) { const { drawnCoordinates, completedDrawings, matchedRoutes, isCapturing } = useDrawStore(); + const [isMapReady, setIsMapReady] = useState(false); return ( @@ -24,9 +26,11 @@ export default function DrawMap({ cameraRef={cameraRef} initialLocation={initialLocation} onUserLocationUpdate={onUserLocationUpdate} + onMapReady={() => setIsMapReady(true)} showUserLocation={!isCapturing} > - {!isCapturing && + {isMapReady && + !isCapturing && drawnCoordinates.length > 1 && drawnCoordinates.every( (coord) => Array.isArray(coord) && coord.length === 2, @@ -45,7 +49,7 @@ export default function DrawMap({ )} - {!isCapturing && + {isMapReady && + !isCapturing && completedDrawings .filter( (drawing) => @@ -80,7 +85,7 @@ export default function DrawMap({ ))} - {matchedRoutes - .filter( - (route) => - route && - route.geometry && - route.geometry.coordinates && - Array.isArray(route.geometry.coordinates) && - route.geometry.coordinates.every( - (coord) => Array.isArray(coord) && coord.length === 2, - ), - ) - .map((route, index) => ( - - - - ))} + {isMapReady && + matchedRoutes + .filter( + (route) => + route && + route.geometry && + route.geometry.coordinates && + Array.isArray(route.geometry.coordinates) && + route.geometry.coordinates.every( + (coord) => Array.isArray(coord) && coord.length === 2, + ), + ) + .map((route, index) => ( + + + + ))} {!isCapturing && ( diff --git a/src/pages/Draw/index.tsx b/src/pages/Draw/index.tsx index c6c7448..9e24811 100644 --- a/src/pages/Draw/index.tsx +++ b/src/pages/Draw/index.tsx @@ -52,12 +52,21 @@ export default function Draw() { useFocusEffect( useCallback(() => { - clearAll(); // 위치가 없으면 강제로 위치 요청 if (!initialLocation && !locationLoading) { refreshLocation(); } - }, [clearAll, initialLocation, locationLoading, refreshLocation]), + }, [initialLocation, locationLoading, refreshLocation]), + ); + + // Draw 탭을 완전히 이탈할 때 그린 선들 초기화 + useFocusEffect( + useCallback(() => { + return () => { + // 컴포넌트가 언마운트될 때 (탭 이탈 시) 초기화 + clearAll(); + }; + }, [clearAll]), ); const handleBackPress = () => { diff --git a/src/store/draw.ts b/src/store/draw.ts index 8bd388c..af48563 100644 --- a/src/store/draw.ts +++ b/src/store/draw.ts @@ -44,12 +44,10 @@ const useDrawStore = create((set) => ({ toggleDrawMode: () => set((state) => ({ drawMode: state.drawMode === 'draw' ? 'none' : 'draw', - drawnCoordinates: [], })), toggleEraseMode: () => set((state) => ({ drawMode: state.drawMode === 'erase' ? 'none' : 'erase', - drawnCoordinates: [], })), eraseRouteByIndex: (routeIndex) => set((state) => ({ From ea1d0439849d861c89ecfa60abdd8c08463f3c90 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 09:54:02 +0900 Subject: [PATCH 16/24] =?UTF-8?q?feat:=20LoadingIndicator=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20RouteSave=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EB=82=B4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoadingIndicator 컴포넌트에 메시지 프로퍼티를 추가하여 로딩 메시지를 동적으로 설정 가능하도록 개선 - Draw 페이지에서 로딩 중 메시지를 "매칭중..."으로 변경 - RouteSave 페이지에서 내비게이션을 'TabNavigator'의 'Home' 화면으로 수정하여 사용자 흐름 개선 --- src/pages/Draw/index.tsx | 6 ++++-- src/pages/RouteSave/index.tsx | 5 ++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pages/Draw/index.tsx b/src/pages/Draw/index.tsx index 9e24811..2350fa8 100644 --- a/src/pages/Draw/index.tsx +++ b/src/pages/Draw/index.tsx @@ -26,7 +26,9 @@ import { showCourseSaveError, } from './_components/Toasts'; -const LoadingIndicator = () => ; +const LoadingIndicator = ({ message = '로딩 중...' }: { message?: string }) => ( + +); export default function Draw() { const navigation = @@ -137,7 +139,7 @@ export default function Draw() { /> - {isLoading && } + {isLoading && } ); diff --git a/src/pages/RouteSave/index.tsx b/src/pages/RouteSave/index.tsx index 67cabd9..97f2e1a 100644 --- a/src/pages/RouteSave/index.tsx +++ b/src/pages/RouteSave/index.tsx @@ -112,9 +112,8 @@ export default function RouteSave() { onPress: () => { clearAll(); clearRouteData(); - navigation.reset({ - index: 0, - routes: [{ name: 'RouteMain' }], + navigation.navigate('TabNavigator', { + screen: 'Home', }); }, }, From f4beefa17f6c363d32540fe6040d7b0ebb5ca6e6 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 10:42:06 +0900 Subject: [PATCH 17/24] =?UTF-8?q?feat:=20Draw=20=ED=83=AD=20=EC=9D=B4?= =?UTF-8?q?=ED=83=88=20=EC=8B=9C=20=EA=B7=B8=EB=A6=B0=20=EC=84=A0=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20RunTabNavigator=20=EB=A6=AC=EC=85=8B=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Draw 탭 이탈 시 그린 선 초기화 로직을 RouteSave에서 돌아오는 경우를 제외하도록 수정 - RunTabNavigator를 리셋하는 RunTabWithReset 컴포넌트 추가하여 사용자 흐름 개선 - Route 페이지의 버튼 배경색을 '#111827'에서 '#000000'으로 변경하여 UI 일관성 향상 --- src/hooks/useMapGestures.ts | 1 + src/navigation/TabNavigator.tsx | 7 ++++++- src/pages/Draw/index.tsx | 9 +++++---- src/pages/Route/index.tsx | 2 +- src/pages/RouteSave/index.tsx | 6 +++--- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/hooks/useMapGestures.ts b/src/hooks/useMapGestures.ts index 73dba56..1c44991 100644 --- a/src/hooks/useMapGestures.ts +++ b/src/hooks/useMapGestures.ts @@ -43,6 +43,7 @@ export function useMapGestures(mapRef: RefObject) { .enabled(drawMode === 'draw') .onBegin(() => { pointsInCurrentGesture.current = []; + setDrawnCoordinates([]); }) .onUpdate(async (event) => { if (drawMode !== 'draw' || !mapRef.current) return; diff --git a/src/navigation/TabNavigator.tsx b/src/navigation/TabNavigator.tsx index fbf09d6..a736bcd 100644 --- a/src/navigation/TabNavigator.tsx +++ b/src/navigation/TabNavigator.tsx @@ -13,6 +13,11 @@ import Run from '@/pages/Run'; import type { TabParamList } from '@/types/navigation.types'; import WebMyPage from '@/pages/WebMyPage'; +// RunTab 전용 컴포넌트 +function RunTabWithReset() { + return ; +} + const Tab = createBottomTabNavigator(); const TAB_BAR_HEIGHT = 68; @@ -72,7 +77,7 @@ export default function TabNavigator() { /> { const routeName = getFocusedRouteNameFromRoute(route) ?? 'QuickStartMain'; diff --git a/src/pages/Draw/index.tsx b/src/pages/Draw/index.tsx index 2350fa8..47af62f 100644 --- a/src/pages/Draw/index.tsx +++ b/src/pages/Draw/index.tsx @@ -61,14 +61,15 @@ export default function Draw() { }, [initialLocation, locationLoading, refreshLocation]), ); - // Draw 탭을 완전히 이탈할 때 그린 선들 초기화 + // Draw 탭을 완전히 이탈할 때 그린 선들 초기화 (RouteSave에서 돌아올 때는 제외) useFocusEffect( useCallback(() => { return () => { - // 컴포넌트가 언마운트될 때 (탭 이탈 시) 초기화 - clearAll(); + // RouteSave에서 돌아오는 경우가 아닐 때만 초기화 + // 현재는 초기화하지 않음 (탭 이탈 시에만 초기화하려면 다른 방법 필요) + // clearAll(); }; - }, [clearAll]), + }, []), ); const handleBackPress = () => { diff --git a/src/pages/Route/index.tsx b/src/pages/Route/index.tsx index 3a0fcc5..8640066 100644 --- a/src/pages/Route/index.tsx +++ b/src/pages/Route/index.tsx @@ -113,7 +113,7 @@ const Screen = styled.View({ const styles = { defaultButton: { - backgroundColor: '#111827', + backgroundColor: '#000000', }, activeButton: { backgroundColor: theme.colors.secondary[500], diff --git a/src/pages/RouteSave/index.tsx b/src/pages/RouteSave/index.tsx index 97f2e1a..52754be 100644 --- a/src/pages/RouteSave/index.tsx +++ b/src/pages/RouteSave/index.tsx @@ -112,9 +112,9 @@ export default function RouteSave() { onPress: () => { clearAll(); clearRouteData(); - navigation.navigate('TabNavigator', { - screen: 'Home', - }); + // RouteStackNavigator 내에서 RouteMain으로 돌아가기 + navigation.goBack(); // Draw로 돌아가기 + navigation.goBack(); // RouteMain으로 돌아가기 }, }, ]); From ad265da69e2a6ede38188e2483e3100f4cb0448a Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 10:48:08 +0900 Subject: [PATCH 18/24] =?UTF-8?q?feat:=20=EC=BA=A1=EC=B2=98=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=20=EC=86=8C=EC=8A=A4=20=ED=91=9C=EC=8B=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useMapCapture 훅에 캡처 전후 대기 시간을 추가하여 안정성 향상 - DrawMap 컴포넌트에서 캡처 상태에 따라 그리기 소스 표시 로직을 개선하여 사용자 경험 향상 - Draw 페이지 이탈 시 그린 선 초기화 로직을 간소화하여 코드 가독성 개선 --- src/hooks/useMapCapture.ts | 6 ++++++ src/pages/Draw/_components/DrawMap.tsx | 21 ++++++++++++++++++--- src/pages/Draw/index.tsx | 8 +++----- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/hooks/useMapCapture.ts b/src/hooks/useMapCapture.ts index 211329b..d3595c9 100644 --- a/src/hooks/useMapCapture.ts +++ b/src/hooks/useMapCapture.ts @@ -19,8 +19,14 @@ export function useMapCapture( throw new Error('맵이 준비되지 않았습니다.'); } + // 캡처 전에 잠시 대기하여 현재 렌더링이 완료되도록 함 + await new Promise((resolve) => setTimeout(resolve, 100)); + setIsCapturing(true); + // 소스 제거 후 추가 대기 + await new Promise((resolve) => setTimeout(resolve, 200)); + try { const coordinatesToUse = routeCoordinates || diff --git a/src/pages/Draw/_components/DrawMap.tsx b/src/pages/Draw/_components/DrawMap.tsx index f3e1ec8..d21b483 100644 --- a/src/pages/Draw/_components/DrawMap.tsx +++ b/src/pages/Draw/_components/DrawMap.tsx @@ -1,5 +1,5 @@ import { View } from 'react-native'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import styled from '@emotion/native'; import Mapbox from '@rnmapbox/maps'; import { theme } from '@/styles/theme'; @@ -18,6 +18,21 @@ export default function DrawMap({ const { drawnCoordinates, completedDrawings, matchedRoutes, isCapturing } = useDrawStore(); const [isMapReady, setIsMapReady] = useState(false); + const [showDrawingSources, setShowDrawingSources] = useState(true); + + // 캡처 시작 시 그리기 중인 소스들만 안전하게 숨김 (매칭된 경로는 유지) + useEffect(() => { + if (isCapturing) { + // 캡처 시작 시 그리기 중인 소스들만 즉시 숨김 + setShowDrawingSources(false); + } else { + // 캡처 완료 후 약간의 지연을 두고 그리기 소스들을 다시 표시 + const timer = setTimeout(() => { + setShowDrawingSources(true); + }, 300); + return () => clearTimeout(timer); + } + }, [isCapturing]); return ( @@ -30,7 +45,7 @@ export default function DrawMap({ showUserLocation={!isCapturing} > {isMapReady && - !isCapturing && + showDrawingSources && drawnCoordinates.length > 1 && drawnCoordinates.every( (coord) => Array.isArray(coord) && coord.length === 2, @@ -59,7 +74,7 @@ export default function DrawMap({ )} {isMapReady && - !isCapturing && + showDrawingSources && completedDrawings .filter( (drawing) => diff --git a/src/pages/Draw/index.tsx b/src/pages/Draw/index.tsx index 47af62f..f8d1918 100644 --- a/src/pages/Draw/index.tsx +++ b/src/pages/Draw/index.tsx @@ -61,15 +61,13 @@ export default function Draw() { }, [initialLocation, locationLoading, refreshLocation]), ); - // Draw 탭을 완전히 이탈할 때 그린 선들 초기화 (RouteSave에서 돌아올 때는 제외) + // Draw 페이지 이탈 시 그린 선들 초기화 useFocusEffect( useCallback(() => { return () => { - // RouteSave에서 돌아오는 경우가 아닐 때만 초기화 - // 현재는 초기화하지 않음 (탭 이탈 시에만 초기화하려면 다른 방법 필요) - // clearAll(); + clearAll(); }; - }, []), + }, [clearAll]), ); const handleBackPress = () => { From 06ab1c0a5acb058faeb7448062ffd628b5c111c9 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 11:14:41 +0900 Subject: [PATCH 19/24] =?UTF-8?q?feat:=20eas.json=EC=97=90=20Android=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eas.json 파일에 Android 빌드 타입을 "apk"로 설정하여 Android 빌드 구성 개선 --- eas.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/eas.json b/eas.json index bdcde1c..8f726a9 100644 --- a/eas.json +++ b/eas.json @@ -17,6 +17,9 @@ "ios": { "simulator": true }, + "android": { + "buildType": "apk " + }, "environment": "development" } }, From a29e436816ed567d2bb89ec27f2ca3a484e43c79 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 11:15:48 +0900 Subject: [PATCH 20/24] =?UTF-8?q?feat:=20eas.json=EC=97=90=20Android=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EA=B3=B5?= =?UTF-8?q?=EB=B0=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eas.json 파일에 Android 빌드 타입을 "apk"로 설정하여 Android 빌드 구성 개선 - 불필요한 공백을 제거하여 코드 가독성 향상 --- eas.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/eas.json b/eas.json index 8f726a9..a29eb10 100644 --- a/eas.json +++ b/eas.json @@ -10,7 +10,11 @@ "preview": { "distribution": "internal" }, - "production": {}, + "production": { + "android": { + "buildType": "apk" + } + }, "development-simulator": { "developmentClient": true, "distribution": "internal", @@ -18,7 +22,7 @@ "simulator": true }, "android": { - "buildType": "apk " + "buildType": "apk" }, "environment": "development" } From 7413ab5f1feed3d560b8d70fa8c0295e2baf8836 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 12:58:08 +0900 Subject: [PATCH 21/24] =?UTF-8?q?feat:=20Google=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EA=B9=85?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google Sign-In 초기화 과정에서 환경변수 상태 및 설정 정보를 로그로 출력하여 디버깅 용이성 향상 - 로그인 시도 및 토큰 요청 과정에서 상세한 로그를 추가하여 문제 발생 시 원인 파악 용이 - 서버 로그인 요청 시 오류 상태에 따른 상세한 로그를 추가하여 사용자에게 가능한 원인 안내 - 토큰 갱신 및 로그아웃 과정에서도 로그를 추가하여 전체적인 흐름 추적 가능 - 로그인 상태 확인 시 현재 사용자 정보를 로그로 출력하여 상태 확인 용이성 향상 --- src/services/auth.service.ts | 141 ++++++++++++++++++++++++++++++++--- 1 file changed, 132 insertions(+), 9 deletions(-) diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 2033a6a..e9cf6c4 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -16,63 +16,184 @@ export interface RefreshTokenResponse { } export function initializeGoogleSignIn(): void { - GoogleSignin.configure({ - webClientId: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID, - iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID, - profileImageSize: 120, - }); + try { + console.log('🔧 Google Sign-In 초기화 시작'); + + const webClientId = process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID; + const iosClientId = process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID; + + console.log('Web Client ID 상태:', webClientId ? '설정됨' : '❌ 미설정'); + console.log('iOS Client ID 상태:', iosClientId ? '설정됨' : '❌ 미설정'); + + if (webClientId) { + console.log('Web Client ID 길이:', webClientId.length); + console.log( + 'Web Client ID 앞 20자:', + webClientId.substring(0, 20) + '...', + ); + } + + if (iosClientId) { + console.log('iOS Client ID 길이:', iosClientId.length); + console.log( + 'iOS Client ID 앞 20자:', + iosClientId.substring(0, 20) + '...', + ); + } + + if (!webClientId) { + console.error('❌ Web Client ID가 설정되지 않았습니다!'); + console.error('EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID 환경변수를 확인하세요'); + } + + if (!iosClientId) { + console.error('❌ iOS Client ID가 설정되지 않았습니다!'); + console.error('EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID 환경변수를 확인하세요'); + } + + const config = { + webClientId, + iosClientId, + profileImageSize: 120, + }; + + console.log('Google Sign-In 설정:', config); + GoogleSignin.configure(config); + + console.log('✅ Google Sign-In 초기화 완료'); + } catch (error) { + console.error('❌ Google Sign-In 초기화 오류:', error); + throw error; + } } export async function signInWithGoogle(): Promise { try { + console.log('Google 로그인 시작'); + + console.log('Play Services 확인 중...'); await GoogleSignin.hasPlayServices(); - await GoogleSignin.signIn(); - const { idToken } = await GoogleSignin.getTokens(); + console.log('Play Services 확인 완료'); + + console.log('Google 로그인 시도 중...'); + const signInResult = await GoogleSignin.signIn(); + console.log('Google 로그인 성공:', signInResult); + + console.log('토큰 가져오는 중...'); + const tokens = await GoogleSignin.getTokens(); + console.log('전체 토큰 응답:', tokens); + + const { idToken, accessToken } = tokens; + console.log('ID Token 상태:', idToken ? '발급됨' : '미발급'); + console.log('Access Token 상태:', accessToken ? '발급됨' : '미발급'); + + if (idToken) { + console.log('ID Token 길이:', idToken.length); + console.log('ID Token 앞 20자:', idToken.substring(0, 20) + '...'); + } if (!idToken) { + console.error('❌ ID 토큰이 발급되지 않았습니다'); + console.error('가능한 원인:'); + console.error('1. Google Console에서 OAuth 클라이언트 ID 설정 오류'); + console.error('2. 프로덕션 bundle ID가 Google Console에 등록되지 않음'); + console.error('3. Redirect URI 설정 오류'); + console.error('4. .env 파일의 클라이언트 ID가 잘못됨'); throw new Error('구글 로그인 중 ID 토큰을 받지 못했습니다.'); } - const { data } = await api.post('/auth/google', { - idToken, + console.log('서버에 로그인 요청 중...'); + console.log('요청 URL:', '/auth/google'); + console.log('요청 페이로드:', { + idToken: idToken.substring(0, 20) + '...', }); + + let data: GoogleSignInResponse; + try { + const response = await api.post('/auth/google', { + idToken, + }); + data = response.data; + console.log('✅ 서버 로그인 성공:', data); + } catch (serverError: any) { + console.error('❌ 서버 로그인 실패:', serverError); + console.error('서버 응답 상태:', serverError.response?.status); + console.error('서버 응답 데이터:', serverError.response?.data); + console.error('서버 응답 헤더:', serverError.response?.headers); + + if (serverError.response?.status === 400) { + console.error('400 오류 - 가능한 원인:'); + console.error('1. 잘못된 ID 토큰 형식'); + console.error('2. Google Console OAuth 설정 오류'); + console.error( + '3. 서버에서 사용하는 클라이언트 ID와 앱의 클라이언트 ID 불일치', + ); + } else if (serverError.response?.status === 401) { + console.error('401 오류 - 가능한 원인:'); + console.error('1. ID 토큰이 만료됨'); + console.error('2. Google에서 발급한 토큰이 서버에서 검증 실패'); + console.error('3. 서버의 Google OAuth 설정 오류'); + } else if (serverError.response?.status === 500) { + console.error('500 오류 - 서버 내부 오류'); + console.error('서버 로그를 확인해야 합니다'); + } + + throw serverError; + } + return data; } catch (error) { + console.error('Google 로그인 전체 오류:', error); + if (isErrorWithCode(error)) { + console.error('Google 로그인 코드 오류:', error.code); switch (error.code) { case statusCodes.SIGN_IN_CANCELLED: + console.error('사용자가 로그인을 취소했습니다'); throw new Error('사용자가 로그인을 취소했습니다.'); case statusCodes.IN_PROGRESS: + console.error('로그인이 진행 중입니다'); throw new Error('로그인이 진행 중입니다.'); case statusCodes.PLAY_SERVICES_NOT_AVAILABLE: + console.error('Play Services를 사용할 수 없습니다'); throw new Error( '구글 플레이 서비스를 사용할 수 없거나 버전이 오래되었습니다.', ); default: + console.error('알 수 없는 Google 로그인 오류:', error.code); throw new Error('구글 로그인 중 오류가 발생했습니다.'); } } + + console.error('일반 로그인 오류:', error); throw new Error('로그인 중 오류가 발생했습니다. 다시 시도해주세요.'); } } export async function refreshToken(): Promise { try { + console.log('토큰 갱신 시작'); const { data } = await api.post('/auth/refresh'); + console.log('토큰 갱신 응답:', data); if (!data.accessToken) { + console.error('토큰 갱신 실패: accessToken이 없습니다'); throw new Error('토큰 갱신에 실패했습니다.'); } + console.log('토큰 갱신 성공'); return data; } catch (error) { + console.error('토큰 갱신 오류:', error); throw new Error('토큰 갱신 중 오류가 발생했습니다.'); } } export async function signOut(): Promise { try { + console.log('Google 로그아웃 시작'); await GoogleSignin.signOut(); + console.log('Google 로그아웃 성공'); } catch (error) { console.error('Google 로그아웃 오류:', error); } @@ -80,7 +201,9 @@ export async function signOut(): Promise { export async function isSignedIn(): Promise { try { + console.log('로그인 상태 확인 중...'); const userInfo = await GoogleSignin.getCurrentUser(); + console.log('현재 사용자 정보:', userInfo ? '로그인됨' : '로그인 안됨'); return userInfo !== null; } catch (error) { console.error('로그인 상태 확인 오류:', error); From 996f747c9ef569ce7bab8bc3a02f137cffc86732 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 13:57:49 +0900 Subject: [PATCH 22/24] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=98=81=EC=96=B4=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=8F=20=EC=98=A4=EB=A5=98=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google Sign-In 초기화 및 로그인 과정에서 로그 메시지를 영어로 변경하여 일관성 유지 - 오류 메시지를 개선하여 사용자에게 더 명확한 원인 안내 제공 - 전체적인 로그 메시지의 가독성을 향상시켜 디버깅 용이성 증대 --- src/services/auth.service.ts | 163 ++++++++++++++++++----------------- 1 file changed, 86 insertions(+), 77 deletions(-) diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index e9cf6c4..df5e879 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -17,38 +17,46 @@ export interface RefreshTokenResponse { export function initializeGoogleSignIn(): void { try { - console.log('🔧 Google Sign-In 초기화 시작'); + console.log('🔧 Google Sign-In initialization started'); const webClientId = process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID; const iosClientId = process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID; - console.log('Web Client ID 상태:', webClientId ? '설정됨' : '❌ 미설정'); - console.log('iOS Client ID 상태:', iosClientId ? '설정됨' : '❌ 미설정'); + // CRITICAL: Always log the Web Client ID + console.error('EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID:', webClientId); + console.error('EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID:', iosClientId); + + console.log('Web Client ID status:', webClientId ? 'SET' : '❌ NOT SET'); + console.log('iOS Client ID status:', iosClientId ? 'SET' : '❌ NOT SET'); if (webClientId) { - console.log('Web Client ID 길이:', webClientId.length); + console.log('Web Client ID length:', webClientId.length); console.log( - 'Web Client ID 앞 20자:', + 'Web Client ID first 20 chars:', webClientId.substring(0, 20) + '...', ); } if (iosClientId) { - console.log('iOS Client ID 길이:', iosClientId.length); + console.log('iOS Client ID length:', iosClientId.length); console.log( - 'iOS Client ID 앞 20자:', + 'iOS Client ID first 20 chars:', iosClientId.substring(0, 20) + '...', ); } if (!webClientId) { - console.error('❌ Web Client ID가 설정되지 않았습니다!'); - console.error('EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID 환경변수를 확인하세요'); + console.error('❌ Web Client ID is NOT SET!'); + console.error( + 'Check EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID environment variable', + ); } if (!iosClientId) { - console.error('❌ iOS Client ID가 설정되지 않았습니다!'); - console.error('EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID 환경변수를 확인하세요'); + console.error('❌ iOS Client ID is NOT SET!'); + console.error( + 'Check EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID environment variable', + ); } const config = { @@ -57,54 +65,55 @@ export function initializeGoogleSignIn(): void { profileImageSize: 120, }; - console.log('Google Sign-In 설정:', config); + console.log('Google Sign-In config:', config); GoogleSignin.configure(config); - console.log('✅ Google Sign-In 초기화 완료'); + console.log('✅ Google Sign-In initialization completed'); } catch (error) { - console.error('❌ Google Sign-In 초기화 오류:', error); + console.error('❌ Google Sign-In initialization error:', error); throw error; } } export async function signInWithGoogle(): Promise { try { - console.log('Google 로그인 시작'); + console.log('Google login started'); - console.log('Play Services 확인 중...'); + console.log('Checking Play Services...'); await GoogleSignin.hasPlayServices(); - console.log('Play Services 확인 완료'); + console.log('Play Services check completed'); - console.log('Google 로그인 시도 중...'); + console.log('Attempting Google login...'); const signInResult = await GoogleSignin.signIn(); - console.log('Google 로그인 성공:', signInResult); + console.log('Google login successful:', signInResult); - console.log('토큰 가져오는 중...'); + console.log('Getting tokens...'); const tokens = await GoogleSignin.getTokens(); - console.log('전체 토큰 응답:', tokens); + console.log('Full token response:', tokens); const { idToken, accessToken } = tokens; - console.log('ID Token 상태:', idToken ? '발급됨' : '미발급'); - console.log('Access Token 상태:', accessToken ? '발급됨' : '미발급'); + console.log('ID Token status:', idToken ? 'ISSUED' : 'NOT ISSUED'); + console.log('Access Token status:', accessToken ? 'ISSUED' : 'NOT ISSUED'); if (idToken) { - console.log('ID Token 길이:', idToken.length); - console.log('ID Token 앞 20자:', idToken.substring(0, 20) + '...'); + console.log('ID Token length:', idToken.length); + console.log('ID Token first 20 chars:', idToken.substring(0, 20) + '...'); } if (!idToken) { - console.error('❌ ID 토큰이 발급되지 않았습니다'); - console.error('가능한 원인:'); - console.error('1. Google Console에서 OAuth 클라이언트 ID 설정 오류'); - console.error('2. 프로덕션 bundle ID가 Google Console에 등록되지 않음'); - console.error('3. Redirect URI 설정 오류'); - console.error('4. .env 파일의 클라이언트 ID가 잘못됨'); - throw new Error('구글 로그인 중 ID 토큰을 받지 못했습니다.'); + console.error('❌ ID Token was NOT issued'); + console.error('Possible causes:'); + console.error('1. Google Console OAuth client ID configuration error'); + console.error('2. Production bundle ID not registered in Google Console'); + console.error('3. Redirect URI configuration error'); + console.error('4. Wrong client ID in .env file'); + console.error('5. OAuth consent screen not published to production'); + throw new Error('Failed to receive ID token during Google login.'); } - console.log('서버에 로그인 요청 중...'); - console.log('요청 URL:', '/auth/google'); - console.log('요청 페이로드:', { + console.log('Sending login request to server...'); + console.log('Request URL:', '/auth/google'); + console.log('Request payload:', { idToken: idToken.substring(0, 20) + '...', }); @@ -114,28 +123,28 @@ export async function signInWithGoogle(): Promise { idToken, }); data = response.data; - console.log('✅ 서버 로그인 성공:', data); + console.log('✅ Server login successful:', data); } catch (serverError: any) { - console.error('❌ 서버 로그인 실패:', serverError); - console.error('서버 응답 상태:', serverError.response?.status); - console.error('서버 응답 데이터:', serverError.response?.data); - console.error('서버 응답 헤더:', serverError.response?.headers); + console.error('❌ Server login failed:', serverError); + console.error('Server response status:', serverError.response?.status); + console.error('Server response data:', serverError.response?.data); + console.error('Server response headers:', serverError.response?.headers); if (serverError.response?.status === 400) { - console.error('400 오류 - 가능한 원인:'); - console.error('1. 잘못된 ID 토큰 형식'); - console.error('2. Google Console OAuth 설정 오류'); - console.error( - '3. 서버에서 사용하는 클라이언트 ID와 앱의 클라이언트 ID 불일치', - ); + console.error('400 Error - Possible causes:'); + console.error('1. Invalid ID token format'); + console.error('2. Google Console OAuth configuration error'); + console.error('3. Client ID mismatch between server and app'); + console.error('4. Wrong OAuth consent screen configuration'); } else if (serverError.response?.status === 401) { - console.error('401 오류 - 가능한 원인:'); - console.error('1. ID 토큰이 만료됨'); - console.error('2. Google에서 발급한 토큰이 서버에서 검증 실패'); - console.error('3. 서버의 Google OAuth 설정 오류'); + console.error('401 Error - Possible causes:'); + console.error('1. ID token expired'); + console.error('2. Google token verification failed on server'); + console.error('3. Server Google OAuth configuration error'); + console.error('4. Invalid client ID or secret on server'); } else if (serverError.response?.status === 500) { - console.error('500 오류 - 서버 내부 오류'); - console.error('서버 로그를 확인해야 합니다'); + console.error('500 Error - Server internal error'); + console.error('Check server logs for details'); } throw serverError; @@ -143,70 +152,70 @@ export async function signInWithGoogle(): Promise { return data; } catch (error) { - console.error('Google 로그인 전체 오류:', error); + console.error('Google login overall error:', error); if (isErrorWithCode(error)) { - console.error('Google 로그인 코드 오류:', error.code); + console.error('Google login code error:', error.code); switch (error.code) { case statusCodes.SIGN_IN_CANCELLED: - console.error('사용자가 로그인을 취소했습니다'); - throw new Error('사용자가 로그인을 취소했습니다.'); + console.error('User cancelled login'); + throw new Error('User cancelled login.'); case statusCodes.IN_PROGRESS: - console.error('로그인이 진행 중입니다'); - throw new Error('로그인이 진행 중입니다.'); + console.error('Login in progress'); + throw new Error('Login in progress.'); case statusCodes.PLAY_SERVICES_NOT_AVAILABLE: - console.error('Play Services를 사용할 수 없습니다'); + console.error('Play Services not available'); throw new Error( - '구글 플레이 서비스를 사용할 수 없거나 버전이 오래되었습니다.', + 'Google Play Services is not available or version is outdated.', ); default: - console.error('알 수 없는 Google 로그인 오류:', error.code); - throw new Error('구글 로그인 중 오류가 발생했습니다.'); + console.error('Unknown Google login error:', error.code); + throw new Error('Error occurred during Google login.'); } } - console.error('일반 로그인 오류:', error); - throw new Error('로그인 중 오류가 발생했습니다. 다시 시도해주세요.'); + console.error('General login error:', error); + throw new Error('Error occurred during login. Please try again.'); } } export async function refreshToken(): Promise { try { - console.log('토큰 갱신 시작'); + console.log('Token refresh started'); const { data } = await api.post('/auth/refresh'); - console.log('토큰 갱신 응답:', data); + console.log('Token refresh response:', data); if (!data.accessToken) { - console.error('토큰 갱신 실패: accessToken이 없습니다'); - throw new Error('토큰 갱신에 실패했습니다.'); + console.error('Token refresh failed: accessToken is missing'); + throw new Error('Token refresh failed.'); } - console.log('토큰 갱신 성공'); + console.log('Token refresh successful'); return data; } catch (error) { - console.error('토큰 갱신 오류:', error); - throw new Error('토큰 갱신 중 오류가 발생했습니다.'); + console.error('Token refresh error:', error); + throw new Error('Error occurred during token refresh.'); } } export async function signOut(): Promise { try { - console.log('Google 로그아웃 시작'); + console.log('Google logout started'); await GoogleSignin.signOut(); - console.log('Google 로그아웃 성공'); + console.log('Google logout successful'); } catch (error) { - console.error('Google 로그아웃 오류:', error); + console.error('Google logout error:', error); } } export async function isSignedIn(): Promise { try { - console.log('로그인 상태 확인 중...'); + console.log('Checking login status...'); const userInfo = await GoogleSignin.getCurrentUser(); - console.log('현재 사용자 정보:', userInfo ? '로그인됨' : '로그인 안됨'); + console.log('Current user info:', userInfo ? 'LOGGED IN' : 'NOT LOGGED IN'); return userInfo !== null; } catch (error) { - console.error('로그인 상태 확인 오류:', error); + console.error('Login status check error:', error); return false; } } From dc3b876ba2c2365f2bfcadb248201fb3b3d67dbd Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 14 Sep 2025 15:56:04 +0900 Subject: [PATCH 23/24] =?UTF-8?q?fix:=20=EC=83=81=ED=83=9C=20=EB=B0=8F=20a?= =?UTF-8?q?pi=20=EA=B4=80=EB=A0=A8=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/api/useRouteApi.ts | 102 +++++++++++++++--- src/hooks/useLocationTracking.ts | 14 ++- src/pages/Route/_components/RouteGrid.tsx | 10 ++ src/pages/Route/index.tsx | 25 ++++- .../Run/_components/ControlContainer.tsx | 6 ++ src/pages/Run/index.tsx | 8 +- src/services/courses.service.ts | 42 ++++++++ src/services/running.service.ts | 21 +++- src/store/run.ts | 73 ++++++++++--- 9 files changed, 266 insertions(+), 35 deletions(-) diff --git a/src/hooks/api/useRouteApi.ts b/src/hooks/api/useRouteApi.ts index 3abea5d..42fc8ea 100644 --- a/src/hooks/api/useRouteApi.ts +++ b/src/hooks/api/useRouteApi.ts @@ -28,7 +28,9 @@ export function useRouteData() { const loadCourses = useCallback( async (reset = false) => { - if (!accessToken || loading) return; + // loading 상태를 함수 내부에서 직접 확인 + const currentLoading = useRouteStore.getState().loading; + if (!accessToken || currentLoading) return; setLoading(true); setError(null); @@ -36,13 +38,27 @@ export function useRouteData() { try { // cursor를 함수 내부에서 직접 가져오기 const currentCursor = reset ? null : useRouteStore.getState().cursor; - const response = await searchUserCourses( - { - cursor: currentCursor ? { id: currentCursor } : null, - limit: 10, - }, - accessToken, - ); + const requestParams = { + cursor: currentCursor ? { id: currentCursor } : null, + limit: 10, + }; + + console.log('🔍 [RouteAPI] 생성한 경로 요청:', { + endpoint: '/api/courses/user', + params: requestParams, + accessToken: accessToken ? '있음' : '없음', + }); + + const response = await searchUserCourses(requestParams, accessToken); + + console.log('✅ [RouteAPI] 생성한 경로 응답:', { + resultsCount: response.results.length, + results: response.results.map((course) => ({ + id: course.id, + title: course.title, + createdAt: course.createdAt, + })), + }); if (reset) { setCourses(response.results); @@ -61,12 +77,20 @@ export function useRouteData() { setCursor(nextCursor); setHasMore(response.results.length >= 10); } catch (error: unknown) { + console.error('❌ [RouteAPI] 생성한 경로 요청 실패:', error); + let errorMessage = '경로를 불러오는데 실패했습니다.'; if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as AxiosErrorResponse; const status = axiosError.status; + console.error('❌ [RouteAPI] HTTP 오류:', { + status, + statusText: axiosError.statusText, + data: axiosError.data, + }); + if (status === 500) { errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; @@ -86,7 +110,7 @@ export function useRouteData() { }, [ accessToken, - loading, + // loading을 의존성에서 제거하여 무한 루프 방지 setCourses, setLoading, setError, @@ -141,7 +165,9 @@ export function useBookmarkedCourses() { const loadBookmarkedCourses = useCallback( async (reset = false) => { - if (!accessToken || loading) return; + // loading 상태를 함수 내부에서 직접 확인 + const currentLoading = useRouteStore.getState().loading; + if (!accessToken || currentLoading) return; setLoading(true); setError(null); @@ -152,8 +178,24 @@ export function useBookmarkedCourses() { cursor: currentCursor ? { id: currentCursor } : null, limit: 10, }; + + console.log('🔍 [RouteAPI] 북마크한 경로 요청:', { + endpoint: '/api/courses/search/bookmarked', + params, + accessToken: accessToken ? '있음' : '없음', + }); + const response = await searchBookmarkedCourses(params, accessToken); + console.log('✅ [RouteAPI] 북마크한 경로 응답:', { + resultsCount: response.results.length, + results: response.results.map((course) => ({ + id: course.id, + title: course.title, + createdAt: course.createdAt, + })), + }); + if (reset) { setBookmarkedCourses(response.results); } else { @@ -171,12 +213,20 @@ export function useBookmarkedCourses() { setCursor(nextCursor); setHasMore(response.results.length === 10); } catch (error: unknown) { + console.error('❌ [RouteAPI] 북마크한 경로 요청 실패:', error); + let errorMessage = '북마크한 경로를 불러오는데 실패했습니다.'; if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as AxiosErrorResponse; const status = axiosError.status; + console.error('❌ [RouteAPI] HTTP 오류:', { + status, + statusText: axiosError.statusText, + data: axiosError.data, + }); + if (status === 500) { errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; @@ -197,7 +247,7 @@ export function useBookmarkedCourses() { [ accessToken, cursor, - loading, + // loading을 의존성에서 제거하여 무한 루프 방지 setBookmarkedCourses, setLoading, setError, @@ -252,7 +302,9 @@ export function useCompletedCourses() { const loadCompletedCourses = useCallback( async (reset = false) => { - if (!accessToken || loading) return; + // loading 상태를 함수 내부에서 직접 확인 + const currentLoading = useRouteStore.getState().loading; + if (!accessToken || currentLoading) return; setLoading(true); setError(null); @@ -263,8 +315,24 @@ export function useCompletedCourses() { cursor: currentCursor ? { id: currentCursor } : null, limit: 10, }; + + console.log('🔍 [RouteAPI] 완주한 경로 요청:', { + endpoint: '/api/courses/search/completed', + params, + accessToken: accessToken ? '있음' : '없음', + }); + const response = await searchCompletedCourses(params, accessToken); + console.log('✅ [RouteAPI] 완주한 경로 응답:', { + resultsCount: response.results.length, + results: response.results.map((course) => ({ + id: course.id, + title: course.title, + createdAt: course.createdAt, + })), + }); + if (reset) { setCompletedCourses(response.results); } else { @@ -286,12 +354,20 @@ export function useCompletedCourses() { setCursor(nextCursor); setHasMore(!!response.nextCursor); } catch (error: unknown) { + console.error('❌ [RouteAPI] 완주한 경로 요청 실패:', error); + let errorMessage = '완주한 경로를 불러오는데 실패했습니다.'; if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as AxiosErrorResponse; const status = axiosError.status; + console.error('❌ [RouteAPI] HTTP 오류:', { + status, + statusText: axiosError.statusText, + data: axiosError.data, + }); + if (status === 500) { errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; @@ -312,7 +388,7 @@ export function useCompletedCourses() { [ accessToken, cursor, - loading, + // loading을 의존성에서 제거하여 무한 루프 방지 setCompletedCourses, setLoading, setError, diff --git a/src/hooks/useLocationTracking.ts b/src/hooks/useLocationTracking.ts index 7e6b899..ec60d11 100644 --- a/src/hooks/useLocationTracking.ts +++ b/src/hooks/useLocationTracking.ts @@ -40,9 +40,19 @@ export function useLocationTracking() { const startTracking = useCallback(async () => { if (isTracking) return; - if (location) { + // 기존 경로가 없을 때만 초기 위치를 설정 + const currentCoords = useRunStore.getState().routeCoordinates; + if (currentCoords.length === 0 && location) { const { latitude, longitude } = location.coords; + console.log('📍 [LocationTracking] 위치 추적 시작 - 초기 위치 설정:', { + latitude, + longitude, + }); setRouteCoordinates([[longitude, latitude]]); + } else if (currentCoords.length > 0) { + console.log('📍 [LocationTracking] 위치 추적 재시작 - 기존 경로 유지:', { + coordinatesCount: currentCoords.length, + }); } const newSubscriber = await Location.watchPositionAsync( @@ -86,6 +96,7 @@ export function useLocationTracking() { ]); const pauseTracking = useCallback(() => { + console.log('⏸️ [LocationTracking] 위치 추적 일시정지'); if (subscriber) { subscriber.remove(); setSubscriber(null); @@ -94,6 +105,7 @@ export function useLocationTracking() { }, [subscriber, setSubscriber, setIsTracking]); const stopTracking = useCallback(() => { + console.log('⏹️ [LocationTracking] 위치 추적 완전 종료 - 경로 초기화됨'); if (subscriber) { subscriber.remove(); setSubscriber(null); diff --git a/src/pages/Route/_components/RouteGrid.tsx b/src/pages/Route/_components/RouteGrid.tsx index 81e838b..824d263 100644 --- a/src/pages/Route/_components/RouteGrid.tsx +++ b/src/pages/Route/_components/RouteGrid.tsx @@ -158,6 +158,16 @@ export default function RouteGrid({ onRouteCardPress }: RouteGridProps) { } if (currentData.length === 0 && !loading && !error) { + console.log('📱 [RouteGrid] 빈 데이터 상태:', { + activeTab, + currentDataLength: currentData.length, + loading, + error, + coursesLength: courses.length, + bookmarkedCoursesLength: bookmarkedCourses.length, + completedCoursesLength: completedCourses.length, + }); + const getEmptyMessage = () => { switch (activeTab) { case 'created': diff --git a/src/pages/Route/index.tsx b/src/pages/Route/index.tsx index 8640066..5f7fc3d 100644 --- a/src/pages/Route/index.tsx +++ b/src/pages/Route/index.tsx @@ -42,6 +42,8 @@ export default function Route({ navigation }: Props) { courses, bookmarkedCourses, completedCourses, + loading, + error, } = useRouteStore(); const { loadCourses } = useRouteData(); const { loadBookmarkedCourses } = useBookmarkedCourses(); @@ -65,10 +67,31 @@ export default function Route({ navigation }: Props) { }; useEffect(() => { + console.log('📱 [Route] useEffect 실행:', { + activeTab, + coursesLength: courses.length, + bookmarkedCoursesLength: bookmarkedCourses.length, + completedCoursesLength: completedCourses.length, + loading, + error, + }); + if (activeTab === 'created' && courses.length === 0) { + console.log('📱 [Route] 생성한 경로 로드 시작'); loadCourses(true); + } else if (activeTab === 'liked' && bookmarkedCourses.length === 0) { + console.log('📱 [Route] 좋아요한 경로 로드 시작'); + loadBookmarkedCourses(true); + } else if (activeTab === 'completed' && completedCourses.length === 0) { + console.log('📱 [Route] 완주한 경로 로드 시작'); + loadCompletedCourses(true); } - }, [activeTab, courses.length, loadCourses]); + }, [ + activeTab, + courses.length, + bookmarkedCourses.length, + completedCourses.length, + ]); // loadCourses를 의존성에서 제거 const handleSettingsPress = () => {}; diff --git a/src/pages/Run/_components/ControlContainer.tsx b/src/pages/Run/_components/ControlContainer.tsx index cc780e4..6080218 100644 --- a/src/pages/Run/_components/ControlContainer.tsx +++ b/src/pages/Run/_components/ControlContainer.tsx @@ -44,14 +44,20 @@ const ControlContainer: React.FC = ({ const handleToggleTracking = () => { if (!isTracking && !startTime) { + // 새로운 런닝 시작 + console.log('🎬 [ControlContainer] 새로운 런닝 시작'); startRun(); const initialStats = calculateRunStats([], new Date(), true, 0, null); setRunning({ stats: initialStats }); toggleTracking(); } else if (!isTracking && startTime) { + // 일시정지된 런닝 재시작 - 기존 경로와 통계 유지 + console.log('▶️ [ControlContainer] 일시정지된 런닝 재시작'); resumeRun(); toggleTracking(); } else if (isTracking) { + // 런닝 일시정지 - 경로와 통계 유지 + console.log('⏸️ [ControlContainer] 런닝 일시정지'); pauseRun(); toggleTracking(); } diff --git a/src/pages/Run/index.tsx b/src/pages/Run/index.tsx index 9b49786..2059a54 100644 --- a/src/pages/Run/index.tsx +++ b/src/pages/Run/index.tsx @@ -88,8 +88,12 @@ export default function Run({ route, navigation }: Props) { useFocusEffect( useCallback(() => { - resetLocationTracking(); - resetRunState(); + // 런닝이 시작되지 않은 상태에서만 초기화 + const { startTime } = useRunStore.getState(); + if (!startTime) { + resetLocationTracking(); + resetRunState(); + } // courseId가 있을 때만 경로 데이터를 로드 if (courseId) { loadCourseTopology(); diff --git a/src/services/courses.service.ts b/src/services/courses.service.ts index 6273733..456143e 100644 --- a/src/services/courses.service.ts +++ b/src/services/courses.service.ts @@ -30,12 +30,26 @@ export async function searchUserCourses( params: CourseSearchRequest, accessToken: string, ): Promise { + console.log('🌐 [CoursesService] searchUserCourses 요청:', { + url: '/api/courses/search/users', + params, + headers: { + Authorization: `Bearer ${accessToken.substring(0, 20)}...`, + }, + }); + const response = await api.get('/api/courses/search/users', { params, headers: { Authorization: `Bearer ${accessToken}`, }, }); + + console.log('🌐 [CoursesService] searchUserCourses 응답:', { + status: response.status, + data: response.data, + }); + return response.data; } @@ -55,12 +69,26 @@ export async function searchBookmarkedCourses( params: CourseSearchRequest, accessToken: string, ): Promise { + console.log('🌐 [CoursesService] searchBookmarkedCourses 요청:', { + url: '/api/courses/search/bookmarked', + params, + headers: { + Authorization: `Bearer ${accessToken.substring(0, 20)}...`, + }, + }); + const response = await api.get('/api/courses/search/bookmarked', { params, headers: { Authorization: `Bearer ${accessToken}`, }, }); + + console.log('🌐 [CoursesService] searchBookmarkedCourses 응답:', { + status: response.status, + data: response.data, + }); + return response.data; } @@ -71,12 +99,26 @@ export async function searchCompletedCourses( }, accessToken: string, ): Promise { + console.log('🌐 [CoursesService] searchCompletedCourses 요청:', { + url: '/api/courses/search/completed', + params, + headers: { + Authorization: `Bearer ${accessToken.substring(0, 20)}...`, + }, + }); + const response = await api.get('/api/courses/search/completed', { params, headers: { Authorization: `Bearer ${accessToken}`, }, }); + + console.log('🌐 [CoursesService] searchCompletedCourses 응답:', { + status: response.status, + data: response.data, + }); + return response.data; } diff --git a/src/services/running.service.ts b/src/services/running.service.ts index e226042..3d56a2a 100644 --- a/src/services/running.service.ts +++ b/src/services/running.service.ts @@ -23,16 +23,35 @@ export async function createRunningRecord( }; const params = courseId ? { courseId } : undefined; + const endpoint = '/api/running/records'; - const response = await api.post('/api/running/records', requestData, { + console.log('📤 [RunningService] 런닝 기록 저장 API 요청'); + console.log('📤 [RunningService] 엔드포인트:', endpoint); + console.log('📤 [RunningService] 쿼리 파라미터:', params); + console.log('📤 [RunningService] 요청 페이로드:', { + path: `${requestData.path.length}개 좌표`, + startAt: new Date(requestData.startAt).toISOString(), + endAt: new Date(requestData.endAt).toISOString(), + pace: requestData.pace, + calories: requestData.calories, + imageUrl: requestData.imageUrl ? '있음' : '없음', + }); + + const response = await api.post(endpoint, requestData, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, params, }); + + console.log('📥 [RunningService] 런닝 기록 저장 API 응답'); + console.log('📥 [RunningService] 응답 상태:', response.status); + console.log('📥 [RunningService] 응답 데이터:', response.data); + return response.data; } catch (error: unknown) { + console.error('❌ [RunningService] 런닝 기록 저장 API 오류:', error); throw error; } } diff --git a/src/store/run.ts b/src/store/run.ts index d3d9b4f..9785915 100644 --- a/src/store/run.ts +++ b/src/store/run.ts @@ -185,23 +185,33 @@ const useRunStore = create((set, get) => ({ // 런닝 액션들 setRunning: (running) => set((state) => ({ ...state, ...running })), - startRun: () => + startRun: () => { + const startTime = new Date(); + console.log('🏃‍♂️ [RunStore] 새로운 런닝 시작:', startTime.toISOString()); set({ - startTime: new Date(), + startTime, pausedTime: 0, pauseStartTime: null, - }), + }); + }, - pauseRun: () => + pauseRun: () => { + const pauseStartTime = new Date(); + console.log('⏸️ [RunStore] 런닝 일시정지:', pauseStartTime.toISOString()); set({ - pauseStartTime: new Date(), - }), + pauseStartTime, + }); + }, resumeRun: () => set((state) => { if (state.pauseStartTime) { const pauseDuration = (new Date().getTime() - state.pauseStartTime.getTime()) / 1000; + console.log('▶️ [RunStore] 런닝 재시작:', { + pauseDuration: `${pauseDuration.toFixed(1)}초`, + totalPausedTime: `${(state.pausedTime + pauseDuration).toFixed(1)}초`, + }); return { pausedTime: state.pausedTime + pauseDuration, pauseStartTime: null, @@ -210,13 +220,15 @@ const useRunStore = create((set, get) => ({ return state; }), - stopRun: () => + stopRun: () => { + console.log('⏹️ [RunStore] 런닝 완전 종료 - 통계 초기화됨'); set({ startTime: null, pausedTime: 0, pauseStartTime: null, stats: initialStats, - }), + }); + }, // 코스 액션들 setCourseTopology: (courseTopology) => set({ courseTopology }), @@ -282,15 +294,42 @@ const useRunStore = create((set, get) => ({ }), resetRunState: () => { - set((state) => ({ - ...initialUIState, - ...initialErrorState, - ...initialRunningState, - // 코스 데이터는 유지 (currentCourseId, currentCourseData 보존) - courseTopology: null, - ...initialLocationTrackingState, - ...initialCourseValidationState, - })); + set((state) => { + // 런닝이 진행 중이면 상태를 보존 + if (state.startTime) { + console.log('🔄 [RunStore] 런닝 상태 보존 - 통계 초기화되지 않음'); + return { + ...initialUIState, + ...initialErrorState, + // 런닝 상태는 보존 + startTime: state.startTime, + pausedTime: state.pausedTime, + pauseStartTime: state.pauseStartTime, + stats: state.stats, + // 코스 데이터는 유지 (currentCourseId, currentCourseData 보존) + courseTopology: null, + // 위치 추적 상태는 보존 (경로 데이터 유지) + routeCoordinates: state.routeCoordinates, + location: state.location, + isTracking: state.isTracking, + subscriber: state.subscriber, + errorMsg: state.errorMsg, + ...initialCourseValidationState, + }; + } + + // 런닝이 시작되지 않은 경우에만 완전 초기화 + console.log('🔄 [RunStore] 런닝 상태 완전 초기화 - 통계 초기화됨'); + return { + ...initialUIState, + ...initialErrorState, + ...initialRunningState, + // 코스 데이터는 유지 (currentCourseId, currentCourseData 보존) + courseTopology: null, + ...initialLocationTrackingState, + ...initialCourseValidationState, + }; + }); }, })); From 6a623174e1a74c1f1a0982aa46500c360b4b52db Mon Sep 17 00:00:00 2001 From: Monixc Date: Mon, 15 Sep 2025 09:17:15 +0900 Subject: [PATCH 24/24] =?UTF-8?q?refactor:=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=ED=99=95=EC=9D=B8=20=EB=B0=8F=20API=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useRouteData, useBookmarkedCourses, useCompletedCourses 훅에서 로딩 상태 확인 방식을 간소화하여 가독성 향상 - 불필요한 로그 메시지를 제거하고, API 호출 시 요청 및 응답 로그를 삭제하여 코드 정리 - Route 컴포넌트의 useEffect 의존성에서 불필요한 항목을 제거하여 성능 개선 --- src/hooks/api/useRouteApi.ts | 102 +++------------------- src/pages/Route/_components/RouteGrid.tsx | 10 --- src/pages/Route/index.tsx | 25 +----- src/services/courses.service.ts | 42 --------- 4 files changed, 14 insertions(+), 165 deletions(-) diff --git a/src/hooks/api/useRouteApi.ts b/src/hooks/api/useRouteApi.ts index 42fc8ea..3abea5d 100644 --- a/src/hooks/api/useRouteApi.ts +++ b/src/hooks/api/useRouteApi.ts @@ -28,9 +28,7 @@ export function useRouteData() { const loadCourses = useCallback( async (reset = false) => { - // loading 상태를 함수 내부에서 직접 확인 - const currentLoading = useRouteStore.getState().loading; - if (!accessToken || currentLoading) return; + if (!accessToken || loading) return; setLoading(true); setError(null); @@ -38,27 +36,13 @@ export function useRouteData() { try { // cursor를 함수 내부에서 직접 가져오기 const currentCursor = reset ? null : useRouteStore.getState().cursor; - const requestParams = { - cursor: currentCursor ? { id: currentCursor } : null, - limit: 10, - }; - - console.log('🔍 [RouteAPI] 생성한 경로 요청:', { - endpoint: '/api/courses/user', - params: requestParams, - accessToken: accessToken ? '있음' : '없음', - }); - - const response = await searchUserCourses(requestParams, accessToken); - - console.log('✅ [RouteAPI] 생성한 경로 응답:', { - resultsCount: response.results.length, - results: response.results.map((course) => ({ - id: course.id, - title: course.title, - createdAt: course.createdAt, - })), - }); + const response = await searchUserCourses( + { + cursor: currentCursor ? { id: currentCursor } : null, + limit: 10, + }, + accessToken, + ); if (reset) { setCourses(response.results); @@ -77,20 +61,12 @@ export function useRouteData() { setCursor(nextCursor); setHasMore(response.results.length >= 10); } catch (error: unknown) { - console.error('❌ [RouteAPI] 생성한 경로 요청 실패:', error); - let errorMessage = '경로를 불러오는데 실패했습니다.'; if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as AxiosErrorResponse; const status = axiosError.status; - console.error('❌ [RouteAPI] HTTP 오류:', { - status, - statusText: axiosError.statusText, - data: axiosError.data, - }); - if (status === 500) { errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; @@ -110,7 +86,7 @@ export function useRouteData() { }, [ accessToken, - // loading을 의존성에서 제거하여 무한 루프 방지 + loading, setCourses, setLoading, setError, @@ -165,9 +141,7 @@ export function useBookmarkedCourses() { const loadBookmarkedCourses = useCallback( async (reset = false) => { - // loading 상태를 함수 내부에서 직접 확인 - const currentLoading = useRouteStore.getState().loading; - if (!accessToken || currentLoading) return; + if (!accessToken || loading) return; setLoading(true); setError(null); @@ -178,24 +152,8 @@ export function useBookmarkedCourses() { cursor: currentCursor ? { id: currentCursor } : null, limit: 10, }; - - console.log('🔍 [RouteAPI] 북마크한 경로 요청:', { - endpoint: '/api/courses/search/bookmarked', - params, - accessToken: accessToken ? '있음' : '없음', - }); - const response = await searchBookmarkedCourses(params, accessToken); - console.log('✅ [RouteAPI] 북마크한 경로 응답:', { - resultsCount: response.results.length, - results: response.results.map((course) => ({ - id: course.id, - title: course.title, - createdAt: course.createdAt, - })), - }); - if (reset) { setBookmarkedCourses(response.results); } else { @@ -213,20 +171,12 @@ export function useBookmarkedCourses() { setCursor(nextCursor); setHasMore(response.results.length === 10); } catch (error: unknown) { - console.error('❌ [RouteAPI] 북마크한 경로 요청 실패:', error); - let errorMessage = '북마크한 경로를 불러오는데 실패했습니다.'; if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as AxiosErrorResponse; const status = axiosError.status; - console.error('❌ [RouteAPI] HTTP 오류:', { - status, - statusText: axiosError.statusText, - data: axiosError.data, - }); - if (status === 500) { errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; @@ -247,7 +197,7 @@ export function useBookmarkedCourses() { [ accessToken, cursor, - // loading을 의존성에서 제거하여 무한 루프 방지 + loading, setBookmarkedCourses, setLoading, setError, @@ -302,9 +252,7 @@ export function useCompletedCourses() { const loadCompletedCourses = useCallback( async (reset = false) => { - // loading 상태를 함수 내부에서 직접 확인 - const currentLoading = useRouteStore.getState().loading; - if (!accessToken || currentLoading) return; + if (!accessToken || loading) return; setLoading(true); setError(null); @@ -315,24 +263,8 @@ export function useCompletedCourses() { cursor: currentCursor ? { id: currentCursor } : null, limit: 10, }; - - console.log('🔍 [RouteAPI] 완주한 경로 요청:', { - endpoint: '/api/courses/search/completed', - params, - accessToken: accessToken ? '있음' : '없음', - }); - const response = await searchCompletedCourses(params, accessToken); - console.log('✅ [RouteAPI] 완주한 경로 응답:', { - resultsCount: response.results.length, - results: response.results.map((course) => ({ - id: course.id, - title: course.title, - createdAt: course.createdAt, - })), - }); - if (reset) { setCompletedCourses(response.results); } else { @@ -354,20 +286,12 @@ export function useCompletedCourses() { setCursor(nextCursor); setHasMore(!!response.nextCursor); } catch (error: unknown) { - console.error('❌ [RouteAPI] 완주한 경로 요청 실패:', error); - let errorMessage = '완주한 경로를 불러오는데 실패했습니다.'; if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as AxiosErrorResponse; const status = axiosError.status; - console.error('❌ [RouteAPI] HTTP 오류:', { - status, - statusText: axiosError.statusText, - data: axiosError.data, - }); - if (status === 500) { errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; @@ -388,7 +312,7 @@ export function useCompletedCourses() { [ accessToken, cursor, - // loading을 의존성에서 제거하여 무한 루프 방지 + loading, setCompletedCourses, setLoading, setError, diff --git a/src/pages/Route/_components/RouteGrid.tsx b/src/pages/Route/_components/RouteGrid.tsx index 824d263..81e838b 100644 --- a/src/pages/Route/_components/RouteGrid.tsx +++ b/src/pages/Route/_components/RouteGrid.tsx @@ -158,16 +158,6 @@ export default function RouteGrid({ onRouteCardPress }: RouteGridProps) { } if (currentData.length === 0 && !loading && !error) { - console.log('📱 [RouteGrid] 빈 데이터 상태:', { - activeTab, - currentDataLength: currentData.length, - loading, - error, - coursesLength: courses.length, - bookmarkedCoursesLength: bookmarkedCourses.length, - completedCoursesLength: completedCourses.length, - }); - const getEmptyMessage = () => { switch (activeTab) { case 'created': diff --git a/src/pages/Route/index.tsx b/src/pages/Route/index.tsx index 5f7fc3d..8640066 100644 --- a/src/pages/Route/index.tsx +++ b/src/pages/Route/index.tsx @@ -42,8 +42,6 @@ export default function Route({ navigation }: Props) { courses, bookmarkedCourses, completedCourses, - loading, - error, } = useRouteStore(); const { loadCourses } = useRouteData(); const { loadBookmarkedCourses } = useBookmarkedCourses(); @@ -67,31 +65,10 @@ export default function Route({ navigation }: Props) { }; useEffect(() => { - console.log('📱 [Route] useEffect 실행:', { - activeTab, - coursesLength: courses.length, - bookmarkedCoursesLength: bookmarkedCourses.length, - completedCoursesLength: completedCourses.length, - loading, - error, - }); - if (activeTab === 'created' && courses.length === 0) { - console.log('📱 [Route] 생성한 경로 로드 시작'); loadCourses(true); - } else if (activeTab === 'liked' && bookmarkedCourses.length === 0) { - console.log('📱 [Route] 좋아요한 경로 로드 시작'); - loadBookmarkedCourses(true); - } else if (activeTab === 'completed' && completedCourses.length === 0) { - console.log('📱 [Route] 완주한 경로 로드 시작'); - loadCompletedCourses(true); } - }, [ - activeTab, - courses.length, - bookmarkedCourses.length, - completedCourses.length, - ]); // loadCourses를 의존성에서 제거 + }, [activeTab, courses.length, loadCourses]); const handleSettingsPress = () => {}; diff --git a/src/services/courses.service.ts b/src/services/courses.service.ts index 456143e..6273733 100644 --- a/src/services/courses.service.ts +++ b/src/services/courses.service.ts @@ -30,26 +30,12 @@ export async function searchUserCourses( params: CourseSearchRequest, accessToken: string, ): Promise { - console.log('🌐 [CoursesService] searchUserCourses 요청:', { - url: '/api/courses/search/users', - params, - headers: { - Authorization: `Bearer ${accessToken.substring(0, 20)}...`, - }, - }); - const response = await api.get('/api/courses/search/users', { params, headers: { Authorization: `Bearer ${accessToken}`, }, }); - - console.log('🌐 [CoursesService] searchUserCourses 응답:', { - status: response.status, - data: response.data, - }); - return response.data; } @@ -69,26 +55,12 @@ export async function searchBookmarkedCourses( params: CourseSearchRequest, accessToken: string, ): Promise { - console.log('🌐 [CoursesService] searchBookmarkedCourses 요청:', { - url: '/api/courses/search/bookmarked', - params, - headers: { - Authorization: `Bearer ${accessToken.substring(0, 20)}...`, - }, - }); - const response = await api.get('/api/courses/search/bookmarked', { params, headers: { Authorization: `Bearer ${accessToken}`, }, }); - - console.log('🌐 [CoursesService] searchBookmarkedCourses 응답:', { - status: response.status, - data: response.data, - }); - return response.data; } @@ -99,26 +71,12 @@ export async function searchCompletedCourses( }, accessToken: string, ): Promise { - console.log('🌐 [CoursesService] searchCompletedCourses 요청:', { - url: '/api/courses/search/completed', - params, - headers: { - Authorization: `Bearer ${accessToken.substring(0, 20)}...`, - }, - }); - const response = await api.get('/api/courses/search/completed', { params, headers: { Authorization: `Bearer ${accessToken}`, }, }); - - console.log('🌐 [CoursesService] searchCompletedCourses 응답:', { - status: response.status, - data: response.data, - }); - return response.data; }