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 6fe60cb..19dc9f1 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -9,6 +9,7 @@ interface MapProps { cameraRef: RefObject; initialLocation: Position | null; onUserLocationUpdate?: (location: Mapbox.Location) => void; + onMapReady?: () => void; children?: React.ReactNode; showUserLocation?: boolean; } @@ -18,6 +19,7 @@ export default function Map({ cameraRef, initialLocation, onUserLocationUpdate, + onMapReady, children, showUserLocation = true, }: MapProps) { @@ -25,7 +27,11 @@ export default function Map({ const safeInitialLocation = initialLocation || [127.0276, 37.4979]; return ( - + {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/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 a24cc3e..3abea5d 100644 --- a/src/hooks/api/useRouteApi.ts +++ b/src/hooks/api/useRouteApi.ts @@ -3,10 +3,12 @@ import { searchUserCourses, searchBookmarkedCourses, searchCompletedCourses, + searchAdjacentCourses, } from '@/services/courses.service'; import useAuthStore from '@/store/auth'; import useRouteStore from '@/store/route'; import type { AxiosErrorResponse } from '@/types/api.types'; +import type { Position } from 'geojson'; export function useRouteData() { const { accessToken } = useAuthStore(); @@ -346,3 +348,32 @@ export function useCompletedCourses() { handleRefresh, }; } + +export function useAdjacentCourses() { + const { accessToken } = useAuthStore(); + + const searchAdjacent = useCallback( + async (location: Position, radius: number = 1000) => { + if (!accessToken) return []; + + try { + const locationString = `${location[0]},${location[1]}`; + const response = await searchAdjacentCourses( + { + location: locationString, + radius, + limit: 3, + }, + accessToken, + ); + return response.results; + } catch (error) { + console.error('주변 경로 검색 실패:', error); + return []; + } + }, + [accessToken], + ); + + return { searchAdjacent }; +} diff --git a/src/hooks/useLocationManager.ts b/src/hooks/useLocationManager.ts index 986ec12..f1118fa 100644 --- a/src/hooks/useLocationManager.ts +++ b/src/hooks/useLocationManager.ts @@ -78,7 +78,7 @@ export function useLocationManager() { }; return { - initialLocation: currentUserLocation.current, // 현재 위치가 없으면 null + initialLocation: currentUserLocation.current, // 현재 위치만 사용 locationLoading: locationLoading, location, errorMsg, diff --git a/src/hooks/useLocationTracking.ts b/src/hooks/useLocationTracking.ts index 7e6b899..ec60d11 100644 --- a/src/hooks/useLocationTracking.ts +++ b/src/hooks/useLocationTracking.ts @@ -40,9 +40,19 @@ export function useLocationTracking() { const startTracking = useCallback(async () => { if (isTracking) return; - if (location) { + // 기존 경로가 없을 때만 초기 위치를 설정 + const currentCoords = useRunStore.getState().routeCoordinates; + if (currentCoords.length === 0 && location) { const { latitude, longitude } = location.coords; + console.log('📍 [LocationTracking] 위치 추적 시작 - 초기 위치 설정:', { + latitude, + longitude, + }); setRouteCoordinates([[longitude, latitude]]); + } else if (currentCoords.length > 0) { + console.log('📍 [LocationTracking] 위치 추적 재시작 - 기존 경로 유지:', { + coordinatesCount: currentCoords.length, + }); } const newSubscriber = await Location.watchPositionAsync( @@ -86,6 +96,7 @@ export function useLocationTracking() { ]); const pauseTracking = useCallback(() => { + console.log('⏸️ [LocationTracking] 위치 추적 일시정지'); if (subscriber) { subscriber.remove(); setSubscriber(null); @@ -94,6 +105,7 @@ export function useLocationTracking() { }, [subscriber, setSubscriber, setIsTracking]); const stopTracking = useCallback(() => { + console.log('⏹️ [LocationTracking] 위치 추적 완전 종료 - 경로 초기화됨'); if (subscriber) { subscriber.remove(); setSubscriber(null); diff --git a/src/hooks/useLongPress.ts b/src/hooks/useLongPress.ts index ef6f4cc..fc1eba5 100644 --- a/src/hooks/useLongPress.ts +++ b/src/hooks/useLongPress.ts @@ -11,15 +11,12 @@ export function useLongPress({ onComplete, }: UseLongPressOptions) { const [isPressing, setIsPressing] = useState(false); - const [pressProgress, setPressProgress] = useState(0); const pressTimer = useRef(null); - const progressTimer = useRef(null); const animatedValue = useRef(new Animated.Value(0)).current; const startPress = useCallback(() => { setIsPressing(true); - setPressProgress(0); pressTimer.current = setTimeout(() => { onComplete(); @@ -31,29 +28,16 @@ export function useLongPress({ duration, useNativeDriver: false, }).start(); - - progressTimer.current = setInterval(() => { - setPressProgress((prev) => { - const newProgress = prev + 100 / (duration / 100); - return newProgress >= 100 ? 100 : newProgress; - }); - }, 100); }, [duration, onComplete, animatedValue]); const stopPress = useCallback(() => { setIsPressing(false); - setPressProgress(0); if (pressTimer.current) { clearTimeout(pressTimer.current); pressTimer.current = null; } - if (progressTimer.current) { - clearInterval(progressTimer.current); - progressTimer.current = null; - } - Animated.timing(animatedValue, { toValue: 0, duration: 200, @@ -63,7 +47,6 @@ export function useLongPress({ return { isPressing, - pressProgress, animatedValue, startPress, stopPress, diff --git a/src/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/useRunModals.ts b/src/hooks/useRunModals.ts index 5970ff8..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; @@ -82,6 +82,18 @@ export function useRunModals({ return; } + // 코스 선택 시 최소 이동 거리 체크 + if (courseId && routeCoordinates.length < 2) { + Toast.show({ + type: 'error', + text1: '저장 불가', + text2: + '선택한 코스와 실제 러닝 경로가 많이 다릅니다. 코스를 따라 달려보세요.', + }); + cleanupAndGoBack(); + return; + } + try { setUI({ savingRecord: true }); setError('save', null); @@ -131,7 +143,20 @@ export function useRunModals({ imageUrl, }; - await saveRunningRecord(runningRecord, courseId); + console.log('📤 [useRunModals] 런닝 기록 저장 요청 페이로드:', { + runningRecord, + courseId, + pathLength: path.length, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + pace: paceValue, + calories: stats.calories, + imageUrl, + }); + + const response = await saveRunningRecord(runningRecord, courseId); + + console.log('📥 [useRunModals] 런닝 기록 저장 응답:', response); Toast.show({ type: 'success', @@ -143,10 +168,20 @@ export function useRunModals({ } catch (error: unknown) { let errorMessage = '런닝 기록 저장에 실패했습니다.'; + // API 응답 로그 추가 + console.error('🚨 [useRunModals] 런닝 기록 저장 실패:', error); + if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as AxiosErrorResponse; const status = axiosError.status; + console.error('🚨 [useRunModals] API 응답 상태코드:', status); + console.error('🚨 [useRunModals] API 응답 데이터:', axiosError.data); + console.error( + '🚨 [useRunModals] API 응답 상태텍스트:', + axiosError.statusText, + ); + if (status === 400) { const errorData = axiosError.data; if (errorData?.message) { @@ -156,10 +191,14 @@ export function useRunModals({ } } else if (status === 401) { errorMessage = '로그인이 필요합니다.'; + } else if (status === 409) { + errorMessage = + '선택한 코스와 실제 러닝 경로가 많이 다릅니다. 코스를 따라 달려보세요.'; } else if (status === 500) { errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; } } else if (error instanceof Error) { + console.error('🚨 [useRunModals] 일반 에러:', error.message); errorMessage = error.message; } diff --git a/src/navigation/RouteStackNavigator.tsx b/src/navigation/RouteStackNavigator.tsx index 7df54f1..ef57d48 100644 --- a/src/navigation/RouteStackNavigator.tsx +++ b/src/navigation/RouteStackNavigator.tsx @@ -20,10 +20,18 @@ export type RouteStackParamList = { const Stack = createNativeStackNavigator(); -export default function RouteStackNavigator() { +interface RouteStackNavigatorProps { + onStartRun?: (courseId: number) => void; +} + +export default function RouteStackNavigator({ + onStartRun, +}: RouteStackNavigatorProps) { return ( - + + {(props) => } + diff --git a/src/navigation/RunTabNavigator.tsx b/src/navigation/RunTabNavigator.tsx new file mode 100644 index 0000000..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 60b6107..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,17 +67,26 @@ export default function TabNavigator() { }} /> ( + + ), + }} + /> + { - const routeName = getFocusedRouteNameFromRoute(route) ?? 'RouteMain'; + const routeName = + getFocusedRouteNameFromRoute(route) ?? 'QuickStartMain'; return { - tabBarIcon: ({ color, size }) => ( - - ), + tabBarIcon: ({ color, size }) => , tabBarStyle: { ...baseTabBarStyle, display: + routeName === 'Run' || routeName === 'Draw' || routeName === 'RouteSave' || routeName === 'Detail' @@ -78,17 +96,6 @@ export default function TabNavigator() { }; }} /> - , - 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 index c600712..7913620 100644 --- a/src/pages/Detail/index.tsx +++ b/src/pages/Detail/index.tsx @@ -13,6 +13,7 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { BottomTabScreenProps } from '@react-navigation/bottom-tabs'; import { Share, ArrowLeft, Edit3 } from 'lucide-react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { LinearGradient } from 'expo-linear-gradient'; import styled from '@emotion/native'; import Header from '@/components/Header'; @@ -31,10 +32,10 @@ import type { CompletedCourseItem, } from '@/types/courses.types'; -type Props = CompositeScreenProps< - NativeStackScreenProps, - BottomTabScreenProps ->; +type Props = { + route: any; + navigation: any; +}; export default function Detail({ route, navigation }: Props) { const { courseId } = route.params; @@ -61,27 +62,13 @@ export default function Detail({ route, navigation }: Props) { useRunStore.getState().setCurrentCourse(courseId, courseData); } - try { - const routeStackNavigator = navigation.getParent(); - if (routeStackNavigator && 'jumpTo' in routeStackNavigator) { - (routeStackNavigator as any).jumpTo('Run'); - return; - } - - const tabNavigator = navigation.getParent()?.getParent(); - if (tabNavigator && 'jumpTo' in tabNavigator) { - (tabNavigator as any).jumpTo('Run'); - return; - } - } catch (error) { - // 네비게이션 에러 시 무시 - } + // RunTabNavigator 내부의 Run 스크린으로 이동 + navigation.navigate('Run', { courseId }); }; - const { isPressing, pressProgress, animatedValue, startPress, stopPress } = - useLongPress({ - onComplete: handleDrawPress, - }); + const { isPressing, animatedValue, startPress, stopPress } = useLongPress({ + onComplete: handleDrawPress, + }); const { shareCourse } = useShare({ courseId, courseData }); useEffect(() => { @@ -107,7 +94,7 @@ export default function Detail({ route, navigation }: Props) { title="경로 상세" /> - + 경로 정보를 불러오는 중... @@ -177,7 +164,7 @@ export default function Detail({ route, navigation }: Props) { )} {imageLoading && imageUrl && !imageError && ( - + )} @@ -261,7 +248,7 @@ export default function Detail({ route, navigation }: Props) { - + + @@ -324,7 +316,7 @@ const ErrorText = styled.Text({ }); const RetryButton = styled(TouchableOpacity)({ - backgroundColor: '#007AFF', + backgroundColor: '#2d2d2d', paddingHorizontal: 24, paddingVertical: 12, borderRadius: 8, @@ -405,7 +397,6 @@ const ShareButton = styled(TouchableOpacity)({ const DrawButton = styled(TouchableOpacity)({ flex: 1, height: 48, - backgroundColor: '#f0f0f0', borderRadius: 8, justifyContent: 'center', alignItems: 'center', @@ -413,17 +404,26 @@ const DrawButton = styled(TouchableOpacity)({ 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: '#007AFF', + backgroundColor: '#000000', borderRadius: 8, }); const DrawButtonText = styled(Text)({ - color: '#000000', + color: '#ffffff', fontSize: 16, fontWeight: '600', zIndex: 1, diff --git a/src/pages/Draw/_components/DrawMap.tsx b/src/pages/Draw/_components/DrawMap.tsx index 805167a..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,9 +41,11 @@ export default function DrawMap({ cameraRef={cameraRef} initialLocation={initialLocation} onUserLocationUpdate={onUserLocationUpdate} + onMapReady={() => setIsMapReady(true)} showUserLocation={!isCapturing} > - {!isCapturing && + {isMapReady && + showDrawingSources && drawnCoordinates.length > 1 && drawnCoordinates.every( (coord) => Array.isArray(coord) && coord.length === 2, @@ -45,7 +64,7 @@ export default function DrawMap({ )} - {!isCapturing && + {isMapReady && + showDrawingSources && completedDrawings .filter( (drawing) => @@ -80,7 +100,7 @@ export default function DrawMap({ ))} - {matchedRoutes - .filter( - (route) => - route && - route.geometry && - route.geometry.coordinates && - Array.isArray(route.geometry.coordinates) && - route.geometry.coordinates.every( - (coord) => Array.isArray(coord) && coord.length === 2, - ), - ) - .map((route, index) => ( - - - - ))} + {isMapReady && + matchedRoutes + .filter( + (route) => + route && + route.geometry && + route.geometry.coordinates && + Array.isArray(route.geometry.coordinates) && + route.geometry.coordinates.every( + (coord) => Array.isArray(coord) && coord.length === 2, + ), + ) + .map((route, index) => ( + + + + ))} {!isCapturing && ( diff --git a/src/pages/Draw/index.tsx b/src/pages/Draw/index.tsx index a090bd0..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]), ); @@ -123,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 5badc32..81e838b 100644 --- a/src/pages/Route/_components/RouteGrid.tsx +++ b/src/pages/Route/_components/RouteGrid.tsx @@ -5,6 +5,7 @@ import { Text, TouchableOpacity, RefreshControl, + View, } from 'react-native'; import { useCallback } from 'react'; import Card from '@/components/Card'; @@ -90,36 +91,42 @@ export default function RouteGrid({ onRouteCardPress }: RouteGridProps) { if (activeTab === 'completed') { const completedItem = item as CompletedCourseItem; return ( - onRouteCardPress(completedItem.id)} - /> + + onRouteCardPress(completedItem.id)} + /> + ); } else if (activeTab === 'liked') { const bookmarkedItem = item as BookmarkedCourseItem; return ( - onRouteCardPress(bookmarkedItem.id)} - /> + + onRouteCardPress(bookmarkedItem.id)} + /> + ); } else { const courseItem = item as CourseSearchItem; return ( - onRouteCardPress(courseItem.id)} - /> + + onRouteCardPress(courseItem.id)} + /> + ); } }, @@ -192,15 +199,15 @@ export default function RouteGrid({ onRouteCardPress }: RouteGridProps) { } ListFooterComponent={() => ( <> {loading && ( - + )} {error && currentData.length > 0 && ( @@ -250,7 +257,7 @@ const ErrorText = styled.Text({ }); const RetryButton = styled(TouchableOpacity)({ - backgroundColor: '#007AFF', + backgroundColor: '#2d2d2d', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8, @@ -261,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 108295c..8640066 100644 --- a/src/pages/Route/index.tsx +++ b/src/pages/Route/index.tsx @@ -10,6 +10,8 @@ import FloatingButton from '@/components/FloatingButton'; import RouteGrid from './_components/RouteGrid'; import type { RouteTabId, TabParamList } from '@/types/navigation.types'; import type { RouteStackParamList } from '@/navigation/RouteStackNavigator'; +import { useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { BookmarkedCourseItem, CourseSearchItem, @@ -20,11 +22,12 @@ import { 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: '생성한 경로' }, @@ -88,18 +91,17 @@ export default function Route({ navigation }: Props) { return ( -
tabs={tabs} activeTab={activeTab} onTabPress={handleTabPress} /> - + ); } @@ -108,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 63a3e78..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(); } @@ -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 aff08d2..af40973 100644 --- a/src/pages/Run/_components/RunMap.tsx +++ b/src/pages/Run/_components/RunMap.tsx @@ -54,7 +54,7 @@ function RunMap({ + 달린 시간 {localStats.runningTime} @@ -149,7 +155,6 @@ const StatLabel = styled.Text(({ theme }) => ({ })); const RunningTimeContainer = styled.View(({ theme }) => ({ - backgroundColor: theme.colors.primary[500], borderRadius: 12, paddingVertical: 16, paddingHorizontal: 20, @@ -157,18 +162,31 @@ const RunningTimeContainer = styled.View(({ theme }) => ({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', + position: 'relative', + overflow: 'hidden', })); +const RunningTimeGradient = styled(LinearGradient)({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 12, +}); + const RunningTimeLabel = styled.Text({ fontSize: 16, fontWeight: '600', color: '#ffffff', + zIndex: 1, }); const RunningTimeValue = styled.Text({ fontSize: 24, fontWeight: '700', color: '#ffffff', + zIndex: 1, }); export default React.memo(StatsContainer); diff --git a/src/pages/Run/index.tsx b/src/pages/Run/index.tsx index 0ac37c4..2059a54 100644 --- a/src/pages/Run/index.tsx +++ b/src/pages/Run/index.tsx @@ -18,7 +18,7 @@ import ControlContainer from './_components/ControlContainer'; import Modal from '@/components/Modal'; import Mapbox from '@rnmapbox/maps'; -type Props = NativeStackScreenProps; +type Props = NativeStackScreenProps; export default function Run({ route, navigation }: Props) { // 전역 Store에서 현재 코스 데이터 가져오기 @@ -79,12 +79,21 @@ 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(); + // 런닝이 시작되지 않은 상태에서만 초기화 + const { startTime } = useRunStore.getState(); + if (!startTime) { + resetLocationTracking(); + resetRunState(); + } // courseId가 있을 때만 경로 데이터를 로드 if (courseId) { loadCourseTopology(); @@ -192,7 +201,7 @@ export default function Run({ route, navigation }: Props) { 위치를 가져올 수 없습니다 - 새로고침 + 새로고침 )} @@ -272,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, @@ -309,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 cd57502..6273733 100644 --- a/src/services/courses.service.ts +++ b/src/services/courses.service.ts @@ -79,3 +79,21 @@ export async function searchCompletedCourses( }); return response.data; } + +export async function searchAdjacentCourses( + params: { + location: string; // "127.0,37.5" 형식 + radius: number; + limit?: number; + cursor?: { id: number; distance: number } | null; + }, + accessToken: string, +): Promise { + const response = await api.get('/api/courses/search/adjacent', { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return response.data; +} diff --git a/src/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/run.ts b/src/store/run.ts index d3d9b4f..9785915 100644 --- a/src/store/run.ts +++ b/src/store/run.ts @@ -185,23 +185,33 @@ const useRunStore = create((set, get) => ({ // 런닝 액션들 setRunning: (running) => set((state) => ({ ...state, ...running })), - startRun: () => + startRun: () => { + const startTime = new Date(); + console.log('🏃‍♂️ [RunStore] 새로운 런닝 시작:', startTime.toISOString()); set({ - startTime: new Date(), + startTime, pausedTime: 0, pauseStartTime: null, - }), + }); + }, - pauseRun: () => + pauseRun: () => { + const pauseStartTime = new Date(); + console.log('⏸️ [RunStore] 런닝 일시정지:', pauseStartTime.toISOString()); set({ - pauseStartTime: new Date(), - }), + pauseStartTime, + }); + }, resumeRun: () => set((state) => { if (state.pauseStartTime) { const pauseDuration = (new Date().getTime() - state.pauseStartTime.getTime()) / 1000; + console.log('▶️ [RunStore] 런닝 재시작:', { + pauseDuration: `${pauseDuration.toFixed(1)}초`, + totalPausedTime: `${(state.pausedTime + pauseDuration).toFixed(1)}초`, + }); return { pausedTime: state.pausedTime + pauseDuration, pauseStartTime: null, @@ -210,13 +220,15 @@ const useRunStore = create((set, get) => ({ return state; }), - stopRun: () => + stopRun: () => { + console.log('⏹️ [RunStore] 런닝 완전 종료 - 통계 초기화됨'); set({ startTime: null, pausedTime: 0, pauseStartTime: null, stats: initialStats, - }), + }); + }, // 코스 액션들 setCourseTopology: (courseTopology) => set({ courseTopology }), @@ -282,15 +294,42 @@ const useRunStore = create((set, get) => ({ }), resetRunState: () => { - set((state) => ({ - ...initialUIState, - ...initialErrorState, - ...initialRunningState, - // 코스 데이터는 유지 (currentCourseId, currentCourseData 보존) - courseTopology: null, - ...initialLocationTrackingState, - ...initialCourseValidationState, - })); + set((state) => { + // 런닝이 진행 중이면 상태를 보존 + if (state.startTime) { + console.log('🔄 [RunStore] 런닝 상태 보존 - 통계 초기화되지 않음'); + return { + ...initialUIState, + ...initialErrorState, + // 런닝 상태는 보존 + startTime: state.startTime, + pausedTime: state.pausedTime, + pauseStartTime: state.pauseStartTime, + stats: state.stats, + // 코스 데이터는 유지 (currentCourseId, currentCourseData 보존) + courseTopology: null, + // 위치 추적 상태는 보존 (경로 데이터 유지) + routeCoordinates: state.routeCoordinates, + location: state.location, + isTracking: state.isTracking, + subscriber: state.subscriber, + errorMsg: state.errorMsg, + ...initialCourseValidationState, + }; + } + + // 런닝이 시작되지 않은 경우에만 완전 초기화 + console.log('🔄 [RunStore] 런닝 상태 완전 초기화 - 통계 초기화됨'); + return { + ...initialUIState, + ...initialErrorState, + ...initialRunningState, + // 코스 데이터는 유지 (currentCourseId, currentCourseData 보존) + courseTopology: null, + ...initialLocationTrackingState, + ...initialCourseValidationState, + }; + }); }, })); diff --git a/src/types/courses.types.ts b/src/types/courses.types.ts index 1f5b338..77b2fde 100644 --- a/src/types/courses.types.ts +++ b/src/types/courses.types.ts @@ -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; } 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 index 1d870dc..1d98ae6 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -9,25 +9,29 @@ export const formatDate = (dateString: string): string => { export const formatDistance = (distance: number): string => { if (distance < 1000) { - return `${distance}m`; + return `${Math.round(distance * 100) / 100}m`; } - return `${(distance / 1000).toFixed(1)}km`; + return `${Math.round((distance / 1000) * 100) / 100}km`; }; export const formatTime = (time: number): string => { const hours = Math.floor(time / 3600); const minutes = Math.floor((time % 3600) / 60); + const seconds = Math.round((time % 60) * 100) / 100; if (hours > 0) { - return `${hours}시간 ${minutes}분`; + return `${hours}시간 ${minutes}분 ${seconds}초`; } - return `${minutes}분`; + if (minutes > 0) { + return `${minutes}분 ${seconds}초`; + } + return `${seconds}초`; }; export const formatPace = (paceSeconds: number): string => { if (paceSeconds === 0) return '0\'00"'; const minutes = Math.floor(paceSeconds / 60); - const seconds = Math.floor(paceSeconds % 60); - return `${minutes}'${seconds.toString().padStart(2, '0')}"`; + const seconds = Math.round((paceSeconds % 60) * 100) / 100; + return `${minutes}'${seconds.toFixed(2).padStart(5, '0')}"`; };