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')}"`;
+};