diff --git a/eas.json b/eas.json index bdcde1c..a29eb10 100644 --- a/eas.json +++ b/eas.json @@ -10,13 +10,20 @@ "preview": { "distribution": "internal" }, - "production": {}, + "production": { + "android": { + "buildType": "apk" + } + }, "development-simulator": { "developmentClient": true, "distribution": "internal", "ios": { "simulator": true }, + "android": { + "buildType": "apk" + }, "environment": "development" } }, diff --git a/src/components/Card.tsx b/src/components/Card.tsx index d871e4b..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?.hasStar && ( - + )} @@ -86,16 +89,19 @@ export default function Card({ if (finalMode === 'image-with-text') { const contentWidth = screenWidth - 64; - const imageSize = contentWidth * 0.45; + const imageSize = contentWidth * 0.36; // 0.45에서 0.36으로 변경 (약 20% 감소) return ( - {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', @@ -254,7 +278,7 @@ const LoadingContainer = styled.View({ }); const LoadingText = styled.Text({ - color: '#007AFF', + color: '#2d2d2d', fontSize: 12, fontWeight: '500', }); diff --git a/src/components/Map.tsx b/src/components/Map.tsx index 86389c6..19dc9f1 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -7,8 +7,9 @@ import { INITIAL_ZOOM_LEVEL } from '@/constants/location'; interface MapProps { mapRef: RefObject; cameraRef: RefObject; - initialLocation: Position; + initialLocation: Position | null; onUserLocationUpdate?: (location: Mapbox.Location) => void; + onMapReady?: () => void; children?: React.ReactNode; showUserLocation?: boolean; } @@ -18,15 +19,23 @@ export default function Map({ cameraRef, initialLocation, onUserLocationUpdate, + onMapReady, children, showUserLocation = true, }: MapProps) { + // initialLocation이 없으면 기본 서울 좌표 사용 + const safeInitialLocation = initialLocation || [127.0276, 37.4979]; + return ( - + 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/hooks/api/useCourseTopologyApi.ts b/src/hooks/api/useCourseTopologyApi.ts index d3f5330..cdac29c 100644 --- a/src/hooks/api/useCourseTopologyApi.ts +++ b/src/hooks/api/useCourseTopologyApi.ts @@ -8,7 +8,9 @@ export function useCourseTopologyApi(courseId?: number) { const { setUI, setError, setCourseTopology } = useRunStore(); const loadCourseTopology = useCallback(async () => { - if (!courseId) return; + if (!courseId) { + return; + } try { setUI({ loading: true }); @@ -42,8 +44,10 @@ export function useCourseTopologyApi(courseId?: number) { }, [courseId, fetchCourseTopology, setUI, setError, setCourseTopology]); useEffect(() => { - loadCourseTopology(); - }, [loadCourseTopology]); + if (courseId) { + loadCourseTopology(); + } + }, [courseId, loadCourseTopology]); return { loadCourseTopology, 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/hooks/api/useRouteApi.ts b/src/hooks/api/useRouteApi.ts index 30fed48..3abea5d 100644 --- a/src/hooks/api/useRouteApi.ts +++ b/src/hooks/api/useRouteApi.ts @@ -1,8 +1,14 @@ import { useCallback } from 'react'; -import { searchUserCourses } from '@/services/courses.service'; +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(); @@ -28,9 +34,13 @@ export function useRouteData() { setError(null); try { - const currentCursor = reset ? null : cursor; + // cursor를 함수 내부에서 직접 가져오기 + const currentCursor = reset ? null : useRouteStore.getState().cursor; const response = await searchUserCourses( - { cursor: currentCursor, limit: 10 }, + { + cursor: currentCursor ? { id: currentCursor } : null, + limit: 10, + }, accessToken, ); @@ -49,7 +59,7 @@ export function useRouteData() { } setCursor(nextCursor); - setHasMore(response.results.length === 10); + setHasMore(response.results.length >= 10); } catch (error: unknown) { let errorMessage = '경로를 불러오는데 실패했습니다.'; @@ -76,7 +86,6 @@ export function useRouteData() { }, [ accessToken, - cursor, loading, setCourses, setLoading, @@ -113,3 +122,258 @@ export function useRouteData() { handleRefresh, }; } + +export function useBookmarkedCourses() { + const { accessToken } = useAuthStore(); + const { + cursor, + loading, + hasMore, + error, + setBookmarkedCourses, + setLoading, + setRefreshing, + setError, + setCursor, + setHasMore, + appendBookmarkedCourses, + } = useRouteStore(); + + const loadBookmarkedCourses = useCallback( + async (reset = false) => { + if (!accessToken || loading) return; + + setLoading(true); + setError(null); + + try { + const currentCursor = reset ? null : cursor; + const params = { + cursor: currentCursor ? { id: currentCursor } : null, + limit: 10, + }; + const response = await searchBookmarkedCourses(params, accessToken); + + if (reset) { + setBookmarkedCourses(response.results); + } else { + appendBookmarkedCourses(response.results); + } + + let nextCursor = null; + if (response.results.length > 0) { + const minId = Math.min( + ...response.results.map((course) => course.id), + ); + nextCursor = minId; + } + + setCursor(nextCursor); + setHasMore(response.results.length === 10); + } catch (error: unknown) { + 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, + loading, + setBookmarkedCourses, + setLoading, + setError, + setCursor, + setHasMore, + appendBookmarkedCourses, + ], + ); + + const handleLoadMore = useCallback(() => { + if (hasMore && !loading && !error) { + loadBookmarkedCourses(false); + } + }, [hasMore, loading, error, loadBookmarkedCourses]); + + const handleRetry = useCallback(() => { + setError(null); + loadBookmarkedCourses(true); + }, [setError, loadBookmarkedCourses]); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + setError(null); + setCursor(null); + await loadBookmarkedCourses(true); + setRefreshing(false); + }, [setRefreshing, setError, setCursor, loadBookmarkedCourses]); + + return { + loadBookmarkedCourses, + handleLoadMore, + handleRetry, + handleRefresh, + }; +} + +export function useCompletedCourses() { + const { accessToken } = useAuthStore(); + const { + cursor, + loading, + hasMore, + error, + setCompletedCourses, + setLoading, + setRefreshing, + setError, + setCursor, + setHasMore, + appendCompletedCourses, + } = useRouteStore(); + + const loadCompletedCourses = useCallback( + async (reset = false) => { + if (!accessToken || loading) return; + + setLoading(true); + setError(null); + + try { + const currentCursor = reset ? null : cursor; + const params = { + cursor: currentCursor ? { id: currentCursor } : null, + limit: 10, + }; + const response = await searchCompletedCourses(params, accessToken); + + if (reset) { + setCompletedCourses(response.results); + } else { + appendCompletedCourses(response.results); + } + + let nextCursor = null; + if (response.nextCursor) { + // 새로운 API는 nextCursor를 문자열로 반환하므로, + // 마지막 아이템의 ID를 숫자로 변환하여 저장 + if (response.results.length > 0) { + const minId = Math.min( + ...response.results.map((course) => course.id), + ); + nextCursor = minId; + } + } + + setCursor(nextCursor); + setHasMore(!!response.nextCursor); + } catch (error: unknown) { + 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, + loading, + setCompletedCourses, + setLoading, + setError, + setCursor, + setHasMore, + appendCompletedCourses, + ], + ); + + const handleLoadMore = useCallback(() => { + if (hasMore && !loading && !error) { + loadCompletedCourses(false); + } + }, [hasMore, loading, error, loadCompletedCourses]); + + const handleRetry = useCallback(() => { + setError(null); + loadCompletedCourses(true); + }, [setError, loadCompletedCourses]); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + setError(null); + setCursor(null); + await loadCompletedCourses(true); + setRefreshing(false); + }, [setRefreshing, setError, setCursor, loadCompletedCourses]); + + return { + loadCompletedCourses, + handleLoadMore, + handleRetry, + 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/useCourseData.ts b/src/hooks/useCourseData.ts new file mode 100644 index 0000000..6cdda40 --- /dev/null +++ b/src/hooks/useCourseData.ts @@ -0,0 +1,130 @@ +import { useState, useCallback } from 'react'; +import { + searchUserCourses, + searchBookmarkedCourses, +} from '@/services/courses.service'; +import { reverseGeocode } from '@/lib/geocoding'; +import type { + BookmarkedCourseItem, + CourseSearchItem, + CompletedCourseItem, +} from '@/types/courses.types'; + +interface UseCourseDataOptions { + courseId: number; + courseData?: BookmarkedCourseItem | CourseSearchItem | CompletedCourseItem; + accessToken: string | null; +} + +export function useCourseData({ + courseId, + courseData, + accessToken, +}: UseCourseDataOptions) { + const [data, setData] = useState< + BookmarkedCourseItem | CourseSearchItem | CompletedCourseItem | null + >(null); + const [address, setAddress] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadAddress = useCallback( + async ( + departure: + | [number, number] + | { lon: number; lat: number } + | [number, number][], + ) => { + try { + let coords: [number, number]; + + if (Array.isArray(departure) && departure.length > 0) { + if (Array.isArray(departure[0])) { + coords = departure[0] as [number, number]; + } else { + coords = departure as [number, number]; + } + } else { + const dep = departure as { lon: number; lat: number }; + coords = [dep.lon, dep.lat]; + } + + const result = await reverseGeocode(coords); + setAddress(result.address); + } catch (err) { + setAddress('주소 정보 없음'); + } + }, + [], + ); + + const loadCourseData = useCallback(async () => { + if (!accessToken) { + return; + } + + setLoading(true); + setError(null); + + try { + if (courseData) { + setData(courseData); + if ('departure' in courseData && courseData.departure) { + await loadAddress(courseData.departure); + } else if ( + 'path' in courseData && + courseData.path && + Array.isArray(courseData.path) && + courseData.path.length > 0 + ) { + await loadAddress(courseData.path); + } + return; + } + + const userCoursesResponse = await searchUserCourses( + { cursor: null, limit: 100 }, + accessToken, + ); + + const userCourse = userCoursesResponse.results.find( + (course) => course.id === courseId, + ); + + if (userCourse) { + setData(userCourse); + await loadAddress(userCourse.departure); + return; + } + + const bookmarkedResponse = await searchBookmarkedCourses( + { cursor: null, limit: 100 }, + accessToken, + ); + + const bookmarkedCourse = bookmarkedResponse.results.find( + (course) => course.id === courseId, + ); + + if (bookmarkedCourse) { + setData(bookmarkedCourse); + await loadAddress(bookmarkedCourse.departure); + return; + } + + setError('경로를 찾을 수 없습니다.'); + } catch (err) { + setError('경로 정보를 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + } + }, [accessToken, courseId, courseData, loadAddress]); + + return { + courseData: data, + address, + loading, + error, + loadCourseData, + }; +} diff --git a/src/hooks/useCourseValidation.ts b/src/hooks/useCourseValidation.ts index 7d8f7f4..509d3ae 100644 --- a/src/hooks/useCourseValidation.ts +++ b/src/hooks/useCourseValidation.ts @@ -12,6 +12,7 @@ interface UseCourseValidationOptions { validationOptions?: CourseValidationOptions; enableRealTimeValidation?: boolean; validationInterval?: number; // 밀리초 + courseId?: number; // 코스 ID 추가 } export function useCourseValidation(options: UseCourseValidationOptions = {}) { @@ -19,6 +20,7 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { validationOptions = { tolerance: 50, enableDistanceCalculation: true }, enableRealTimeValidation = true, validationInterval = 5000, // 5초마다 검증 + courseId, } = options; const { @@ -40,6 +42,7 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { // 실시간 위치 검증 (트래킹 중이고 좌표가 실제로 이동했을 때만) const validateCurrentLocation = useCallback(() => { if ( + !courseId || !location || !location.coords || !courseTopology || @@ -79,21 +82,12 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { validationOptions, ); - // 디버깅 로그 - console.log('🔍 [CourseValidation] 검증 실행:', { - currentPosition, - isTracking, - routeCoordinatesLength: routeCoordinates.length, - isDeviating: deviationResult.isDeviating, - severity: deviationResult.deviationSeverity, - distance: deviationResult.validationResult.distanceFromCourse, - }); - // 검증 결과 업데이트 updateCourseValidation(deviationResult.validationResult); return deviationResult; }, [ + courseId, location, courseTopology, isTracking, @@ -105,7 +99,7 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { // 안전 거리 계산 const getSafetyDistance = useCallback(() => { - if (!location || !location.coords || !courseTopology) { + if (!courseId || !location || !location.coords || !courseTopology) { return null; } @@ -114,7 +108,7 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { location.coords.latitude, ]; return calculateSafetyDistance(currentPosition, courseTopology); - }, [location, courseTopology]); + }, [courseId, location, courseTopology]); // 코스 복귀 여부 확인 const checkCourseReturn = useCallback(() => { @@ -160,6 +154,7 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { // routeCoordinates 변경 시에만 검증 실행 (트래킹 중일 때만) useEffect(() => { if ( + !courseId || !enableRealTimeValidation || !courseTopology || !location || @@ -172,6 +167,7 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { // routeCoordinates가 변경될 때만 검증 실행 validateCurrentLocation(); }, [ + courseId, enableRealTimeValidation, courseTopology, location, @@ -181,7 +177,7 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { // 코스 변경 시 검증 히스토리 초기화 및 즉시 검증 실행 (트래킹 중일 때만) useEffect(() => { - if (courseTopology) { + if (courseId && courseTopology) { clearValidationHistory(); // 이전 좌표 ref 초기화 lastValidatedCoordinateRef.current = null; @@ -196,6 +192,7 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { } } }, [ + courseId, courseTopology, clearValidationHistory, location, diff --git a/src/hooks/useImageLoading.ts b/src/hooks/useImageLoading.ts new file mode 100644 index 0000000..dd2a330 --- /dev/null +++ b/src/hooks/useImageLoading.ts @@ -0,0 +1,28 @@ +import { useState, useCallback } from 'react'; + +export function useImageLoading() { + const [imageError, setImageError] = useState(false); + const [imageLoading, setImageLoading] = useState(true); + + const handleImageError = useCallback(() => { + setImageError(true); + setImageLoading(false); + }, []); + + const handleImageLoad = useCallback(() => { + setImageLoading(false); + }, []); + + const resetImageState = useCallback(() => { + setImageError(false); + setImageLoading(true); + }, []); + + return { + imageError, + imageLoading, + handleImageError, + handleImageLoad, + resetImageState, + }; +} diff --git a/src/hooks/useInitialLocation.ts b/src/hooks/useInitialLocation.ts index 554aa78..7fbc233 100644 --- a/src/hooks/useInitialLocation.ts +++ b/src/hooks/useInitialLocation.ts @@ -15,6 +15,8 @@ export function useInitialLocation(options: UseInitialLocationOptions = {}) { useEffect(() => { (async () => { + setLoading(true); + // 권한 상태 체크 const { status: currentStatus } = await Location.getForegroundPermissionsAsync(); @@ -38,16 +40,30 @@ export function useInitialLocation(options: UseInitialLocationOptions = {}) { let fetchedLocation: Location.LocationObject | null = null; try { - fetchedLocation = await Location.getLastKnownPositionAsync({}); - if (!fetchedLocation) { - fetchedLocation = await Location.getCurrentPositionAsync({}); - } + // 무조건 현재 위치를 가져오려고 시도 + console.log('📍 현재 위치 요청 중...'); + fetchedLocation = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.High, + }); + console.log('📍 현재 위치 획득 성공:', fetchedLocation.coords); } catch (error) { - console.error('위치 가져오기 오류', error); + console.log('📍 현재 위치 실패, 마지막 알려진 위치 시도...'); + try { + // 현재 위치 실패 시 마지막 알려진 위치 시도 + fetchedLocation = await Location.getLastKnownPositionAsync({}); + if (fetchedLocation) { + console.log('📍 마지막 알려진 위치 획득:', fetchedLocation.coords); + } + } catch (lastKnownError) { + console.log('📍 모든 위치 시도 실패'); + } } if (fetchedLocation) { setLocation(fetchedLocation); + console.log('📍 위치 설정 완료'); + } else { + console.log('📍 위치 정보 없음, 기본값 사용'); } setLoading(false); })(); diff --git a/src/hooks/useLocationManager.ts b/src/hooks/useLocationManager.ts index 8f1e085..f1118fa 100644 --- a/src/hooks/useLocationManager.ts +++ b/src/hooks/useLocationManager.ts @@ -17,6 +17,10 @@ export function useLocationManager() { initialLocation.coords.longitude, initialLocation.coords.latitude, ]; + console.log( + '📍 useLocationManager: 위치 업데이트됨', + currentUserLocation.current, + ); } }, [initialLocation]); @@ -74,8 +78,8 @@ export function useLocationManager() { }; return { - initialLocation, - locationLoading, + initialLocation: currentUserLocation.current, // 현재 위치만 사용 + locationLoading: locationLoading, location, errorMsg, refreshLocation, diff --git a/src/hooks/useLocationTracking.ts b/src/hooks/useLocationTracking.ts index 13a299a..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( @@ -54,13 +64,6 @@ export function useLocationTracking() { (newLocation) => { const { latitude, longitude } = newLocation.coords; - // 디버깅 로그 - console.log('📍 [LocationTracking] 위치 업데이트:', { - latitude, - longitude, - timestamp: new Date().toISOString(), - }); - setLocation(newLocation); const newCoordinate: Position = [longitude, latitude]; @@ -93,6 +96,7 @@ export function useLocationTracking() { ]); const pauseTracking = useCallback(() => { + console.log('⏸️ [LocationTracking] 위치 추적 일시정지'); if (subscriber) { subscriber.remove(); setSubscriber(null); @@ -101,6 +105,7 @@ export function useLocationTracking() { }, [subscriber, setSubscriber, setIsTracking]); const stopTracking = useCallback(() => { + console.log('⏹️ [LocationTracking] 위치 추적 완전 종료 - 경로 초기화됨'); if (subscriber) { subscriber.remove(); setSubscriber(null); @@ -146,7 +151,6 @@ export function useLocationTracking() { setLocationErrorMsg('현재 위치를 가져올 수 없습니다.'); } } catch (error) { - console.error('위치 새로고침 오류', error); setLocationErrorMsg('현재 위치를 가져올 수 없습니다.'); } }, [setLocationErrorMsg, setLocation]); diff --git a/src/hooks/useLongPress.ts b/src/hooks/useLongPress.ts new file mode 100644 index 0000000..fc1eba5 --- /dev/null +++ b/src/hooks/useLongPress.ts @@ -0,0 +1,54 @@ +import { useState, useRef, useCallback } from 'react'; +import { Animated } from 'react-native'; + +interface UseLongPressOptions { + duration?: number; + onComplete: () => void; +} + +export function useLongPress({ + duration = 3000, + onComplete, +}: UseLongPressOptions) { + const [isPressing, setIsPressing] = useState(false); + + const pressTimer = useRef(null); + const animatedValue = useRef(new Animated.Value(0)).current; + + const startPress = useCallback(() => { + setIsPressing(true); + + pressTimer.current = setTimeout(() => { + onComplete(); + stopPress(); + }, duration); + + Animated.timing(animatedValue, { + toValue: 1, + duration, + useNativeDriver: false, + }).start(); + }, [duration, onComplete, animatedValue]); + + const stopPress = useCallback(() => { + setIsPressing(false); + + if (pressTimer.current) { + clearTimeout(pressTimer.current); + pressTimer.current = null; + } + + Animated.timing(animatedValue, { + toValue: 0, + duration: 200, + useNativeDriver: false, + }).start(); + }, [animatedValue]); + + return { + isPressing, + animatedValue, + startPress, + stopPress, + }; +} 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/hooks/useRunMap.ts b/src/hooks/useRunMap.ts index d1183d8..694f866 100644 --- a/src/hooks/useRunMap.ts +++ b/src/hooks/useRunMap.ts @@ -8,6 +8,7 @@ export function useRunMap( cameraRef?: React.RefObject, location?: Position | null, routeCoordinates?: Position[], + courseId?: number, ) { const { courseTopology, isLocked } = useRunStore(); const { fitToCoordinates } = useLocationManager(); @@ -24,32 +25,34 @@ export function useRunMap( }, }; - // 코스 shape 폴리곤들 생성 + // 코스 shape 폴리곤들 생성 - courseId가 있을 때만 const courseShapePolygons = - courseTopology?.shape.map((polygonCoords, index) => ({ - type: 'Feature' as const, - properties: { id: index }, - geometry: { - type: 'Polygon' as const, - coordinates: [polygonCoords], - }, - })) || []; + courseId && courseTopology?.shape + ? courseTopology.shape.map((polygonCoords, index) => ({ + type: 'Feature' as const, + properties: { id: index }, + geometry: { + type: 'Polygon' as const, + coordinates: [polygonCoords], + }, + })) + : []; const courseShapeGeoJSON = { type: 'FeatureCollection' as const, features: courseShapePolygons, }; - // 코스 토폴로지가 로드되면 지도에 맞춤 + // 코스 토폴로지가 로드되면 지도에 맞춤 - courseId가 있을 때만 useEffect(() => { - if (courseTopology && courseTopology.shape.length > 0) { + if (courseId && courseTopology && courseTopology.shape.length > 0) { const allCoordinates: [number, number][] = courseTopology.shape.flat(1); if (allCoordinates.length > 0) { fitToCoordinates(finalCameraRef, allCoordinates); } } - }, [courseTopology, fitToCoordinates, finalCameraRef]); + }, [courseId, courseTopology, fitToCoordinates, finalCameraRef]); return { cameraRef: finalCameraRef, diff --git a/src/hooks/useRunModals.ts b/src/hooks/useRunModals.ts index f27b0b3..518c790 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; @@ -33,6 +33,7 @@ export function useRunModals({ setError, setUI, resetRunState, + clearCurrentCourse, startTime, stats, } = useRunStore(); @@ -58,10 +59,11 @@ export function useRunModals({ const cleanupAndGoBack = useCallback(() => { resetLocationTracking(); resetRunState(); + clearCurrentCourse(); // currentCourseId를 undefined로 초기화 // courseId 파라미터 초기화 navigation.setParams({ courseId: undefined }); navigation.goBack(); - }, [resetLocationTracking, resetRunState, navigation]); + }, [resetLocationTracking, resetRunState, clearCurrentCourse, navigation]); const handleConfirmBack = cleanupAndGoBack; @@ -80,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); @@ -113,7 +127,6 @@ export function useRunModals({ imageUrl = publicImageUrl; } } catch (imageError) { - console.warn('이미지 처리 실패:', imageError); Toast.show({ type: 'info', text1: '이미지 저장 실패', @@ -130,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', @@ -142,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) { @@ -155,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; } diff --git a/src/hooks/useRunStats.ts b/src/hooks/useRunStats.ts index bd1e299..ea15433 100644 --- a/src/hooks/useRunStats.ts +++ b/src/hooks/useRunStats.ts @@ -1,16 +1,18 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useCallback } from 'react'; import type { Position } from 'geojson'; import { calculateRunStats } from '@/utils/runStats'; import useRunStore from '@/store/run'; export function useRunStats(routeCoordinates: Position[], isTracking: boolean) { - const { startTime, pausedTime, pauseStartTime, setRunning } = useRunStore(); + const { startTime, pausedTime, pauseStartTime, setRunning, stats } = + useRunStore(); // 타이머 업데이트를 위한 ref들 const routeCoordinatesRef = useRef(routeCoordinates); const isTrackingRef = useRef(isTracking); const pausedTimeRef = useRef(pausedTime); const pauseStartTimeRef = useRef(pauseStartTime); + const lastStatsRef = useRef(stats || null); // ref 값들을 최신으로 유지 useEffect(() => { @@ -29,35 +31,52 @@ export function useRunStats(routeCoordinates: Position[], isTracking: boolean) { pauseStartTimeRef.current = pauseStartTime; }, [pauseStartTime]); - // 초기 통계 계산 (startTime이 설정될 때만) useEffect(() => { - if (startTime) { - const newStats = calculateRunStats( - routeCoordinates, - startTime, - isTracking, - pausedTime, - pauseStartTime, - ); + lastStatsRef.current = stats || null; + }, [stats]); + + // 통계 업데이트 함수 (이전 값과 비교해서 변경된 경우에만 업데이트) + const updateStats = useCallback(() => { + if (!startTime) return; + + const newStats = calculateRunStats( + routeCoordinatesRef.current, + startTime, + isTrackingRef.current, + pausedTimeRef.current, + pauseStartTimeRef.current, + ); + + // 이전 통계와 비교해서 변경된 경우에만 업데이트 + const lastStats = lastStatsRef.current; + const hasChanged = + !lastStats || + lastStats.distance !== newStats.distance || + lastStats.calories !== newStats.calories || + lastStats.pace !== newStats.pace || + lastStats.runningTime !== newStats.runningTime; + + if (hasChanged) { + lastStatsRef.current = newStats; setRunning({ stats: newStats }); } }, [startTime, setRunning]); + // 초기 통계 계산 (startTime이 설정될 때만) + useEffect(() => { + if (startTime) { + updateStats(); + } + }, [startTime, updateStats]); + // 실시간 통계 업데이트 useEffect(() => { if (!startTime) return; const interval = setInterval(() => { - const newStats = calculateRunStats( - routeCoordinatesRef.current, - startTime, - isTrackingRef.current, - pausedTimeRef.current, - pauseStartTimeRef.current, - ); - setRunning({ stats: newStats }); + updateStats(); }, 1000); return () => clearInterval(interval); - }, [startTime, setRunning]); + }, [startTime, updateStats]); } diff --git a/src/hooks/useShare.ts b/src/hooks/useShare.ts new file mode 100644 index 0000000..dc05521 --- /dev/null +++ b/src/hooks/useShare.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react'; +import { Share as RNShare } from 'react-native'; + +interface UseShareOptions { + courseId: number; + courseData: any; +} + +export function useShare({ courseId, courseData }: UseShareOptions) { + const shareCourse = useCallback(async () => { + if (!courseData) return; + + try { + const title = + 'title' in courseData + ? courseData.title + : `완주 기록 #${courseData.id}`; + await RNShare.share({ + message: `${title} 경로를 공유합니다!`, + url: `runova://course/${courseId}`, + }); + } catch (err) { + // 공유 실패 시 무시 + } + }, [courseId, courseData]); + + return { shareCourse }; +} diff --git a/src/navigation/RouteStackNavigator.tsx b/src/navigation/RouteStackNavigator.tsx index adf2e82..ef57d48 100644 --- a/src/navigation/RouteStackNavigator.tsx +++ b/src/navigation/RouteStackNavigator.tsx @@ -2,21 +2,39 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import Route from '@/pages/Route'; import Draw from '@/pages/Draw'; import RouteSave from '@/pages/RouteSave'; +import Detail from '@/pages/Detail'; +import type { + BookmarkedCourseItem, + CourseSearchItem, +} from '@/types/courses.types'; export type RouteStackParamList = { - RouteMain: Record; - Draw: Record; - RouteSave: Record; + RouteMain: undefined; + Draw: undefined; + RouteSave: undefined; + Detail: { + courseId: number; + courseData?: BookmarkedCourseItem | CourseSearchItem | null; + }; }; 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..a9e6472 --- /dev/null +++ b/src/navigation/RunTabNavigator.tsx @@ -0,0 +1,242 @@ +import { useState, useRef, useCallback, useEffect } 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 { 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(); + +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 [recommendations, setRecommendations] = useState( + [], + ); + const mapRef = useRef(null); + const cameraRef = useRef(null); + + const { + initialLocation, + locationLoading, + flyToCurrentUserLocation, + handleUserLocationUpdate, + 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(); + } + if (initialLocation) { + loadRecommendations(); + } + }, [ + initialLocation, + locationLoading, + refreshLocation, + loadRecommendations, + ]), + ); + + const handleStartPress = () => { + navigation.navigate('Run', {}); + }; + + const handleRecommendationPress = (item: CourseSearchItem) => { + navigation.navigate('Run', { courseId: item.id }); + }; + + const { isPressing, animatedValue, startPress, stopPress } = useLongPress({ + onComplete: handleStartPress, + }); + + const handleTabPress = (tabId: RunTabId) => { + setActiveTab(tabId); + }; + + const renderContent = () => { + if (activeTab === 'quickstart') { + return ( + + {initialLocation && ( + + + + + )} + + + + + + + + + {isPressing ? '러닝 시작 중...' : '러닝 시작'} + + + + + + ); + } 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, + position: 'relative', +}); + +const MapView = styled(Mapbox.MapView)({ + flex: 1, +}); + +const StartButtonContainer = styled.View({ + position: 'absolute', + bottom: 120, + left: 20, + right: 20, +}); + +const StartButton = styled.TouchableOpacity({ + height: 56, + borderRadius: 8, + justifyContent: 'center', + alignItems: 'center', + position: 'relative', + overflow: 'hidden', +}); + +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: '#000000', + borderRadius: 8, +}); + +const StartButtonContent = styled.View({ + flexDirection: 'row', + alignItems: 'center', + gap: 8, + zIndex: 1, +}); + +const PlayIcon = styled(Play)({ + // styled component for Play icon +}); + +const StartButtonText = styled.Text({ + color: '#ffffff', + fontSize: 16, + fontWeight: '600', +}); diff --git a/src/navigation/TabNavigator.tsx b/src/navigation/TabNavigator.tsx index c488a90..a736bcd 100644 --- a/src/navigation/TabNavigator.tsx +++ b/src/navigation/TabNavigator.tsx @@ -1,31 +1,32 @@ 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 { View } from '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'; import WebMyPage from '@/pages/WebMyPage'; +// RunTab 전용 컴포넌트 +function RunTabWithReset() { + return ; +} + 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, @@ -35,6 +36,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 ( @@ -58,35 +67,35 @@ 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 === 'Draw' || routeName === 'RouteSave' + routeName === 'Run' || + routeName === 'Draw' || + routeName === 'RouteSave' || + routeName === 'Detail' ? 'none' : 'flex', }, }; }} /> - , - tabBarStyle: { - ...baseTabBarStyle, - display: 'none', - }, - }} - /> >(); + // 로그인 시 위치 미리 받아오기 + 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 new file mode 100644 index 0000000..7913620 --- /dev/null +++ b/src/pages/Detail/index.tsx @@ -0,0 +1,454 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { + View, + Text, + Image, + ScrollView, + TouchableOpacity, + ActivityIndicator, + Animated, +} from 'react-native'; +import { CompositeScreenProps } from '@react-navigation/native'; +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'; +import useAuthStore from '@/store/auth'; +import useRunStore from '@/store/run'; +import { useImageLoading } from '@/hooks/useImageLoading'; +import { useLongPress } from '@/hooks/useLongPress'; +import { useCourseData } from '@/hooks/useCourseData'; +import { useShare } from '@/hooks/useShare'; +import { formatDate, formatDistance, formatTime } from '@/utils/formatters'; +import type { RouteStackParamList } from '@/navigation/RouteStackNavigator'; +import type { TabParamList } from '@/types/navigation.types'; +import type { + BookmarkedCourseItem, + CourseSearchItem, + CompletedCourseItem, +} from '@/types/courses.types'; + +type Props = { + route: any; + navigation: any; +}; + +export default function Detail({ route, navigation }: Props) { + const { courseId } = route.params; + const { accessToken } = useAuthStore(); + const insets = useSafeAreaInsets(); + + const { courseData, address, loading, error, loadCourseData } = useCourseData( + { + courseId, + courseData: route.params?.courseData || undefined, + accessToken, + }, + ); + const { + imageError, + imageLoading, + handleImageError, + handleImageLoad, + resetImageState, + } = useImageLoading(); + + const handleDrawPress = () => { + if (courseData) { + useRunStore.getState().setCurrentCourse(courseId, courseData); + } + + // RunTabNavigator 내부의 Run 스크린으로 이동 + navigation.navigate('Run', { courseId }); + }; + + const { isPressing, animatedValue, startPress, stopPress } = useLongPress({ + onComplete: handleDrawPress, + }); + const { shareCourse } = useShare({ courseId, courseData }); + + useEffect(() => { + loadCourseData(); + }, [loadCourseData]); + + const handleBackPress = () => { + navigation.goBack(); + }; + + const handleEditPress = () => { + // TODO: 편집 기능 구현 + }; + + if (loading) { + return ( + +
+ + + 경로 정보를 불러오는 중... + + + ); + } + + if (error || !courseData) { + return ( + +
+ + {error || '경로를 찾을 수 없습니다.'} + + 다시 시도 + + + + ); + } + + const imageUrl = (() => { + if (courseData && 'imageUrl' in courseData && courseData.imageUrl) { + return courseData.imageUrl as string; + } + if (courseData && 'artUrl' in courseData && courseData.artUrl) { + return courseData.artUrl as string; + } + return null; + })(); + + const isCompletedCourse = + courseData && 'id' in courseData && !('title' in courseData); + + return ( + +
+ + + + {imageUrl && !imageError && ( + + )} + {(imageError || !imageUrl) && ( + + + {isCompletedCourse && courseData && 'id' in courseData + ? `완주 기록 #${(courseData as any).id}` + : '이미지가 없습니다'} + + + )} + {imageLoading && imageUrl && !imageError && ( + + + + )} + + + + + {isCompletedCourse && courseData && 'id' in courseData + ? `완주 기록 #${(courseData as any).id}` + : courseData && 'title' in courseData + ? (courseData as any).title || '경로 정보' + : '경로 정보'} + + + {address && ( + + 출발지 + {address} + + )} + + + 거리 + + {formatDistance( + 'distance' in courseData + ? courseData.distance + : 'length' in courseData + ? courseData.length + : 0, + )} + + + + {courseData && 'time' in courseData ? ( + + 예상 시간 + {formatTime((courseData as any).time)} + + ) : courseData && + 'duration' in courseData && + (courseData as any).duration ? ( + + 실제 시간 + {formatTime((courseData as any).duration)} + + ) : null} + + {courseData && 'createdAt' in courseData ? ( + + 생성일 + {formatDate((courseData as any).createdAt)} + + ) : courseData && + 'endAt' in courseData && + (courseData as any).endAt ? ( + + 완주일 + {formatDate((courseData as any).endAt)} + + ) : null} + + {courseData && 'author' in courseData ? ( + + 작성자 + + {typeof (courseData as any).author === 'string' + ? (courseData as any).author + : (courseData as any).author.nickname} + + + ) : courseData && + 'calories' in courseData && + (courseData as any).calories ? ( + + 칼로리 + {(courseData as any).calories} kcal + + ) : null} + + + + + + + + + + + + + {isPressing + ? isCompletedCourse + ? '인증하기...' + : '달리기 ...' + : isCompletedCourse + ? '인증하기' + : '달리기'} + + + + + ); +} + +const Container = styled.View({ + flex: 1, + backgroundColor: '#ffffff', +}); + +const LoadingContainer = styled.View({ + flex: 1, + justifyContent: 'center', + alignItems: 'center', +}); + +const LoadingText = styled.Text({ + marginTop: 16, + fontSize: 16, + color: '#666666', +}); + +const ErrorContainer = styled.View({ + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 32, +}); + +const ErrorText = styled.Text({ + fontSize: 16, + color: '#666666', + textAlign: 'center', + marginBottom: 24, +}); + +const RetryButton = styled(TouchableOpacity)({ + backgroundColor: '#2d2d2d', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 8, +}); + +const RetryButtonText = styled.Text({ + color: '#ffffff', + fontSize: 16, + fontWeight: '600', +}); + +const ImageContainer = styled.View({ + aspectRatio: 1, + backgroundColor: '#f5f5f5', +}); + +const CourseImage = styled(Image)({ + width: '100%', + height: '100%', + resizeMode: 'cover', +}); + +const InfoContainer = styled.View({ + padding: 20, +}); + +const Title = styled.Text({ + fontSize: 24, + fontWeight: 'bold', + color: '#000000', + marginBottom: 24, +}); + +const InfoRow = styled.View<{ isLast?: boolean }>(({ isLast }) => ({ + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + borderBottomWidth: isLast ? 0 : 1, + borderBottomColor: '#f0f0f0', +})); + +const InfoLabel = styled.Text({ + fontSize: 16, + color: '#666666', + fontWeight: '500', +}); + +const InfoValue = styled.Text({ + fontSize: 16, + color: '#000000', + flex: 1, + textAlign: 'right', +}); + +const BottomContainer = styled.View<{ paddingBottom: number }>( + ({ paddingBottom }) => ({ + flexDirection: 'row', + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom, + backgroundColor: '#ffffff', + borderTopWidth: 1, + borderTopColor: '#f0f0f0', + }), +); + +const ShareButton = styled(TouchableOpacity)({ + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: '#f8f9fa', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, +}); + +const DrawButton = styled(TouchableOpacity)({ + flex: 1, + height: 48, + borderRadius: 8, + justifyContent: 'center', + alignItems: 'center', + position: 'relative', + overflow: 'hidden', +}); + +const DrawButtonGradient = styled(LinearGradient)({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 8, +}); + +const DrawButtonProgress = styled(Animated.View)({ + position: 'absolute', + top: 0, + left: 0, + height: '100%', + backgroundColor: '#000000', + borderRadius: 8, +}); + +const DrawButtonText = styled(Text)({ + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + zIndex: 1, +}); + +const FallbackContainer = styled.View({ + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#f5f5f5', +}); + +const FallbackText = styled.Text({ + fontSize: 16, + color: '#666666', + textAlign: 'center', +}); + +const LoadingImageContainer = styled.View({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#f5f5f5', +}); diff --git a/src/pages/Draw/_components/DrawMap.tsx b/src/pages/Draw/_components/DrawMap.tsx index 1674809..d21b483 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, useEffect } from 'react'; import styled from '@emotion/native'; import Mapbox from '@rnmapbox/maps'; import { theme } from '@/styles/theme'; @@ -16,6 +17,22 @@ export default function DrawMap({ }: DrawMapProps) { 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 ( @@ -24,73 +41,101 @@ export default function DrawMap({ cameraRef={cameraRef} initialLocation={initialLocation} onUserLocationUpdate={onUserLocationUpdate} + onMapReady={() => setIsMapReady(true)} showUserLocation={!isCapturing} > - {!isCapturing && drawnCoordinates.length > 1 && ( - - - - )} - - {!isCapturing && - completedDrawings.map((drawing, index) => ( + {isMapReady && + showDrawingSources && + drawnCoordinates.length > 1 && + drawnCoordinates.every( + (coord) => Array.isArray(coord) && coord.length === 2, + ) && ( - ))} + )} - {matchedRoutes.map((route, index) => ( - - - - ))} + {isMapReady && + showDrawingSources && + completedDrawings + .filter( + (drawing) => + Array.isArray(drawing) && + drawing.length > 0 && + drawing.every( + (coord) => Array.isArray(coord) && coord.length === 2, + ), + ) + .map((drawing, 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 ac1a53b..f8d1918 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 = @@ -44,6 +46,7 @@ export default function Draw() { locationLoading, flyToCurrentUserLocation, handleUserLocationUpdate, + refreshLocation, } = useLocationManager(); const { composedGesture } = useMapGestures(mapRef); const { processImage } = useMapCapture(mapRef, cameraRef); @@ -51,7 +54,19 @@ export default function Draw() { useFocusEffect( useCallback(() => { - clearAll(); + // 위치가 없으면 강제로 위치 요청 + if (!initialLocation && !locationLoading) { + refreshLocation(); + } + }, [initialLocation, locationLoading, refreshLocation]), + ); + + // Draw 페이지 이탈 시 그린 선들 초기화 + useFocusEffect( + useCallback(() => { + return () => { + clearAll(); + }; }, [clearAll]), ); @@ -85,7 +100,7 @@ export default function Draw() { path: validation.data.path, }); - navigation.navigate('RouteSave', {}); + navigation.navigate('RouteSave'); } catch (error: unknown) { let errorMessage = '경로 저장에 실패했습니다.'; showCourseSaveError(errorMessage); @@ -93,6 +108,10 @@ export default function Draw() { }; if (locationLoading || !initialLocation) { + console.log('📍 Draw 페이지 로딩 중...', { + locationLoading, + initialLocation, + }); return ; } @@ -119,7 +138,7 @@ export default function Draw() { /> - {isLoading && } + {isLoading && } ); diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index 64c6aa0..ca53248 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -3,7 +3,6 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Bell } from 'lucide-react-native'; import Header from '@/components/Header'; import FloatingImageContainer from './_components/FloatingImageContainer'; -import CardContainer from './_components/CardContainer'; export default function Home() { const handleLocationPress = () => { @@ -14,14 +13,10 @@ export default function Home() { console.log('알림 버튼 클릭'); }; - const handleRecommendationPress = (item: RecommendationData) => { - console.log('추천 경로 클릭:', item.title); - }; - return ( @@ -37,7 +32,7 @@ export default function Home() { - + {/* 추천 경로는 바로가기 화면으로 이동됨 */} @@ -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/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 new file mode 100644 index 0000000..e9bae3d --- /dev/null +++ b/src/pages/Records/index.tsx @@ -0,0 +1,165 @@ +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); + }} + /> + )} + + ); +} + +const Screen = styled.View({ + flex: 1, + backgroundColor: '#ffffff', +}); diff --git a/src/pages/Route/_components/RouteGrid.tsx b/src/pages/Route/_components/RouteGrid.tsx index 8d93142..81e838b 100644 --- a/src/pages/Route/_components/RouteGrid.tsx +++ b/src/pages/Route/_components/RouteGrid.tsx @@ -5,55 +5,159 @@ import { Text, TouchableOpacity, RefreshControl, + View, } from 'react-native'; import { useCallback } from 'react'; import Card from '@/components/Card'; import useRouteStore from '@/store/route'; -import { useRouteData } from '@/hooks/api/useRouteApi'; -import { CourseSearchItem } from '@/types/courses.types'; +import { + useRouteData, + useBookmarkedCourses, + useCompletedCourses, +} from '@/hooks/api/useRouteApi'; +import { + BookmarkedCourseItem, + CompletedCourseItem, + CourseSearchItem, +} from '@/types/courses.types'; interface RouteGridProps { onRouteCardPress: (courseId: number) => void; } export default function RouteGrid({ onRouteCardPress }: RouteGridProps) { - const { activeTab, courses, loading, error, refreshing } = useRouteStore(); + const { + activeTab, + courses, + bookmarkedCourses, + completedCourses, + loading, + error, + refreshing, + } = useRouteStore(); const { handleLoadMore, handleRetry, handleRefresh } = useRouteData(); + const { + handleLoadMore: handleBookmarkedLoadMore, + handleRetry: handleBookmarkedRetry, + handleRefresh: handleBookmarkedRefresh, + } = useBookmarkedCourses(); + const { + handleLoadMore: handleCompletedLoadMore, + handleRetry: handleCompletedRetry, + handleRefresh: handleCompletedRefresh, + } = useCompletedCourses(); + + const getCurrentData = () => { + switch (activeTab) { + case 'created': + return courses; + case 'liked': + return bookmarkedCourses; + case 'completed': + return completedCourses; + default: + return courses; + } + }; + + const getCurrentHandlers = () => { + switch (activeTab) { + case 'created': + return { handleLoadMore, handleRetry, handleRefresh }; + case 'liked': + return { + handleLoadMore: handleBookmarkedLoadMore, + handleRetry: handleBookmarkedRetry, + handleRefresh: handleBookmarkedRefresh, + }; + case 'completed': + return { + handleLoadMore: handleCompletedLoadMore, + handleRetry: handleCompletedRetry, + handleRefresh: handleCompletedRefresh, + }; + default: + return { handleLoadMore, handleRetry, handleRefresh }; + } + }; const renderRouteCard = useCallback( - ({ item }: { item: CourseSearchItem }) => ( - onRouteCardPress(item.id)} - /> - ), - [onRouteCardPress], + ({ + item, + }: { + item: CourseSearchItem | BookmarkedCourseItem | CompletedCourseItem; + }) => { + if (activeTab === 'completed') { + const completedItem = item as CompletedCourseItem; + return ( + + onRouteCardPress(completedItem.id)} + /> + + ); + } else if (activeTab === 'liked') { + const bookmarkedItem = item as BookmarkedCourseItem; + return ( + + onRouteCardPress(bookmarkedItem.id)} + /> + + ); + } else { + const courseItem = item as CourseSearchItem; + return ( + + onRouteCardPress(courseItem.id)} + /> + + ); + } + }, + [activeTab, onRouteCardPress], ); + const currentData = getCurrentData(); + const currentHandlers = getCurrentHandlers(); + const handleEndReached = useCallback(() => { - handleLoadMore(); - }, [handleLoadMore]); + currentHandlers.handleLoadMore(); + }, [currentHandlers]); const keyExtractor = useCallback( - (item: CourseSearchItem) => item.id.toString(), - [], + (item: CourseSearchItem | BookmarkedCourseItem | CompletedCourseItem) => + `${activeTab}-${item.id}`, + [activeTab], ); - if (error && courses.length === 0) { + if (error && currentData.length === 0) { return ( {error} - + 다시 시도 ); } - if (courses.length === 0 && !loading && !error) { + if (currentData.length === 0 && !loading && !error) { const getEmptyMessage = () => { switch (activeTab) { case 'created': @@ -76,7 +180,7 @@ export default function RouteGrid({ onRouteCardPress }: RouteGridProps) { return ( } ListFooterComponent={() => ( <> {loading && ( - + )} - {error && courses.length > 0 && ( + {error && currentData.length > 0 && ( {error} - + 다시 시도 @@ -153,7 +257,7 @@ const ErrorText = styled.Text({ }); const RetryButton = styled(TouchableOpacity)({ - backgroundColor: '#007AFF', + backgroundColor: '#2d2d2d', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8, @@ -164,3 +268,8 @@ const RetryButtonText = styled.Text({ fontSize: 14, fontWeight: '600', }); + +const CardContainer = styled.View({ + flex: 1, + marginBottom: 16, +}); diff --git a/src/pages/Route/index.tsx b/src/pages/Route/index.tsx index 2604b3b..8640066 100644 --- a/src/pages/Route/index.tsx +++ b/src/pages/Route/index.tsx @@ -10,13 +10,24 @@ 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, +} from '@/types/courses.types'; import useRouteStore from '@/store/route'; -import { useRouteData } from '@/hooks/api/useRouteApi'; +import { + useRouteData, + useBookmarkedCourses, + useCompletedCourses, +} from '@/hooks/api/useRouteApi'; +import { theme } from '@/styles/theme'; -type Props = CompositeScreenProps< - NativeStackScreenProps, - BottomTabScreenProps ->; +type Props = { + navigation: any; + onStartRun?: (courseId: number) => void; +}; const tabs: Array<{ id: RouteTabId; title: string }> = [ { id: 'created', title: '생성한 경로' }, @@ -25,11 +36,32 @@ const tabs: Array<{ id: RouteTabId; title: string }> = [ ]; export default function Route({ navigation }: Props) { - const { activeTab, setActiveTab, courses } = useRouteStore(); + const { + activeTab, + setActiveTab, + courses, + bookmarkedCourses, + completedCourses, + } = useRouteStore(); const { loadCourses } = useRouteData(); + const { loadBookmarkedCourses } = useBookmarkedCourses(); + const { loadCompletedCourses } = useCompletedCourses(); const handleRouteCardPress = (courseId: number) => { - navigation.navigate('Run', { courseId }); + // 현재 탭의 데이터에서 해당 courseId 찾기 + let courseData: BookmarkedCourseItem | CourseSearchItem | null = null; + + if (activeTab === 'created') { + courseData = courses.find((course) => course.id === courseId) || null; + } else if (activeTab === 'liked') { + courseData = + bookmarkedCourses.find((course) => course.id === courseId) || null; + } else if (activeTab === 'completed') { + // completed 탭의 경우 courseData는 null로 전달 + courseData = null; + } + + navigation.navigate('Detail', { courseId, courseData }); }; useEffect(() => { @@ -41,30 +73,35 @@ export default function Route({ navigation }: Props) { const handleSettingsPress = () => {}; const handleCreatePress = () => { - navigation.navigate('Draw', {}); + navigation.navigate('Draw', undefined); }; const handleTabPress = (tabId: RouteTabId) => { setActiveTab(tabId); - if (tabId === 'created') { + + // 탭 전환 시 해당 탭의 데이터가 비어있을 때만 로드 + if (tabId === 'created' && courses.length === 0) { loadCourses(true); + } else if (tabId === 'liked' && bookmarkedCourses.length === 0) { + loadBookmarkedCourses(true); + } else if (tabId === 'completed' && completedCourses.length === 0) { + loadCompletedCourses(true); } }; return ( -
tabs={tabs} activeTab={activeTab} onTabPress={handleTabPress} /> - + ); } @@ -73,3 +110,14 @@ const Screen = styled.View({ flex: 1, backgroundColor: '#ffffff', }); + +const styles = { + defaultButton: { + backgroundColor: '#000000', + }, + activeButton: { + backgroundColor: theme.colors.secondary[500], + elevation: 12, + shadowOpacity: 0.5, + }, +}; diff --git a/src/pages/RouteSave/index.tsx b/src/pages/RouteSave/index.tsx index 67cabd9..52754be 100644 --- a/src/pages/RouteSave/index.tsx +++ b/src/pages/RouteSave/index.tsx @@ -112,10 +112,9 @@ export default function RouteSave() { onPress: () => { clearAll(); clearRouteData(); - navigation.reset({ - index: 0, - routes: [{ name: 'RouteMain' }], - }); + // RouteStackNavigator 내에서 RouteMain으로 돌아가기 + navigation.goBack(); // Draw로 돌아가기 + navigation.goBack(); // RouteMain으로 돌아가기 }, }, ]); diff --git a/src/pages/Run/_components/ControlContainer.tsx b/src/pages/Run/_components/ControlContainer.tsx index f753558..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(); } @@ -100,7 +106,7 @@ const ControlContainer: React.FC = ({ ); }; -export default ControlContainer; +export default React.memo(ControlContainer); const ControlContainerWrapper = styled.View({ position: 'absolute', @@ -133,7 +139,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/RecommendationContainer.tsx b/src/pages/Run/_components/RecommendationContainer.tsx new file mode 100644 index 0000000..a2129fb --- /dev/null +++ b/src/pages/Run/_components/RecommendationContainer.tsx @@ -0,0 +1,143 @@ +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[0], + item.departure[1], + ]); + 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]); + + // 안내 카드 데이터 + const guideCard = { + id: 'guide', + title: '🏃‍♂️ 런닝을 시작해보세요!', + subtitle: + '바로 달리기 또는 추천 경로를 선택하여 런닝을 시작할 수 있습니다.', + }; + + // 데이터 배열 생성 (안내 카드 + 추천 경로) + const data = [guideCard, ...recommendations]; + + return ( + + { + // 첫 번째 카드는 안내 문구 + if (index === 0) { + const guideItem = item as typeof guideCard; + return ( + + {guideItem.title} + {guideItem.subtitle} + + ); + } + + // 추천 경로 카드 + 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" + /> + + ); +} + +const RecommendationOverlay = styled.View({ + position: 'absolute', + top: 20, + left: 0, + right: 0, + zIndex: 1000, + paddingHorizontal: 16, +}); + +const GuideCard = styled.View({ + backgroundColor: 'rgba(255, 255, 255, 0.95)', + borderRadius: 16, + padding: 24, + alignItems: 'center', + justifyContent: 'center', + minHeight: 100, +}); + +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, +}); diff --git a/src/pages/Run/_components/RunMap.tsx b/src/pages/Run/_components/RunMap.tsx index e68139f..af40973 100644 --- a/src/pages/Run/_components/RunMap.tsx +++ b/src/pages/Run/_components/RunMap.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { View } from 'react-native'; import styled from '@emotion/native'; import Mapbox from '@rnmapbox/maps'; @@ -8,16 +9,18 @@ import { useRunMap } from '@/hooks/useRunMap'; import type { Position } from 'geojson'; import * as Location from 'expo-location'; -export default function RunMap({ +function RunMap({ mapRef: externalMapRef, cameraRef: externalCameraRef, routeCoordinates, locationObject, + courseId, }: { mapRef?: React.RefObject; cameraRef?: React.RefObject; routeCoordinates: Position[]; locationObject: Location.LocationObject | null; + courseId?: number; }) { // 트래킹 중일 때는 routeCoordinates의 마지막 위치를 사용, 그렇지 않으면 현재 위치 사용 // 실시간 위치 업데이트를 위해 locationObject를 우선 사용 @@ -32,7 +35,7 @@ export default function RunMap({ : null; const { routeGeoJSON, courseShapeGeoJSON, courseShapePolygons, isLocked } = - useRunMap(externalCameraRef, location, routeCoordinates); + useRunMap(externalCameraRef, location, routeCoordinates, courseId); if (!location || !externalMapRef || !externalCameraRef) { return null; @@ -51,7 +54,7 @@ export default function RunMap({ )} - {/* 선택해서 읽어온 코스 shape 표시 */} - {courseShapePolygons.length > 0 && ( + {/* 선택해서 읽어온 코스 shape 표시 - courseId가 있을 때만 */} + {courseId && courseShapePolygons.length > 0 && ( { - if (paceSeconds === 0) return '0\'00"'; +function StatsContainer() { + const { routeCoordinates, isTracking } = useLocationTracking(); + const { startTime, pausedTime, pauseStartTime } = useRunStore(); - const minutes = Math.floor(paceSeconds / 60); - const seconds = Math.floor(paceSeconds % 60); - return `${minutes}'${seconds.toString().padStart(2, '0')}"`; -}; + // 로컬 상태로 통계 관리 (전역 상태 업데이트 안함) + const [localStats, setLocalStats] = useState({ + distance: 0, + calories: 0, + pace: 0, + runningTime: '00:00:00', + }); -export default function StatsContainer() { - const { stats } = useRunStore(); + const routeCoordinatesRef = useRef(routeCoordinates); + const isTrackingRef = useRef(isTracking); + const pausedTimeRef = useRef(pausedTime); + const pauseStartTimeRef = useRef(pauseStartTime); + const lastStatsRef = useRef(localStats); + + // ref 값들을 최신으로 유지 + useEffect(() => { + routeCoordinatesRef.current = routeCoordinates; + }, [routeCoordinates]); + + useEffect(() => { + isTrackingRef.current = isTracking; + }, [isTracking]); + + useEffect(() => { + pausedTimeRef.current = pausedTime; + }, [pausedTime]); + + useEffect(() => { + pauseStartTimeRef.current = pauseStartTime; + }, [pauseStartTime]); + + // 통계 업데이트 함수 (로컬 상태만 업데이트) + const updateStats = useCallback(() => { + if (!startTime) return; + + const newStats = calculateRunStats( + routeCoordinatesRef.current, + startTime, + isTrackingRef.current, + pausedTimeRef.current, + pauseStartTimeRef.current, + ); + + // 이전 통계와 비교해서 변경된 경우에만 업데이트 + const lastStats = lastStatsRef.current; + const hasChanged = + lastStats.distance !== newStats.distance || + lastStats.calories !== newStats.calories || + lastStats.pace !== newStats.pace || + lastStats.runningTime !== newStats.runningTime; + + if (hasChanged) { + lastStatsRef.current = newStats; + setLocalStats(newStats); + } + }, [startTime]); + + // 초기 통계 계산 + useEffect(() => { + if (startTime) { + updateStats(); + } + }, [startTime, updateStats]); + + // 실시간 통계 업데이트 + useEffect(() => { + if (!startTime) return; + + const interval = setInterval(() => { + updateStats(); + }, 1000); + + return () => clearInterval(interval); + }, [startTime, updateStats]); return ( + 달린 시간 - {stats.runningTime} + {localStats.runningTime} - {stats.distance}m + {localStats.distance}m 거리 - {stats.calories} + {localStats.calories} 칼로리 - {formatPace(stats.pace)} + {formatPace(localStats.pace)} 페이스 @@ -75,7 +155,6 @@ const StatLabel = styled.Text(({ theme }) => ({ })); const RunningTimeContainer = styled.View(({ theme }) => ({ - backgroundColor: theme.colors.primary[500], borderRadius: 12, paddingVertical: 16, paddingHorizontal: 20, @@ -83,16 +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 94b8764..2059a54 100644 --- a/src/pages/Run/index.tsx +++ b/src/pages/Run/index.tsx @@ -1,5 +1,5 @@ import { useRef, useCallback, useMemo, useEffect } from 'react'; -import { Text, BackHandler } from 'react-native'; +import { Text, View, BackHandler } from 'react-native'; import styled from '@emotion/native'; import { ArrowLeft, AlertTriangle } from 'lucide-react-native'; import { useFocusEffect } from '@react-navigation/native'; @@ -8,7 +8,6 @@ import { LoadingOverlay, ErrorOverlay } from '@/components/Overlay'; import Header from '@/components/Header'; import type { TabParamList } from '@/types/navigation.types'; import { useLocationTracking } from '@/hooks/useLocationTracking'; -import { useRunStats } from '@/hooks/useRunStats'; import { useRunModals } from '@/hooks/useRunModals'; import { useCourseTopologyApi } from '@/hooks/api/useCourseTopologyApi'; import { useCourseValidation } from '@/hooks/useCourseValidation'; @@ -19,10 +18,12 @@ 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) { - const courseId = route.params?.courseId; + // 전역 Store에서 현재 코스 데이터 가져오기 + const { currentCourseId, currentCourseData } = useRunStore(); + const courseId = currentCourseId || route.params?.courseId; const { routeCoordinates, @@ -34,6 +35,7 @@ export default function Run({ route, navigation }: Props) { refreshLocation, toggleTracking, } = useLocationTracking(); + const cameraRef = useRef(null!); const mapRef = useRef(null); @@ -46,7 +48,8 @@ export default function Run({ route, navigation }: Props) { startTime, } = useRunStore(); - useRunStats(routeCoordinates, isTracking); + // useRunStats는 StatsContainer에서 처리 + const { loadCourseTopology } = useCourseTopologyApi(courseId); // 코스 검증 훅 @@ -58,6 +61,7 @@ export default function Run({ route, navigation }: Props) { distanceFromCourse, validateCurrentLocation, } = useCourseValidation({ + courseId, validationOptions: { tolerance: 5, // 5미터 허용 오차 (매우 엄격하게) enableDistanceCalculation: true, @@ -75,13 +79,22 @@ 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(() => { - resetLocationTracking(); - resetRunState(); - // 같은 courseId로 다시 진입할 때도 경로 데이터를 다시 로드 + // 런닝이 시작되지 않은 상태에서만 초기화 + const { startTime } = useRunStore.getState(); + if (!startTime) { + resetLocationTracking(); + resetRunState(); + } + // courseId가 있을 때만 경로 데이터를 로드 if (courseId) { loadCourseTopology(); } @@ -165,6 +178,7 @@ export default function Run({ route, navigation }: Props) { cameraRef={cameraRef} routeCoordinates={routeCoordinates} locationObject={location} + courseId={courseId} /> {loading && } {topologyError && ( @@ -173,8 +187,8 @@ export default function Run({ route, navigation }: Props) { onRetry={loadCourseTopology} /> )} - {/* 코스 이탈 알림 */} - {isDeviating && ( + {/* 코스 이탈 알림 - courseId가 있을 때만 */} + {courseId && isDeviating && ( @@ -187,7 +201,7 @@ export default function Run({ route, navigation }: Props) { 위치를 가져올 수 없습니다 - 새로고침 + 새로고침 )} @@ -267,8 +281,8 @@ const DeviationAlert = styled.View<{ severity: 'low' | 'medium' | 'high' }>( severity === 'high' ? '#ef4444' : severity === 'medium' - ? '#f59e0b' - : '#3b82f6', + ? '#f87171' + : '#fca5a5', paddingHorizontal: 20, paddingVertical: 16, borderRadius: 12, @@ -304,5 +318,5 @@ const RefreshButton = styled.TouchableOpacity({ padding: 12, borderRadius: 8, borderWidth: 1, - borderColor: '#007AFF', + borderColor: '#2d2d2d', }); diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 2033a6a..df5e879 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -16,74 +16,206 @@ 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 initialization started'); + + const webClientId = process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID; + const iosClientId = process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID; + + // 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 length:', webClientId.length); + console.log( + 'Web Client ID first 20 chars:', + webClientId.substring(0, 20) + '...', + ); + } + + if (iosClientId) { + console.log('iOS Client ID length:', iosClientId.length); + console.log( + 'iOS Client ID first 20 chars:', + iosClientId.substring(0, 20) + '...', + ); + } + + if (!webClientId) { + 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 is NOT SET!'); + console.error( + 'Check EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID environment variable', + ); + } + + const config = { + webClientId, + iosClientId, + profileImageSize: 120, + }; + + console.log('Google Sign-In config:', config); + GoogleSignin.configure(config); + + console.log('✅ Google Sign-In initialization completed'); + } catch (error) { + console.error('❌ Google Sign-In initialization error:', error); + throw error; + } } export async function signInWithGoogle(): Promise { try { + console.log('Google login started'); + + console.log('Checking Play Services...'); await GoogleSignin.hasPlayServices(); - await GoogleSignin.signIn(); - const { idToken } = await GoogleSignin.getTokens(); + console.log('Play Services check completed'); + + console.log('Attempting Google login...'); + const signInResult = await GoogleSignin.signIn(); + console.log('Google login successful:', signInResult); + + console.log('Getting tokens...'); + const tokens = await GoogleSignin.getTokens(); + console.log('Full token response:', tokens); + + const { idToken, accessToken } = tokens; + console.log('ID Token status:', idToken ? 'ISSUED' : 'NOT ISSUED'); + console.log('Access Token status:', accessToken ? 'ISSUED' : 'NOT ISSUED'); + + if (idToken) { + console.log('ID Token length:', idToken.length); + console.log('ID Token first 20 chars:', idToken.substring(0, 20) + '...'); + } if (!idToken) { - 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.'); } - const { data } = await api.post('/auth/google', { - idToken, + console.log('Sending login request to server...'); + console.log('Request URL:', '/auth/google'); + console.log('Request payload:', { + idToken: idToken.substring(0, 20) + '...', }); + + let data: GoogleSignInResponse; + try { + const response = await api.post('/auth/google', { + idToken, + }); + data = response.data; + console.log('✅ Server login successful:', data); + } catch (serverError: any) { + 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 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 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 Error - Server internal error'); + console.error('Check server logs for details'); + } + + throw serverError; + } + return data; } catch (error) { + console.error('Google login overall error:', error); + if (isErrorWithCode(error)) { + console.error('Google login code error:', error.code); switch (error.code) { case statusCodes.SIGN_IN_CANCELLED: - throw new Error('사용자가 로그인을 취소했습니다.'); + console.error('User cancelled login'); + throw new Error('User cancelled login.'); case statusCodes.IN_PROGRESS: - throw new Error('로그인이 진행 중입니다.'); + console.error('Login in progress'); + throw new Error('Login in progress.'); case statusCodes.PLAY_SERVICES_NOT_AVAILABLE: + console.error('Play Services not available'); throw new Error( - '구글 플레이 서비스를 사용할 수 없거나 버전이 오래되었습니다.', + 'Google Play Services is not available or version is outdated.', ); default: - throw new Error('구글 로그인 중 오류가 발생했습니다.'); + console.error('Unknown Google login error:', error.code); + throw new Error('Error occurred during Google login.'); } } - 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('Token refresh started'); const { data } = await api.post('/auth/refresh'); + console.log('Token refresh response:', data); if (!data.accessToken) { - throw new Error('토큰 갱신에 실패했습니다.'); + console.error('Token refresh failed: accessToken is missing'); + throw new Error('Token refresh failed.'); } + console.log('Token refresh successful'); return data; } catch (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 logout started'); await GoogleSignin.signOut(); + 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('Checking login status...'); const userInfo = await GoogleSignin.getCurrentUser(); + 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; } } diff --git a/src/services/courses.service.ts b/src/services/courses.service.ts index 7fd76b7..6273733 100644 --- a/src/services/courses.service.ts +++ b/src/services/courses.service.ts @@ -1,6 +1,8 @@ import { convertPathToApiFormat } from '@/utils/courseFormatter'; import api from '../lib/api'; import type { + BookmarkedCourseResponse, + CompletedCourseResponse, CourseClientData, CourseCreateRequest, CourseSearchRequest, @@ -48,3 +50,50 @@ export async function getCourseTopology( }); return response.data; } + +export async function searchBookmarkedCourses( + params: CourseSearchRequest, + accessToken: string, +): Promise { + const response = await api.get('/api/courses/search/bookmarked', { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return response.data; +} + +export async function searchCompletedCourses( + params: { + cursor?: { id: number } | null; + limit?: number; + }, + accessToken: string, +): Promise { + const response = await api.get('/api/courses/search/completed', { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + 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/services/running.service.ts b/src/services/running.service.ts index 541e278..3d56a2a 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, @@ -17,16 +23,61 @@ export async function createRunningRecord( }; const params = courseId ? { courseId } : undefined; + const endpoint = '/api/running/records'; + + 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('/api/running/records', requestData, { + 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; } } + +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/store/draw.ts b/src/store/draw.ts index 9d2c958..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) => ({ @@ -66,6 +64,7 @@ const useDrawStore = create((set) => ({ completedDrawings: [], drawnCoordinates: [], drawMode: 'none', + isLoading: false, }), })); diff --git a/src/store/route.ts b/src/store/route.ts index 5cec949..6c61c5c 100644 --- a/src/store/route.ts +++ b/src/store/route.ts @@ -1,10 +1,16 @@ import { create } from 'zustand'; -import type { CourseSearchItem } from '@/types/courses.types'; +import type { + BookmarkedCourseItem, + CompletedCourseItem, + CourseSearchItem, +} from '@/types/courses.types'; import type { RouteTabId } from '@/types/navigation.types'; interface RouteState { activeTab: RouteTabId; courses: CourseSearchItem[]; + bookmarkedCourses: BookmarkedCourseItem[]; + completedCourses: CompletedCourseItem[]; loading: boolean; refreshing: boolean; hasMore: boolean; @@ -13,6 +19,10 @@ interface RouteState { setActiveTab: (tab: RouteTabId) => void; setCourses: (courses: CourseSearchItem[]) => void; appendCourses: (courses: CourseSearchItem[]) => void; + setBookmarkedCourses: (courses: BookmarkedCourseItem[]) => void; + appendBookmarkedCourses: (courses: BookmarkedCourseItem[]) => void; + setCompletedCourses: (courses: CompletedCourseItem[]) => void; + appendCompletedCourses: (courses: CompletedCourseItem[]) => void; setLoading: (loading: boolean) => void; setRefreshing: (refreshing: boolean) => void; setHasMore: (hasMore: boolean) => void; @@ -23,6 +33,8 @@ interface RouteState { const useRouteStore = create((set) => ({ activeTab: 'created', courses: [], + bookmarkedCourses: [], + completedCourses: [], loading: false, refreshing: false, hasMore: true, @@ -33,6 +45,8 @@ const useRouteStore = create((set) => ({ set({ activeTab: tab, courses: [], + bookmarkedCourses: [], + completedCourses: [], cursor: null, error: null, hasMore: true, @@ -40,13 +54,76 @@ const useRouteStore = create((set) => ({ }, setCourses: (courses: CourseSearchItem[]) => { - set({ courses }); + set((state) => { + // 중복 제거를 위해 ID 기준으로 유니크한 아이템만 유지 + const uniqueCourses = courses.filter( + (course, index, self) => + index === self.findIndex((c) => c.id === course.id), + ); + return { courses: uniqueCourses }; + }); }, appendCourses: (courses: CourseSearchItem[]) => { - set((state) => ({ - courses: [...state.courses, ...courses], - })); + set((state) => { + const existingIds = new Set(state.courses.map((course) => course.id)); + const newCourses = courses.filter( + (course) => !existingIds.has(course.id), + ); + return { + courses: [...state.courses, ...newCourses], + }; + }); + }, + + setBookmarkedCourses: (courses: BookmarkedCourseItem[]) => { + set((state) => { + // 중복 제거를 위해 ID 기준으로 유니크한 아이템만 유지 + const uniqueCourses = courses.filter( + (course, index, self) => + index === self.findIndex((c) => c.id === course.id), + ); + return { bookmarkedCourses: uniqueCourses }; + }); + }, + + appendBookmarkedCourses: (courses: BookmarkedCourseItem[]) => { + set((state) => { + const existingIds = new Set( + state.bookmarkedCourses.map((course) => course.id), + ); + const newCourses = courses.filter( + (course) => !existingIds.has(course.id), + ); + return { + bookmarkedCourses: [...state.bookmarkedCourses, ...newCourses], + }; + }); + }, + + setCompletedCourses: (courses: CompletedCourseItem[]) => { + set((state) => { + // 중복 제거를 위해 ID 기준으로 유니크한 아이템만 유지 + const uniqueCourses = courses.filter( + (course, index, self) => + index === self.findIndex((c) => c.id === course.id), + ); + return { completedCourses: uniqueCourses }; + }); + }, + + appendCompletedCourses: (courses: CompletedCourseItem[]) => { + set((state) => { + const existingIds = new Set( + state.completedCourses.map((course) => course.id), + ); + const newCourses = courses.filter( + (course) => !existingIds.has(course.id), + ); + return { + completedCourses: [...state.completedCourses, ...newCourses], + }; + }); }, setLoading: (loading: boolean) => { diff --git a/src/store/run.ts b/src/store/run.ts index 92d1889..9785915 100644 --- a/src/store/run.ts +++ b/src/store/run.ts @@ -31,6 +31,8 @@ interface RunningState { // 코스 데이터 그룹 interface CourseState { courseTopology: CourseTopologyResponse | null; + currentCourseId: number | null; + currentCourseData: any | null; } // 위치 추적 상태 그룹 @@ -79,6 +81,8 @@ interface RunState // 코스 액션들 setCourseTopology: (topology: CourseTopologyResponse | null) => void; + setCurrentCourse: (courseId: number, courseData: any) => void; + clearCurrentCourse: () => void; // 위치 추적 액션들 setRouteCoordinates: (coords: Position[]) => void; @@ -127,6 +131,8 @@ const initialRunningState: RunningState = { const initialCourseState: CourseState = { courseTopology: null, + currentCourseId: null, + currentCourseData: null, }; const initialLocationTrackingState: LocationTrackingState = { @@ -179,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, @@ -204,17 +220,25 @@ 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 }), + setCurrentCourse: (courseId, courseData) => + set({ currentCourseId: courseId, currentCourseData: courseData }), + + clearCurrentCourse: () => + set({ currentCourseId: null, currentCourseData: null }), + // 위치 추적 액션들 setRouteCoordinates: (coords) => set({ routeCoordinates: coords }), setLocation: (location) => set({ location }), @@ -234,7 +258,6 @@ const useRunStore = create((set, get) => ({ errorMsg: null, location: null, }); - console.log(); }, // 코스 검증 액션들 @@ -271,13 +294,41 @@ const useRunStore = create((set, get) => ({ }), resetRunState: () => { - set({ - ...initialUIState, - ...initialErrorState, - ...initialRunningState, - ...initialCourseState, - ...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, + }; }); }, })); diff --git a/src/types/courses.types.ts b/src/types/courses.types.ts index d16545b..77b2fde 100644 --- a/src/types/courses.types.ts +++ b/src/types/courses.types.ts @@ -31,7 +31,7 @@ export type CourseSaveResult = { }; export interface CourseSearchRequest { - cursor?: number | null; + cursor?: { id: number } | null; limit?: number; } @@ -39,11 +39,14 @@ export interface CourseSearchItem { id: number; title: string; imageUrl: string; - departure: RouteCoordinate; + departure: [number, number]; length: number; time: number; createdAt: string; - author: string; + author: { + nickname: string; + imageUrl: string; + }; bookmarked: boolean; completed: boolean; } @@ -52,6 +55,50 @@ export interface CourseSearchResponse { results: CourseSearchItem[]; } +// 북마크한 경로 API 타입 +export interface BookmarkedCourseItem { + id: number; + title: string; + imageUrl: string; + departure: [number, number]; + length: number; + time: number; + createdAt: string; + author: { + nickname: string; + imageUrl: string; + }; + bookmarked: boolean; + distance: number; +} + +export interface BookmarkedCourseResponse { + results: BookmarkedCourseItem[]; + nextCursor: string; +} + +// 완주한 경로 API 타입 +export interface CompletedCourseItem { + id: number; + title: string; + imageUrl: string; + departure: [number, number]; + length: number; + time: number; + createdAt: string; + author: { + nickname: string; + imageUrl: string; + }; + bookmarked: boolean; + distance: number; +} + +export interface CompletedCourseResponse { + results: CompletedCourseItem[]; + nextCursor: string; +} + export interface CourseTopologyNode { location: [number, number]; progress: number; diff --git a/src/types/draw.types.ts b/src/types/draw.types.ts index 0a1b670..b54e99f 100644 --- a/src/types/draw.types.ts +++ b/src/types/draw.types.ts @@ -11,6 +11,6 @@ export interface DrawUIProps { export interface DrawMapProps extends DrawUIProps { mapRef: RefObject; cameraRef: RefObject; - initialLocation: Position; + initialLocation: Position | null; onUserLocationUpdate: (location: Mapbox.Location) => void; } 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; }; 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 new file mode 100644 index 0000000..1d98ae6 --- /dev/null +++ b/src/utils/formatters.ts @@ -0,0 +1,37 @@ +export const formatDate = (dateString: string): string => { + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); +}; + +export const formatDistance = (distance: number): string => { + if (distance < 1000) { + return `${Math.round(distance * 100) / 100}m`; + } + 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}분 ${seconds}초`; + } + 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.round((paceSeconds % 60) * 100) / 100; + return `${minutes}'${seconds.toFixed(2).padStart(5, '0')}"`; +};