diff --git a/eas.json b/eas.json
index bdcde1c..a29eb10 100644
--- a/eas.json
+++ b/eas.json
@@ -10,13 +10,20 @@
"preview": {
"distribution": "internal"
},
- "production": {},
+ "production": {
+ "android": {
+ "buildType": "apk"
+ }
+ },
"development-simulator": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": true
},
+ "android": {
+ "buildType": "apk"
+ },
"environment": "development"
}
},
diff --git a/src/components/Card.tsx b/src/components/Card.tsx
index d871e4b..0d71222 100644
--- a/src/components/Card.tsx
+++ b/src/components/Card.tsx
@@ -21,6 +21,7 @@ interface CardProps {
onPress?: () => void;
style?: ViewStyle;
fullWidth?: boolean;
+ variant?: 'default' | 'light';
}
export default function Card({
@@ -31,6 +32,7 @@ export default function Card({
onPress,
style,
fullWidth = false,
+ variant = 'default',
}: CardProps) {
const { width: screenWidth } = Dimensions.get('window');
const [imageError, setImageError] = useState(false);
@@ -52,6 +54,7 @@ export default function Card({
)}
{content?.hasStar && (
-
+
)}
@@ -86,16 +89,19 @@ export default function Card({
if (finalMode === 'image-with-text') {
const contentWidth = screenWidth - 64;
- const imageSize = contentWidth * 0.45;
+ const imageSize = contentWidth * 0.36; // 0.45에서 0.36으로 변경 (약 20% 감소)
return (
- {content?.cardTitle && {content.cardTitle}}
+ {content?.cardTitle && (
+ {content.cardTitle}
+ )}
{imageSource && (
- {content.title && {content.title}}
- {content.subtitle && {content.subtitle}}
+ {content.title && (
+ {content.title}
+ )}
+ {content.subtitle && (
+ {content.subtitle}
+ )}
{content.stats && (
{content.stats.map((stat, index) => (
- {stat.label}
- {stat.value}
+ {stat.label}
+ {stat.value}
))}
@@ -130,6 +140,7 @@ export default function Card({
(({ fullWidth, mode }) => ({
- backgroundColor: 'rgba(255, 255, 255, 0.1)',
- borderColor: 'rgba(255, 255, 255, 0.2)',
+ variant: 'default' | 'light';
+}>(({ fullWidth, mode, variant }) => ({
+ backgroundColor: variant === 'light' ? '#ffffff' : 'rgba(255, 255, 255, 0.1)',
+ borderColor:
+ variant === 'light' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
borderRadius: 12,
padding: mode === 'only-image' ? 0 : 16,
@@ -153,12 +166,14 @@ const CardContainer = styled(TouchableOpacity)<{
aspectRatio: fullWidth ? undefined : 1,
}));
-const CardTitle = styled.Text({
- color: '#ffffff',
- fontSize: 16,
- fontWeight: '600',
- marginBottom: 8,
-});
+const CardTitle = styled.Text<{ variant?: 'default' | 'light' }>(
+ ({ variant }) => ({
+ color: variant === 'light' ? '#000000' : '#ffffff',
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 8,
+ }),
+);
const ContentRow = styled.View({
flexDirection: 'row',
@@ -185,18 +200,21 @@ const CardContentContainer = styled.View({
flex: 1,
});
-const Title = styled.Text({
- color: '#ffffff',
+const Title = styled.Text<{ variant?: 'default' | 'light' }>(({ variant }) => ({
+ color: variant === 'light' ? '#000000' : '#ffffff',
fontSize: 18,
fontWeight: 'bold',
marginBottom: 4,
-});
+}));
-const Subtitle = styled.Text({
- color: 'rgba(255, 255, 255, 0.7)',
- fontSize: 14,
- marginBottom: 8,
-});
+const Subtitle = styled.Text<{ variant?: 'default' | 'light' }>(
+ ({ variant }) => ({
+ color:
+ variant === 'light' ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)',
+ fontSize: 14,
+ marginBottom: 8,
+ }),
+);
const StatsContainer = styled.View({
flexDirection: 'row',
@@ -207,17 +225,23 @@ const StatItem = styled.View({
alignItems: 'center',
});
-const StatLabel = styled.Text({
- color: 'rgba(255, 255, 255, 0.8)',
- fontSize: 12,
- marginBottom: 2,
-});
+const StatLabel = styled.Text<{ variant?: 'default' | 'light' }>(
+ ({ variant }) => ({
+ color:
+ variant === 'light' ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.8)',
+ fontSize: 12,
+ marginBottom: 2,
+ }),
+);
-const StatValue = styled.Text({
- color: 'rgba(255, 255, 255, 0.8)',
- fontSize: 14,
- fontWeight: '600',
-});
+const StatValue = styled.Text<{ variant?: 'default' | 'light' }>(
+ ({ variant }) => ({
+ color:
+ variant === 'light' ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.8)',
+ fontSize: 14,
+ fontWeight: '600',
+ }),
+);
const StarIcon = styled(Star)({
position: 'absolute',
@@ -254,7 +278,7 @@ const LoadingContainer = styled.View({
});
const LoadingText = styled.Text({
- color: '#007AFF',
+ color: '#2d2d2d',
fontSize: 12,
fontWeight: '500',
});
diff --git a/src/components/Map.tsx b/src/components/Map.tsx
index 6fe60cb..19dc9f1 100644
--- a/src/components/Map.tsx
+++ b/src/components/Map.tsx
@@ -9,6 +9,7 @@ interface MapProps {
cameraRef: RefObject;
initialLocation: Position | null;
onUserLocationUpdate?: (location: Mapbox.Location) => void;
+ onMapReady?: () => void;
children?: React.ReactNode;
showUserLocation?: boolean;
}
@@ -18,6 +19,7 @@ export default function Map({
cameraRef,
initialLocation,
onUserLocationUpdate,
+ onMapReady,
children,
showUserLocation = true,
}: MapProps) {
@@ -25,7 +27,11 @@ export default function Map({
const safeInitialLocation = initialLocation || [127.0276, 37.4979];
return (
-
+
{message}
+
{cancelText}
+
{loading ? '저장 중...' : confirmText}
@@ -101,16 +112,24 @@ const ModalButton = styled(TouchableOpacity)<{
flex: 1;
padding: 12px 16px;
border-radius: 8px;
- background-color: ${({ isPrimary, disabled }) => {
- if (disabled) return theme.colors.gray[300];
- return isPrimary ? theme.colors.primary[500] : theme.colors.gray[100];
- }};
align-items: center;
+ position: relative;
+ overflow: hidden;
opacity: ${({ disabled }) => (disabled ? 0.6 : 1)};
`;
+const ModalButtonGradient = styled(LinearGradient)`
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-radius: 8px;
+`;
+
const ModalButtonText = styled(Text)<{ isPrimary?: boolean }>`
font-size: 16px;
font-weight: 500;
- color: ${({ isPrimary }) => (isPrimary ? '#ffffff' : theme.colors.gray[700])};
+ color: #ffffff;
+ z-index: 1;
`;
diff --git a/src/components/TabNavigation.tsx b/src/components/TabNavigation.tsx
index 870d76d..78c625f 100644
--- a/src/components/TabNavigation.tsx
+++ b/src/components/TabNavigation.tsx
@@ -46,7 +46,7 @@ const TabButton = styled.TouchableOpacity<{
alignItems: 'center',
paddingVertical: 8,
borderBottomWidth: isActive ? 2 : 0,
- borderBottomColor: '#ff6b35',
+ borderBottomColor: '#2d2d2d',
}));
const TabText = styled.Text<{
@@ -54,5 +54,5 @@ const TabText = styled.Text<{
}>(({ isActive }) => ({
fontSize: 14,
fontWeight: isActive ? '600' : '400',
- color: isActive ? '#ff6b35' : '#666666',
+ color: isActive ? '#2d2d2d' : '#666666',
}));
diff --git a/src/constants/colors.ts b/src/constants/colors.ts
index a474d61..6d81e1b 100644
--- a/src/constants/colors.ts
+++ b/src/constants/colors.ts
@@ -10,64 +10,64 @@ export type ColorKey =
export const COLOR_TOKENS: Record> = {
primary: {
- 50: '#eff6ff',
- 100: '#dbeafe',
- 200: '#bfdbfe',
- 300: '#93c5fd',
- 400: '#60a5fa',
- 500: '#3b82f6',
- 600: '#2563eb',
- 700: '#1d4ed8',
- 800: '#1e40af',
- 900: '#1e3a8a',
+ 50: '#f9fafb',
+ 100: '#f3f4f6',
+ 200: '#e5e7eb',
+ 300: '#d1d5db',
+ 400: '#9ca3af',
+ 500: '#6b7280',
+ 600: '#4b5563',
+ 700: '#374151',
+ 800: '#1f2937',
+ 900: '#111827',
},
secondary: {
- 50: '#f5f3ff',
- 100: '#ede9fe',
- 200: '#ddd6fe',
- 300: '#c4b5fd',
- 400: '#a78bfa',
- 500: '#8b5cf6',
- 600: '#7c3aed',
- 700: '#6d28d9',
- 800: '#5b21b6',
- 900: '#4c1d95',
+ 50: '#f9fafb',
+ 100: '#f3f4f6',
+ 200: '#e5e7eb',
+ 300: '#d1d5db',
+ 400: '#9ca3af',
+ 500: '#6b7280',
+ 600: '#4b5563',
+ 700: '#374151',
+ 800: '#1f2937',
+ 900: '#111827',
},
success: {
- 50: '#ecfdf5',
- 100: '#d1fae5',
- 200: '#a7f3d0',
- 300: '#6ee7b7',
- 400: '#34d399',
- 500: '#10b981',
- 600: '#059669',
- 700: '#047857',
- 800: '#065f46',
- 900: '#064e3b',
+ 50: '#f9fafb',
+ 100: '#f3f4f6',
+ 200: '#e5e7eb',
+ 300: '#d1d5db',
+ 400: '#9ca3af',
+ 500: '#6b7280',
+ 600: '#4b5563',
+ 700: '#374151',
+ 800: '#1f2937',
+ 900: '#111827',
},
warning: {
- 50: '#fffbeb',
- 100: '#fef3c7',
- 200: '#fde68a',
- 300: '#fcd34d',
- 400: '#fbbf24',
- 500: '#f59e0b',
- 600: '#d97706',
- 700: '#b45309',
- 800: '#92400e',
- 900: '#78350f',
+ 50: '#f9fafb',
+ 100: '#f3f4f6',
+ 200: '#e5e7eb',
+ 300: '#d1d5db',
+ 400: '#9ca3af',
+ 500: '#6b7280',
+ 600: '#4b5563',
+ 700: '#374151',
+ 800: '#1f2937',
+ 900: '#111827',
},
error: {
- 50: '#fef2f2',
- 100: '#fee2e2',
- 200: '#fecaca',
- 300: '#fca5a5',
- 400: '#f87171',
- 500: '#ef4444',
- 600: '#dc2626',
- 700: '#b91c1c',
- 800: '#991b1b',
- 900: '#7f1d1d',
+ 50: '#f9fafb',
+ 100: '#f3f4f6',
+ 200: '#e5e7eb',
+ 300: '#d1d5db',
+ 400: '#9ca3af',
+ 500: '#6b7280',
+ 600: '#4b5563',
+ 700: '#374151',
+ 800: '#1f2937',
+ 900: '#111827',
},
gray: {
50: '#f9fafb',
diff --git a/src/hooks/api/useRecordsApi.ts b/src/hooks/api/useRecordsApi.ts
new file mode 100644
index 0000000..f9e02f1
--- /dev/null
+++ b/src/hooks/api/useRecordsApi.ts
@@ -0,0 +1,294 @@
+import { useCallback, useState, useEffect } from 'react';
+import {
+ searchRunningRecords,
+ getRunningDashboard,
+} from '@/services/running.service';
+import useAuthStore from '@/store/auth';
+import type { AxiosErrorResponse } from '@/types/api.types';
+import type {
+ RunningRecord,
+ RunningRecordsRequest,
+ RunningDashboard,
+ RunningDashboardRequest,
+ TimeRange,
+} from '@/types/records.types';
+
+export function useRunningRecords() {
+ const { accessToken } = useAuthStore();
+ const [records, setRecords] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [hasMore, setHasMore] = useState(true);
+ const [cursor, setCursor] = useState<{ id: number } | null>(null);
+
+ const loadRecords = useCallback(
+ async (params: RunningRecordsRequest, reset = false) => {
+ if (!accessToken) return;
+
+ console.log('📊 [useRunningRecords] loadRecords 호출:', {
+ params,
+ reset,
+ loading,
+ });
+
+ if (loading) {
+ console.log('📊 [useRunningRecords] 이미 로딩 중이므로 스킵');
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const requestParams = {
+ ...params,
+ limit: 20,
+ };
+
+ // reset이 아닌 경우에만 cursor 추가
+ if (!reset && params.cursor) {
+ requestParams.cursor = params.cursor;
+ }
+
+ console.log('📊 [useRunningRecords] API 요청 파라미터:', requestParams);
+ const response = await searchRunningRecords(requestParams, accessToken);
+
+ console.log('📊 [useRunningRecords] API 응답:', response);
+
+ if (reset) {
+ setRecords(response.results);
+ } else {
+ setRecords((prev) => [...prev, ...response.results]);
+ }
+
+ // nextCursor가 현재 데이터의 마지막 ID와 같으면 더 이상 데이터가 없음
+ const lastResultId = response.results[response.results.length - 1]?.id;
+ const hasMoreData =
+ response.nextCursor && response.nextCursor.id !== lastResultId;
+
+ console.log('📊 [useRunningRecords] 데이터 종료 조건 확인:', {
+ lastResultId,
+ nextCursor: response.nextCursor,
+ hasMoreData,
+ resultsLength: response.results.length,
+ });
+
+ if (hasMoreData) {
+ setCursor(response.nextCursor);
+ setHasMore(true);
+ } else {
+ setCursor(null);
+ setHasMore(false);
+ }
+ } catch (error: unknown) {
+ console.error('📊 [useRunningRecords] API 오류:', error);
+ let errorMessage = '런닝 기록을 불러오는데 실패했습니다.';
+
+ if (error && typeof error === 'object' && 'response' in error) {
+ const axiosError = error as AxiosErrorResponse;
+ const status = axiosError.status;
+
+ if (status === 500) {
+ errorMessage =
+ '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
+ } else if (status === 401) {
+ errorMessage = '로그인이 필요합니다.';
+ } else if (status === 403) {
+ errorMessage = '접근 권한이 없습니다.';
+ }
+ } else if (error instanceof Error) {
+ errorMessage = error.message;
+ }
+
+ setError(errorMessage);
+ } finally {
+ setLoading(false);
+ }
+ },
+ [accessToken, cursor],
+ );
+
+ const handleLoadMore = useCallback(() => {
+ if (hasMore && !loading && !error && cursor) {
+ console.log('📊 [useRunningRecords] handleLoadMore 호출:', {
+ hasMore,
+ loading,
+ error,
+ cursor,
+ });
+ loadRecords({ cursor }, false);
+ }
+ }, [hasMore, loading, error, cursor, loadRecords]);
+
+ const handleRefresh = useCallback(async () => {
+ setError(null);
+ setCursor(null);
+ await loadRecords({}, true);
+ }, [loadRecords]);
+
+ return {
+ records,
+ loading,
+ error,
+ hasMore,
+ loadRecords,
+ handleLoadMore,
+ handleRefresh,
+ };
+}
+
+export function useRunningDashboard() {
+ const { accessToken } = useAuthStore();
+ const [dashboard, setDashboard] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const loadDashboard = useCallback(
+ async (params: RunningDashboardRequest) => {
+ if (!accessToken) return;
+
+ console.log('📊 [useRunningDashboard] loadDashboard 호출:', {
+ params,
+ loading,
+ });
+
+ if (loading) {
+ console.log('📊 [useRunningDashboard] 이미 로딩 중이므로 스킵');
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const response = await getRunningDashboard(params, accessToken);
+ console.log('📊 [useRunningDashboard] API 응답:', response);
+ setDashboard(response);
+ } catch (error: unknown) {
+ console.error('📊 [useRunningDashboard] API 오류:', error);
+ let errorMessage = '런닝 통계를 불러오는데 실패했습니다.';
+
+ if (error && typeof error === 'object' && 'response' in error) {
+ const axiosError = error as AxiosErrorResponse;
+ const status = axiosError.status;
+
+ if (status === 500) {
+ errorMessage =
+ '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
+ } else if (status === 401) {
+ errorMessage = '로그인이 필요합니다.';
+ } else if (status === 403) {
+ errorMessage = '접근 권한이 없습니다.';
+ }
+ } else if (error instanceof Error) {
+ errorMessage = error.message;
+ }
+
+ setError(errorMessage);
+ } finally {
+ setLoading(false);
+ }
+ },
+ [accessToken],
+ );
+
+ return {
+ dashboard,
+ loading,
+ error,
+ loadDashboard,
+ };
+}
+
+// 기간 범위 계산 유틸리티
+export function getTimeRangeParams(
+ timeRange: TimeRange,
+ selectedWeek?: number,
+ selectedMonth?: number,
+ selectedYear?: number,
+): RunningDashboardRequest {
+ const now = new Date();
+
+ switch (timeRange) {
+ case 'week': {
+ const weekNumber = selectedWeek || 1;
+ const year = selectedYear || now.getFullYear();
+ const month = selectedMonth || now.getMonth() + 1;
+
+ // 해당 월의 첫 번째 날
+ const firstDayOfMonth = new Date(year, month - 1, 1);
+ const firstDayOfWeek = firstDayOfMonth.getDay();
+
+ // 해당 주의 시작일 계산
+ const startOfWeek = new Date(firstDayOfMonth);
+ startOfWeek.setDate(
+ firstDayOfMonth.getDate() - firstDayOfWeek + (weekNumber - 1) * 7,
+ );
+ startOfWeek.setHours(0, 0, 0, 0);
+
+ const endOfWeek = new Date(startOfWeek);
+ endOfWeek.setDate(startOfWeek.getDate() + 6);
+ endOfWeek.setHours(23, 59, 59, 999);
+
+ return {
+ since: startOfWeek.toISOString(),
+ until: endOfWeek.toISOString(),
+ };
+ }
+ case 'month': {
+ const month = selectedMonth || now.getMonth() + 1;
+ const year = selectedYear || now.getFullYear();
+
+ const startOfMonth = new Date(year, month - 1, 1);
+ const endOfMonth = new Date(year, month, 0, 23, 59, 59, 999);
+
+ return {
+ since: startOfMonth.toISOString(),
+ until: endOfMonth.toISOString(),
+ };
+ }
+ case 'year': {
+ const year = selectedYear || now.getFullYear();
+ const startOfYear = new Date(year, 0, 1);
+ const endOfYear = new Date(year, 11, 31, 23, 59, 59, 999);
+
+ return {
+ since: startOfYear.toISOString(),
+ until: endOfYear.toISOString(),
+ };
+ }
+ case 'all':
+ default:
+ return {};
+ }
+}
+
+export function getTimeRangeDisplayText(
+ timeRange: TimeRange,
+ selectedWeek?: number,
+ selectedMonth?: number,
+ selectedYear?: number,
+): string {
+ const now = new Date();
+
+ switch (timeRange) {
+ case 'week': {
+ const weekNumber = selectedWeek || 1;
+ const year = selectedYear || now.getFullYear();
+ const month = selectedMonth || now.getMonth() + 1;
+ return `${year}년 ${month}월 ${weekNumber}째 주`;
+ }
+ case 'month': {
+ const month = selectedMonth || now.getMonth() + 1;
+ const year = selectedYear || now.getFullYear();
+ return `${year}년 ${month}월`;
+ }
+ case 'year': {
+ const year = selectedYear || now.getFullYear();
+ return `${year}년`;
+ }
+ case 'all':
+ default:
+ return '전체';
+ }
+}
diff --git a/src/hooks/api/useRouteApi.ts b/src/hooks/api/useRouteApi.ts
index a24cc3e..3abea5d 100644
--- a/src/hooks/api/useRouteApi.ts
+++ b/src/hooks/api/useRouteApi.ts
@@ -3,10 +3,12 @@ import {
searchUserCourses,
searchBookmarkedCourses,
searchCompletedCourses,
+ searchAdjacentCourses,
} from '@/services/courses.service';
import useAuthStore from '@/store/auth';
import useRouteStore from '@/store/route';
import type { AxiosErrorResponse } from '@/types/api.types';
+import type { Position } from 'geojson';
export function useRouteData() {
const { accessToken } = useAuthStore();
@@ -346,3 +348,32 @@ export function useCompletedCourses() {
handleRefresh,
};
}
+
+export function useAdjacentCourses() {
+ const { accessToken } = useAuthStore();
+
+ const searchAdjacent = useCallback(
+ async (location: Position, radius: number = 1000) => {
+ if (!accessToken) return [];
+
+ try {
+ const locationString = `${location[0]},${location[1]}`;
+ const response = await searchAdjacentCourses(
+ {
+ location: locationString,
+ radius,
+ limit: 3,
+ },
+ accessToken,
+ );
+ return response.results;
+ } catch (error) {
+ console.error('주변 경로 검색 실패:', error);
+ return [];
+ }
+ },
+ [accessToken],
+ );
+
+ return { searchAdjacent };
+}
diff --git a/src/hooks/useLocationManager.ts b/src/hooks/useLocationManager.ts
index 986ec12..f1118fa 100644
--- a/src/hooks/useLocationManager.ts
+++ b/src/hooks/useLocationManager.ts
@@ -78,7 +78,7 @@ export function useLocationManager() {
};
return {
- initialLocation: currentUserLocation.current, // 현재 위치가 없으면 null
+ initialLocation: currentUserLocation.current, // 현재 위치만 사용
locationLoading: locationLoading,
location,
errorMsg,
diff --git a/src/hooks/useLocationTracking.ts b/src/hooks/useLocationTracking.ts
index 7e6b899..ec60d11 100644
--- a/src/hooks/useLocationTracking.ts
+++ b/src/hooks/useLocationTracking.ts
@@ -40,9 +40,19 @@ export function useLocationTracking() {
const startTracking = useCallback(async () => {
if (isTracking) return;
- if (location) {
+ // 기존 경로가 없을 때만 초기 위치를 설정
+ const currentCoords = useRunStore.getState().routeCoordinates;
+ if (currentCoords.length === 0 && location) {
const { latitude, longitude } = location.coords;
+ console.log('📍 [LocationTracking] 위치 추적 시작 - 초기 위치 설정:', {
+ latitude,
+ longitude,
+ });
setRouteCoordinates([[longitude, latitude]]);
+ } else if (currentCoords.length > 0) {
+ console.log('📍 [LocationTracking] 위치 추적 재시작 - 기존 경로 유지:', {
+ coordinatesCount: currentCoords.length,
+ });
}
const newSubscriber = await Location.watchPositionAsync(
@@ -86,6 +96,7 @@ export function useLocationTracking() {
]);
const pauseTracking = useCallback(() => {
+ console.log('⏸️ [LocationTracking] 위치 추적 일시정지');
if (subscriber) {
subscriber.remove();
setSubscriber(null);
@@ -94,6 +105,7 @@ export function useLocationTracking() {
}, [subscriber, setSubscriber, setIsTracking]);
const stopTracking = useCallback(() => {
+ console.log('⏹️ [LocationTracking] 위치 추적 완전 종료 - 경로 초기화됨');
if (subscriber) {
subscriber.remove();
setSubscriber(null);
diff --git a/src/hooks/useLongPress.ts b/src/hooks/useLongPress.ts
index ef6f4cc..fc1eba5 100644
--- a/src/hooks/useLongPress.ts
+++ b/src/hooks/useLongPress.ts
@@ -11,15 +11,12 @@ export function useLongPress({
onComplete,
}: UseLongPressOptions) {
const [isPressing, setIsPressing] = useState(false);
- const [pressProgress, setPressProgress] = useState(0);
const pressTimer = useRef(null);
- const progressTimer = useRef(null);
const animatedValue = useRef(new Animated.Value(0)).current;
const startPress = useCallback(() => {
setIsPressing(true);
- setPressProgress(0);
pressTimer.current = setTimeout(() => {
onComplete();
@@ -31,29 +28,16 @@ export function useLongPress({
duration,
useNativeDriver: false,
}).start();
-
- progressTimer.current = setInterval(() => {
- setPressProgress((prev) => {
- const newProgress = prev + 100 / (duration / 100);
- return newProgress >= 100 ? 100 : newProgress;
- });
- }, 100);
}, [duration, onComplete, animatedValue]);
const stopPress = useCallback(() => {
setIsPressing(false);
- setPressProgress(0);
if (pressTimer.current) {
clearTimeout(pressTimer.current);
pressTimer.current = null;
}
- if (progressTimer.current) {
- clearInterval(progressTimer.current);
- progressTimer.current = null;
- }
-
Animated.timing(animatedValue, {
toValue: 0,
duration: 200,
@@ -63,7 +47,6 @@ export function useLongPress({
return {
isPressing,
- pressProgress,
animatedValue,
startPress,
stopPress,
diff --git a/src/hooks/useMapCapture.ts b/src/hooks/useMapCapture.ts
index 211329b..d3595c9 100644
--- a/src/hooks/useMapCapture.ts
+++ b/src/hooks/useMapCapture.ts
@@ -19,8 +19,14 @@ export function useMapCapture(
throw new Error('맵이 준비되지 않았습니다.');
}
+ // 캡처 전에 잠시 대기하여 현재 렌더링이 완료되도록 함
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
setIsCapturing(true);
+ // 소스 제거 후 추가 대기
+ await new Promise((resolve) => setTimeout(resolve, 200));
+
try {
const coordinatesToUse =
routeCoordinates ||
diff --git a/src/hooks/useRunModals.ts b/src/hooks/useRunModals.ts
index 5970ff8..518c790 100644
--- a/src/hooks/useRunModals.ts
+++ b/src/hooks/useRunModals.ts
@@ -14,7 +14,7 @@ import type { RunningRecordRequest } from '@/types/run.types';
import type { AxiosErrorResponse } from '@/types/api.types';
type Props = {
- navigation: NativeStackNavigationProp;
+ navigation: NativeStackNavigationProp;
mapRef?: RefObject;
cameraRef?: RefObject;
courseId?: number;
@@ -82,6 +82,18 @@ export function useRunModals({
return;
}
+ // 코스 선택 시 최소 이동 거리 체크
+ if (courseId && routeCoordinates.length < 2) {
+ Toast.show({
+ type: 'error',
+ text1: '저장 불가',
+ text2:
+ '선택한 코스와 실제 러닝 경로가 많이 다릅니다. 코스를 따라 달려보세요.',
+ });
+ cleanupAndGoBack();
+ return;
+ }
+
try {
setUI({ savingRecord: true });
setError('save', null);
@@ -131,7 +143,20 @@ export function useRunModals({
imageUrl,
};
- await saveRunningRecord(runningRecord, courseId);
+ console.log('📤 [useRunModals] 런닝 기록 저장 요청 페이로드:', {
+ runningRecord,
+ courseId,
+ pathLength: path.length,
+ startTime: startTime.toISOString(),
+ endTime: endTime.toISOString(),
+ pace: paceValue,
+ calories: stats.calories,
+ imageUrl,
+ });
+
+ const response = await saveRunningRecord(runningRecord, courseId);
+
+ console.log('📥 [useRunModals] 런닝 기록 저장 응답:', response);
Toast.show({
type: 'success',
@@ -143,10 +168,20 @@ export function useRunModals({
} catch (error: unknown) {
let errorMessage = '런닝 기록 저장에 실패했습니다.';
+ // API 응답 로그 추가
+ console.error('🚨 [useRunModals] 런닝 기록 저장 실패:', error);
+
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as AxiosErrorResponse;
const status = axiosError.status;
+ console.error('🚨 [useRunModals] API 응답 상태코드:', status);
+ console.error('🚨 [useRunModals] API 응답 데이터:', axiosError.data);
+ console.error(
+ '🚨 [useRunModals] API 응답 상태텍스트:',
+ axiosError.statusText,
+ );
+
if (status === 400) {
const errorData = axiosError.data;
if (errorData?.message) {
@@ -156,10 +191,14 @@ export function useRunModals({
}
} else if (status === 401) {
errorMessage = '로그인이 필요합니다.';
+ } else if (status === 409) {
+ errorMessage =
+ '선택한 코스와 실제 러닝 경로가 많이 다릅니다. 코스를 따라 달려보세요.';
} else if (status === 500) {
errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
} else if (error instanceof Error) {
+ console.error('🚨 [useRunModals] 일반 에러:', error.message);
errorMessage = error.message;
}
diff --git a/src/navigation/RouteStackNavigator.tsx b/src/navigation/RouteStackNavigator.tsx
index 7df54f1..ef57d48 100644
--- a/src/navigation/RouteStackNavigator.tsx
+++ b/src/navigation/RouteStackNavigator.tsx
@@ -20,10 +20,18 @@ export type RouteStackParamList = {
const Stack = createNativeStackNavigator();
-export default function RouteStackNavigator() {
+interface RouteStackNavigatorProps {
+ onStartRun?: (courseId: number) => void;
+}
+
+export default function RouteStackNavigator({
+ onStartRun,
+}: RouteStackNavigatorProps) {
return (
-
+
+ {(props) => }
+
diff --git a/src/navigation/RunTabNavigator.tsx b/src/navigation/RunTabNavigator.tsx
new file mode 100644
index 0000000..a9e6472
--- /dev/null
+++ b/src/navigation/RunTabNavigator.tsx
@@ -0,0 +1,242 @@
+import { useState, useRef, useCallback, useEffect } from 'react';
+import { View, Animated } from 'react-native';
+import styled from '@emotion/native';
+import { Play } from 'lucide-react-native';
+import { createNativeStackNavigator } from '@react-navigation/native-stack';
+import { useFocusEffect } from '@react-navigation/native';
+import { LinearGradient } from 'expo-linear-gradient';
+import Mapbox from '@rnmapbox/maps';
+import TabNavigation from '@/components/TabNavigation';
+import Header from '@/components/Header';
+import { useLocationManager } from '@/hooks/useLocationManager';
+import { useLongPress } from '@/hooks/useLongPress';
+import { useAdjacentCourses } from '@/hooks/api/useRouteApi';
+import type { CourseSearchItem } from '@/types/courses.types';
+
+import Route from '@/pages/Route';
+import Draw from '@/pages/Draw';
+import RouteSave from '@/pages/RouteSave';
+import Detail from '@/pages/Detail';
+import Run from '@/pages/Run';
+import RecommendationContainer from '@/pages/Run/_components/RecommendationContainer';
+
+const Stack = createNativeStackNavigator();
+
+type RunTabId = 'quickstart' | 'courseselection';
+
+const tabs: Array<{ id: RunTabId; title: string }> = [
+ { id: 'quickstart', title: '바로 달리기' },
+ { id: 'courseselection', title: '코스 선택하기' },
+];
+
+// 바로가기 메인 컴포넌트
+function QuickStartMain({ navigation }: { navigation: any }) {
+ const [activeTab, setActiveTab] = useState('quickstart');
+ const [recommendations, setRecommendations] = useState(
+ [],
+ );
+ const mapRef = useRef(null);
+ const cameraRef = useRef(null);
+
+ const {
+ initialLocation,
+ locationLoading,
+ flyToCurrentUserLocation,
+ handleUserLocationUpdate,
+ refreshLocation,
+ } = useLocationManager();
+
+ const { searchAdjacent } = useAdjacentCourses();
+
+ // 추천 경로 로드 함수
+ const loadRecommendations = useCallback(async () => {
+ if (initialLocation) {
+ try {
+ const results = await searchAdjacent(initialLocation, 1000);
+ setRecommendations(results);
+ } catch (error) {
+ console.error('추천 경로 로드 실패:', error);
+ }
+ }
+ }, [initialLocation, searchAdjacent]);
+
+ // 탭이 포커스될 때마다 위치 새로고침 및 추천 경로 로드
+ useFocusEffect(
+ useCallback(() => {
+ if (!initialLocation && !locationLoading) {
+ refreshLocation();
+ }
+ if (initialLocation) {
+ loadRecommendations();
+ }
+ }, [
+ initialLocation,
+ locationLoading,
+ refreshLocation,
+ loadRecommendations,
+ ]),
+ );
+
+ const handleStartPress = () => {
+ navigation.navigate('Run', {});
+ };
+
+ const handleRecommendationPress = (item: CourseSearchItem) => {
+ navigation.navigate('Run', { courseId: item.id });
+ };
+
+ const { isPressing, animatedValue, startPress, stopPress } = useLongPress({
+ onComplete: handleStartPress,
+ });
+
+ const handleTabPress = (tabId: RunTabId) => {
+ setActiveTab(tabId);
+ };
+
+ const renderContent = () => {
+ if (activeTab === 'quickstart') {
+ return (
+
+ {initialLocation && (
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ {isPressing ? '러닝 시작 중...' : '러닝 시작'}
+
+
+
+
+
+ );
+ } else {
+ return ;
+ }
+ };
+
+ return (
+
+
+
+ tabs={tabs}
+ activeTab={activeTab}
+ onTabPress={handleTabPress}
+ />
+ {renderContent()}
+
+ );
+}
+
+export default function RunTabNavigator() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+const Container = styled.View({
+ flex: 1,
+ backgroundColor: '#ffffff',
+});
+
+const QuickStartContainer = styled.View({
+ flex: 1,
+ position: 'relative',
+});
+
+const MapView = styled(Mapbox.MapView)({
+ flex: 1,
+});
+
+const StartButtonContainer = styled.View({
+ position: 'absolute',
+ bottom: 120,
+ left: 20,
+ right: 20,
+});
+
+const StartButton = styled.TouchableOpacity({
+ height: 56,
+ borderRadius: 8,
+ justifyContent: 'center',
+ alignItems: 'center',
+ position: 'relative',
+ overflow: 'hidden',
+});
+
+const StartButtonGradient = styled(LinearGradient)({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ borderRadius: 8,
+});
+
+const StartButtonProgress = styled(Animated.View)({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ height: '100%',
+ backgroundColor: '#000000',
+ borderRadius: 8,
+});
+
+const StartButtonContent = styled.View({
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ zIndex: 1,
+});
+
+const PlayIcon = styled(Play)({
+ // styled component for Play icon
+});
+
+const StartButtonText = styled.Text({
+ color: '#ffffff',
+ fontSize: 16,
+ fontWeight: '600',
+});
diff --git a/src/navigation/TabNavigator.tsx b/src/navigation/TabNavigator.tsx
index 60b6107..a736bcd 100644
--- a/src/navigation/TabNavigator.tsx
+++ b/src/navigation/TabNavigator.tsx
@@ -1,31 +1,32 @@
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { getFocusedRouteNameFromRoute } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import {
- Star,
- AudioWaveform,
- Play,
- Laugh,
- Settings,
-} from 'lucide-react-native';
+import { View } from 'react-native';
+import { Star, FileText, Play, Laugh, Settings } from 'lucide-react-native';
import WebCommunity from '@/pages/WebCommunity';
import Home from '@/pages/Home';
-import RouteStackNavigator from '@/navigation/RouteStackNavigator';
+import Records from '@/pages/Records';
+import RunTabNavigator from '@/navigation/RunTabNavigator';
import Run from '@/pages/Run';
import type { TabParamList } from '@/types/navigation.types';
import WebMyPage from '@/pages/WebMyPage';
+// RunTab 전용 컴포넌트
+function RunTabWithReset() {
+ return ;
+}
+
const Tab = createBottomTabNavigator();
-const TAB_BAR_HEIGHT = 60;
+const TAB_BAR_HEIGHT = 68;
export default function TabNavigator() {
const insets = useSafeAreaInsets();
const baseTabBarStyle = {
- backgroundColor: 'transparent',
+ backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderTopWidth: 0,
elevation: 0,
shadowOpacity: 0,
@@ -35,6 +36,14 @@ export default function TabNavigator() {
right: 0,
height: TAB_BAR_HEIGHT + insets.bottom,
paddingBottom: insets.bottom,
+ paddingTop: 8,
+ borderTopLeftRadius: 20,
+ borderTopRightRadius: 20,
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.6)',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: -2 },
+ shadowRadius: 8,
};
return (
@@ -58,17 +67,26 @@ export default function TabNavigator() {
}}
/>
(
+
+ ),
+ }}
+ />
+ {
- const routeName = getFocusedRouteNameFromRoute(route) ?? 'RouteMain';
+ const routeName =
+ getFocusedRouteNameFromRoute(route) ?? 'QuickStartMain';
return {
- tabBarIcon: ({ color, size }) => (
-
- ),
+ tabBarIcon: ({ color, size }) => ,
tabBarStyle: {
...baseTabBarStyle,
display:
+ routeName === 'Run' ||
routeName === 'Draw' ||
routeName === 'RouteSave' ||
routeName === 'Detail'
@@ -78,17 +96,6 @@ export default function TabNavigator() {
};
}}
/>
- ,
- tabBarStyle: {
- ...baseTabBarStyle,
- display: 'none',
- },
- }}
- />
>();
+ // 로그인 시 위치 미리 받아오기
+ const { refreshLocation } = useLocationTracking();
+
useEffect(() => {
initializeGoogleSignIn();
}, []);
@@ -30,6 +34,11 @@ export default function Auth() {
const { accessToken, user } = await signInWithGoogle();
setAuth(accessToken, user);
+
+ // 로그인 성공 후 위치 미리 받아오기
+ console.log('📍 로그인 성공! 위치 미리 받아오기 시작...');
+ refreshLocation();
+
// navigation.reset({ index: 0, routes: [{ name: 'TabNavigator' }] });
} catch (error: unknown) {
console.error('로그인 오류:', error);
@@ -54,7 +63,7 @@ export default function Auth() {
} finally {
setIsSubmitting(false);
}
- }, [isSubmitting, setAuth, navigation]);
+ }, [isSubmitting, setAuth, navigation, refreshLocation]);
return (
diff --git a/src/pages/Detail/index.tsx b/src/pages/Detail/index.tsx
index c600712..7913620 100644
--- a/src/pages/Detail/index.tsx
+++ b/src/pages/Detail/index.tsx
@@ -13,6 +13,7 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import { Share, ArrowLeft, Edit3 } from 'lucide-react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { LinearGradient } from 'expo-linear-gradient';
import styled from '@emotion/native';
import Header from '@/components/Header';
@@ -31,10 +32,10 @@ import type {
CompletedCourseItem,
} from '@/types/courses.types';
-type Props = CompositeScreenProps<
- NativeStackScreenProps,
- BottomTabScreenProps
->;
+type Props = {
+ route: any;
+ navigation: any;
+};
export default function Detail({ route, navigation }: Props) {
const { courseId } = route.params;
@@ -61,27 +62,13 @@ export default function Detail({ route, navigation }: Props) {
useRunStore.getState().setCurrentCourse(courseId, courseData);
}
- try {
- const routeStackNavigator = navigation.getParent();
- if (routeStackNavigator && 'jumpTo' in routeStackNavigator) {
- (routeStackNavigator as any).jumpTo('Run');
- return;
- }
-
- const tabNavigator = navigation.getParent()?.getParent();
- if (tabNavigator && 'jumpTo' in tabNavigator) {
- (tabNavigator as any).jumpTo('Run');
- return;
- }
- } catch (error) {
- // 네비게이션 에러 시 무시
- }
+ // RunTabNavigator 내부의 Run 스크린으로 이동
+ navigation.navigate('Run', { courseId });
};
- const { isPressing, pressProgress, animatedValue, startPress, stopPress } =
- useLongPress({
- onComplete: handleDrawPress,
- });
+ const { isPressing, animatedValue, startPress, stopPress } = useLongPress({
+ onComplete: handleDrawPress,
+ });
const { shareCourse } = useShare({ courseId, courseData });
useEffect(() => {
@@ -107,7 +94,7 @@ export default function Detail({ route, navigation }: Props) {
title="경로 상세"
/>
-
+
경로 정보를 불러오는 중...
@@ -177,7 +164,7 @@ export default function Detail({ route, navigation }: Props) {
)}
{imageLoading && imageUrl && !imageError && (
-
+
)}
@@ -261,7 +248,7 @@ export default function Detail({ route, navigation }: Props) {
-
+
+
@@ -324,7 +316,7 @@ const ErrorText = styled.Text({
});
const RetryButton = styled(TouchableOpacity)({
- backgroundColor: '#007AFF',
+ backgroundColor: '#2d2d2d',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
@@ -405,7 +397,6 @@ const ShareButton = styled(TouchableOpacity)({
const DrawButton = styled(TouchableOpacity)({
flex: 1,
height: 48,
- backgroundColor: '#f0f0f0',
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
@@ -413,17 +404,26 @@ const DrawButton = styled(TouchableOpacity)({
overflow: 'hidden',
});
+const DrawButtonGradient = styled(LinearGradient)({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ borderRadius: 8,
+});
+
const DrawButtonProgress = styled(Animated.View)({
position: 'absolute',
top: 0,
left: 0,
height: '100%',
- backgroundColor: '#007AFF',
+ backgroundColor: '#000000',
borderRadius: 8,
});
const DrawButtonText = styled(Text)({
- color: '#000000',
+ color: '#ffffff',
fontSize: 16,
fontWeight: '600',
zIndex: 1,
diff --git a/src/pages/Draw/_components/DrawMap.tsx b/src/pages/Draw/_components/DrawMap.tsx
index 805167a..d21b483 100644
--- a/src/pages/Draw/_components/DrawMap.tsx
+++ b/src/pages/Draw/_components/DrawMap.tsx
@@ -1,4 +1,5 @@
import { View } from 'react-native';
+import { useState, useEffect } from 'react';
import styled from '@emotion/native';
import Mapbox from '@rnmapbox/maps';
import { theme } from '@/styles/theme';
@@ -16,6 +17,22 @@ export default function DrawMap({
}: DrawMapProps) {
const { drawnCoordinates, completedDrawings, matchedRoutes, isCapturing } =
useDrawStore();
+ const [isMapReady, setIsMapReady] = useState(false);
+ const [showDrawingSources, setShowDrawingSources] = useState(true);
+
+ // 캡처 시작 시 그리기 중인 소스들만 안전하게 숨김 (매칭된 경로는 유지)
+ useEffect(() => {
+ if (isCapturing) {
+ // 캡처 시작 시 그리기 중인 소스들만 즉시 숨김
+ setShowDrawingSources(false);
+ } else {
+ // 캡처 완료 후 약간의 지연을 두고 그리기 소스들을 다시 표시
+ const timer = setTimeout(() => {
+ setShowDrawingSources(true);
+ }, 300);
+ return () => clearTimeout(timer);
+ }
+ }, [isCapturing]);
return (
@@ -24,9 +41,11 @@ export default function DrawMap({
cameraRef={cameraRef}
initialLocation={initialLocation}
onUserLocationUpdate={onUserLocationUpdate}
+ onMapReady={() => setIsMapReady(true)}
showUserLocation={!isCapturing}
>
- {!isCapturing &&
+ {isMapReady &&
+ showDrawingSources &&
drawnCoordinates.length > 1 &&
drawnCoordinates.every(
(coord) => Array.isArray(coord) && coord.length === 2,
@@ -45,7 +64,7 @@ export default function DrawMap({
)}
- {!isCapturing &&
+ {isMapReady &&
+ showDrawingSources &&
completedDrawings
.filter(
(drawing) =>
@@ -80,7 +100,7 @@ export default function DrawMap({
))}
- {matchedRoutes
- .filter(
- (route) =>
- route &&
- route.geometry &&
- route.geometry.coordinates &&
- Array.isArray(route.geometry.coordinates) &&
- route.geometry.coordinates.every(
- (coord) => Array.isArray(coord) && coord.length === 2,
- ),
- )
- .map((route, index) => (
-
-
-
- ))}
+ {isMapReady &&
+ matchedRoutes
+ .filter(
+ (route) =>
+ route &&
+ route.geometry &&
+ route.geometry.coordinates &&
+ Array.isArray(route.geometry.coordinates) &&
+ route.geometry.coordinates.every(
+ (coord) => Array.isArray(coord) && coord.length === 2,
+ ),
+ )
+ .map((route, index) => (
+
+
+
+ ))}
{!isCapturing && (
diff --git a/src/pages/Draw/index.tsx b/src/pages/Draw/index.tsx
index a090bd0..f8d1918 100644
--- a/src/pages/Draw/index.tsx
+++ b/src/pages/Draw/index.tsx
@@ -26,7 +26,9 @@ import {
showCourseSaveError,
} from './_components/Toasts';
-const LoadingIndicator = () => ;
+const LoadingIndicator = ({ message = '로딩 중...' }: { message?: string }) => (
+
+);
export default function Draw() {
const navigation =
@@ -44,6 +46,7 @@ export default function Draw() {
locationLoading,
flyToCurrentUserLocation,
handleUserLocationUpdate,
+ refreshLocation,
} = useLocationManager();
const { composedGesture } = useMapGestures(mapRef);
const { processImage } = useMapCapture(mapRef, cameraRef);
@@ -51,7 +54,19 @@ export default function Draw() {
useFocusEffect(
useCallback(() => {
- clearAll();
+ // 위치가 없으면 강제로 위치 요청
+ if (!initialLocation && !locationLoading) {
+ refreshLocation();
+ }
+ }, [initialLocation, locationLoading, refreshLocation]),
+ );
+
+ // Draw 페이지 이탈 시 그린 선들 초기화
+ useFocusEffect(
+ useCallback(() => {
+ return () => {
+ clearAll();
+ };
}, [clearAll]),
);
@@ -123,7 +138,7 @@ export default function Draw() {
/>
- {isLoading && }
+ {isLoading && }
);
diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx
index 64c6aa0..ca53248 100644
--- a/src/pages/Home/index.tsx
+++ b/src/pages/Home/index.tsx
@@ -3,7 +3,6 @@ import { LinearGradient } from 'expo-linear-gradient';
import { Bell } from 'lucide-react-native';
import Header from '@/components/Header';
import FloatingImageContainer from './_components/FloatingImageContainer';
-import CardContainer from './_components/CardContainer';
export default function Home() {
const handleLocationPress = () => {
@@ -14,14 +13,10 @@ export default function Home() {
console.log('알림 버튼 클릭');
};
- const handleRecommendationPress = (item: RecommendationData) => {
- console.log('추천 경로 클릭:', item.title);
- };
-
return (
@@ -37,7 +32,7 @@ export default function Home() {
-
+ {/* 추천 경로는 바로가기 화면으로 이동됨 */}
@@ -45,8 +40,6 @@ export default function Home() {
);
}
-import type { RecommendationData } from '@/types/card.types';
-
const Screen = styled.View({
flex: 1,
});
diff --git a/src/pages/Records/_components/RecordsList.tsx b/src/pages/Records/_components/RecordsList.tsx
new file mode 100644
index 0000000..083f6b5
--- /dev/null
+++ b/src/pages/Records/_components/RecordsList.tsx
@@ -0,0 +1,160 @@
+import styled from '@emotion/native';
+import {
+ FlatList,
+ ActivityIndicator,
+ TouchableOpacity,
+ RefreshControl,
+} from 'react-native';
+import { useCallback } from 'react';
+import type { RunningRecord } from '@/types/records.types';
+
+interface RecordsListProps {
+ records: RunningRecord[];
+ loading: boolean;
+ error: string | null;
+ hasMore: boolean;
+ onLoadMore: () => void;
+ onRefresh: () => void;
+ onRecordPress?: (record: RunningRecord) => void;
+}
+
+export default function RecordsList({
+ records,
+ loading,
+ error,
+ hasMore,
+ onLoadMore,
+ onRefresh,
+ onRecordPress,
+}: RecordsListProps) {
+ const renderRecord = useCallback(
+ ({ item }: { item: RunningRecord }) => (
+ onRecordPress?.(item)}>
+
+
+ ),
+ [onRecordPress],
+ );
+
+ const keyExtractor = useCallback(
+ (item: RunningRecord, index: number) => `${item.id}-${index}`,
+ [],
+ );
+
+ const handleEndReached = useCallback(() => {
+ console.log('📊 [RecordsList] handleEndReached 호출:', {
+ hasMore,
+ loading,
+ });
+ if (hasMore && !loading) {
+ onLoadMore();
+ }
+ }, [hasMore, loading, onLoadMore]);
+
+ const renderFooter = useCallback(() => {
+ if (!loading || records.length === 0) return null;
+
+ return (
+
+
+
+ );
+ }, [loading, records.length]);
+
+ const renderEmpty = useCallback(() => {
+ if (loading) return null;
+
+ return (
+
+ 런닝 기록이 없습니다
+ 첫 번째 런닝을 시작해보세요!
+
+ );
+ }, [loading]);
+
+ return (
+
+ 런닝 기록
+ }
+ onEndReached={handleEndReached}
+ onEndReachedThreshold={0.5}
+ refreshControl={
+
+ }
+ ListFooterComponent={renderFooter}
+ ListEmptyComponent={renderEmpty}
+ />
+
+ );
+}
+
+const Container = styled.View({
+ marginTop: 20,
+});
+
+const SectionTitle = styled.Text({
+ fontSize: 18,
+ fontWeight: '600',
+ color: '#2d2d2d',
+ paddingHorizontal: 16,
+ marginBottom: 12,
+});
+
+const RecordItem = styled(TouchableOpacity)({
+ width: 360, // 120 * 3 = 360
+ height: 360, // 120 * 3 = 360
+ borderRadius: 12,
+ overflow: 'hidden',
+ marginBottom: 120,
+});
+
+const RecordImage = styled.Image({
+ width: '100%',
+ height: '100%',
+ resizeMode: 'cover',
+});
+
+const Separator = styled.View({
+ width: 12,
+});
+
+const LoadingContainer = styled.View({
+ padding: 20,
+ alignItems: 'center',
+});
+
+const EmptyContainer = styled.View({
+ padding: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '100%',
+ minHeight: 200,
+});
+
+const EmptyText = styled.Text({
+ fontSize: 16,
+ fontWeight: '500',
+ color: '#666666',
+ marginBottom: 8,
+});
+
+const EmptySubText = styled.Text({
+ fontSize: 14,
+ color: '#999999',
+});
diff --git a/src/pages/Records/_components/StatsDisplay.tsx b/src/pages/Records/_components/StatsDisplay.tsx
new file mode 100644
index 0000000..ebc42b8
--- /dev/null
+++ b/src/pages/Records/_components/StatsDisplay.tsx
@@ -0,0 +1,94 @@
+import styled from '@emotion/native';
+import { formatDistance, formatTime, formatPace } from '@/utils/formatters';
+import type { RunningDashboard } from '@/types/records.types';
+
+interface StatsDisplayProps {
+ dashboard: RunningDashboard;
+}
+
+export default function StatsDisplay({ dashboard }: StatsDisplayProps) {
+ return (
+
+
+
+
+ {formatDistance(dashboard.totalDistance)}
+
+
+
+
+
+
+ {dashboard.nRecords}회
+ 총 횟수
+
+
+
+ {formatTime(dashboard.totalDuration)}
+ 총 시간
+
+
+
+
+ {Math.round(dashboard.totalCalories * 100) / 100}kcal
+
+ 총 칼로리
+
+
+
+ {formatPace(dashboard.meanPace)}
+ 평균 페이스
+
+
+
+ );
+}
+
+const Container = styled.View({
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+});
+
+const MainStatsContainer = styled.View({
+ marginBottom: 20,
+});
+
+const TotalDistanceContainer = styled.View({
+ flexDirection: 'row',
+ alignItems: 'baseline',
+ gap: 8,
+});
+
+const TotalDistanceText = styled.Text({
+ fontSize: 32,
+ fontWeight: '700',
+ color: '#2d2d2d',
+});
+
+const StatsGrid = styled.View({
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 12,
+});
+
+const StatBox = styled.View({
+ flex: 1,
+ minWidth: '45%',
+ backgroundColor: '#f8f9fa',
+ padding: 16,
+ borderRadius: 12,
+ alignItems: 'center',
+});
+
+const StatValue = styled.Text({
+ fontSize: 18,
+ fontWeight: '600',
+ color: '#2d2d2d',
+ marginBottom: 4,
+});
+
+const StatLabel = styled.Text({
+ fontSize: 12,
+ fontWeight: '400',
+ color: '#666666',
+});
diff --git a/src/pages/Records/_components/TimeRangeSelector.tsx b/src/pages/Records/_components/TimeRangeSelector.tsx
new file mode 100644
index 0000000..b2e563f
--- /dev/null
+++ b/src/pages/Records/_components/TimeRangeSelector.tsx
@@ -0,0 +1,197 @@
+import styled from '@emotion/native';
+import { ChevronDown } from 'lucide-react-native';
+import { useState } from 'react';
+import type { TimeRange } from '@/types/records.types';
+import { theme } from '@/styles/theme';
+
+interface TimeRangeSelectorProps {
+ activeRange: TimeRange;
+ onRangeChange: (range: TimeRange) => void;
+ displayText: string;
+ onDropdownPress: () => void;
+ showDropdown: boolean;
+ onDetailSelection: (value: number) => void;
+ selectedWeek: number;
+ selectedMonth: number;
+ selectedYear: number;
+}
+
+const timeRangeTabs: { id: TimeRange; title: string }[] = [
+ { id: 'week', title: '주' },
+ { id: 'month', title: '월' },
+ { id: 'year', title: '년' },
+ { id: 'all', title: '전체' },
+];
+
+export default function TimeRangeSelector({
+ activeRange,
+ onRangeChange,
+ displayText,
+ onDropdownPress,
+ showDropdown,
+ onDetailSelection,
+ selectedWeek,
+ selectedMonth,
+ selectedYear,
+}: TimeRangeSelectorProps) {
+ return (
+
+
+ {timeRangeTabs.map((tab) => (
+ onRangeChange(tab.id)}
+ >
+ {tab.title}
+
+ ))}
+
+
+
+ {displayText}
+
+
+
+ {showDropdown && (
+
+ {activeRange === 'week' && (
+ <>
+ {Array.from({ length: 4 }, (_, i) => i + 1).map((week) => (
+ onDetailSelection(week)}
+ >
+
+ {week}째 주
+
+
+ ))}
+ >
+ )}
+ {activeRange === 'month' && (
+ <>
+ {Array.from({ length: 12 }, (_, i) => i + 1).map((month) => (
+ onDetailSelection(month)}
+ >
+
+ {month}월
+
+
+ ))}
+ >
+ )}
+ {activeRange === 'year' && (
+ <>
+ {Array.from(
+ { length: 5 },
+ (_, i) => new Date().getFullYear() - 2 + i,
+ ).map((year) => (
+ onDetailSelection(year)}
+ >
+
+ {year}년
+
+
+ ))}
+ >
+ )}
+ {activeRange === 'all' && (
+ onRangeChange('all')}>
+ 전체
+
+ )}
+
+ )}
+
+ );
+}
+
+const Container = styled.View({
+ position: 'relative',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+});
+
+const TabContainer = styled.View({
+ flexDirection: 'row',
+ backgroundColor: 'transparent',
+ borderWidth: 1,
+ borderColor: '#e5e7eb',
+ borderRadius: 8,
+ padding: 4,
+ marginBottom: 12,
+});
+
+const TabButton = styled.TouchableOpacity<{
+ isActive: boolean;
+}>(({ isActive }) => ({
+ flex: 1,
+ alignItems: 'center',
+ paddingVertical: 8,
+ borderRadius: 6,
+ backgroundColor: isActive ? `${theme.colors.gray[900]}` : 'transparent',
+}));
+
+const TabText = styled.Text<{
+ isActive: boolean;
+}>(({ isActive }) => ({
+ fontSize: 14,
+ fontWeight: isActive ? '600' : '400',
+ color: isActive ? '#ffffff' : '#666666',
+}));
+
+const PeriodContainer = styled.TouchableOpacity({
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingVertical: 8,
+});
+
+const PeriodText = styled.Text({
+ fontSize: 16,
+ fontWeight: '500',
+ color: '#2d2d2d',
+});
+
+const DropdownContainer = styled.View({
+ position: 'absolute',
+ top: '100%',
+ left: 16,
+ right: 16,
+ backgroundColor: '#ffffff',
+ borderRadius: 8,
+ marginTop: 8,
+ shadowColor: '#000',
+ shadowOffset: {
+ width: 0,
+ height: 2,
+ },
+ shadowOpacity: 0.1,
+ shadowRadius: 4,
+ elevation: 10,
+ zIndex: 1000,
+});
+
+const DropdownItem = styled.TouchableOpacity({
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderBottomWidth: 1,
+ borderBottomColor: '#f0f0f0',
+});
+
+const DropdownText = styled.Text<{
+ isActive: boolean;
+}>(({ isActive }) => ({
+ fontSize: 14,
+ fontWeight: isActive ? '600' : '400',
+ color: isActive ? '#2d2d2d' : '#666666',
+}));
diff --git a/src/pages/Records/index.tsx b/src/pages/Records/index.tsx
new file mode 100644
index 0000000..e9bae3d
--- /dev/null
+++ b/src/pages/Records/index.tsx
@@ -0,0 +1,165 @@
+import styled from '@emotion/native';
+import { FileText } from 'lucide-react-native';
+import { useState, useEffect, useCallback } from 'react';
+import { ScrollView } from 'react-native';
+import Header from '@/components/Header';
+import TimeRangeSelector from './_components/TimeRangeSelector';
+import StatsDisplay from './_components/StatsDisplay';
+import RecordsList from './_components/RecordsList';
+import { LoadingOverlay, ErrorOverlay } from '@/components/Overlay';
+import {
+ useRunningRecords,
+ useRunningDashboard,
+ getTimeRangeParams,
+ getTimeRangeDisplayText,
+} from '@/hooks/api/useRecordsApi';
+import type { TimeRange } from '@/types/records.types';
+
+export default function Records() {
+ const [activeRange, setActiveRange] = useState('month');
+ const [showDropdown, setShowDropdown] = useState(false);
+ const [selectedWeek, setSelectedWeek] = useState(1);
+ const [selectedMonth, setSelectedMonth] = useState(
+ new Date().getMonth() + 1,
+ );
+ const [selectedYear, setSelectedYear] = useState(
+ new Date().getFullYear(),
+ );
+
+ const timeRangeParams = getTimeRangeParams(
+ activeRange,
+ selectedWeek,
+ selectedMonth,
+ selectedYear,
+ );
+ const displayText = getTimeRangeDisplayText(
+ activeRange,
+ selectedWeek,
+ selectedMonth,
+ selectedYear,
+ );
+
+ const {
+ records,
+ loading: recordsLoading,
+ error: recordsError,
+ hasMore,
+ loadRecords,
+ handleLoadMore,
+ handleRefresh,
+ } = useRunningRecords();
+
+ const {
+ dashboard,
+ loading: dashboardLoading,
+ error: dashboardError,
+ loadDashboard,
+ } = useRunningDashboard();
+
+ // 초기 데이터 로드
+ useEffect(() => {
+ console.log('📊 [Records] useEffect 실행:', {
+ activeRange,
+ selectedWeek,
+ selectedMonth,
+ selectedYear,
+ timeRangeParams,
+ });
+ loadDashboard(timeRangeParams);
+ loadRecords(timeRangeParams, true);
+ }, [activeRange, selectedWeek, selectedMonth, selectedYear]);
+
+ // 기간 변경 시 데이터 새로고침
+ const handleRangeChange = useCallback((range: TimeRange) => {
+ setActiveRange(range);
+ setShowDropdown(false);
+ }, []);
+
+ const handleDropdownPress = useCallback(() => {
+ setShowDropdown(!showDropdown);
+ }, [showDropdown]);
+
+ const handleRecordPress = useCallback((record: any) => {
+ // TODO: 기록 상세 페이지로 이동
+ console.log('Record pressed:', record);
+ }, []);
+
+ const handleDetailSelection = useCallback(
+ (value: number) => {
+ switch (activeRange) {
+ case 'week':
+ setSelectedWeek(value);
+ break;
+ case 'month':
+ setSelectedMonth(value);
+ break;
+ case 'year':
+ setSelectedYear(value);
+ break;
+ }
+ setShowDropdown(false);
+ },
+ [activeRange],
+ );
+
+ const isLoading = recordsLoading || dashboardLoading;
+ const hasError = recordsError || dashboardError;
+
+ // 초기 로딩 상태 (데이터가 없고 로딩 중일 때만)
+ const isInitialLoading = isLoading && !dashboard && records.length === 0;
+
+ // 새로고침 상태 (데이터가 있을 때만)
+ const isRefreshing = isLoading && (dashboard || records.length > 0);
+
+ return (
+
+
+
+
+
+
+ {dashboard && }
+
+
+
+
+ {isInitialLoading && }
+ {hasError && (
+ {
+ loadDashboard(timeRangeParams);
+ loadRecords(timeRangeParams, true);
+ }}
+ />
+ )}
+
+ );
+}
+
+const Screen = styled.View({
+ flex: 1,
+ backgroundColor: '#ffffff',
+});
diff --git a/src/pages/Route/_components/RouteGrid.tsx b/src/pages/Route/_components/RouteGrid.tsx
index 5badc32..81e838b 100644
--- a/src/pages/Route/_components/RouteGrid.tsx
+++ b/src/pages/Route/_components/RouteGrid.tsx
@@ -5,6 +5,7 @@ import {
Text,
TouchableOpacity,
RefreshControl,
+ View,
} from 'react-native';
import { useCallback } from 'react';
import Card from '@/components/Card';
@@ -90,36 +91,42 @@ export default function RouteGrid({ onRouteCardPress }: RouteGridProps) {
if (activeTab === 'completed') {
const completedItem = item as CompletedCourseItem;
return (
- onRouteCardPress(completedItem.id)}
- />
+
+ onRouteCardPress(completedItem.id)}
+ />
+
);
} else if (activeTab === 'liked') {
const bookmarkedItem = item as BookmarkedCourseItem;
return (
- onRouteCardPress(bookmarkedItem.id)}
- />
+
+ onRouteCardPress(bookmarkedItem.id)}
+ />
+
);
} else {
const courseItem = item as CourseSearchItem;
return (
- onRouteCardPress(courseItem.id)}
- />
+
+ onRouteCardPress(courseItem.id)}
+ />
+
);
}
},
@@ -192,15 +199,15 @@ export default function RouteGrid({ onRouteCardPress }: RouteGridProps) {
}
ListFooterComponent={() => (
<>
{loading && (
-
+
)}
{error && currentData.length > 0 && (
@@ -250,7 +257,7 @@ const ErrorText = styled.Text({
});
const RetryButton = styled(TouchableOpacity)({
- backgroundColor: '#007AFF',
+ backgroundColor: '#2d2d2d',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
@@ -261,3 +268,8 @@ const RetryButtonText = styled.Text({
fontSize: 14,
fontWeight: '600',
});
+
+const CardContainer = styled.View({
+ flex: 1,
+ marginBottom: 16,
+});
diff --git a/src/pages/Route/index.tsx b/src/pages/Route/index.tsx
index 108295c..8640066 100644
--- a/src/pages/Route/index.tsx
+++ b/src/pages/Route/index.tsx
@@ -10,6 +10,8 @@ import FloatingButton from '@/components/FloatingButton';
import RouteGrid from './_components/RouteGrid';
import type { RouteTabId, TabParamList } from '@/types/navigation.types';
import type { RouteStackParamList } from '@/navigation/RouteStackNavigator';
+import { useNavigation } from '@react-navigation/native';
+import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type {
BookmarkedCourseItem,
CourseSearchItem,
@@ -20,11 +22,12 @@ import {
useBookmarkedCourses,
useCompletedCourses,
} from '@/hooks/api/useRouteApi';
+import { theme } from '@/styles/theme';
-type Props = CompositeScreenProps<
- NativeStackScreenProps,
- BottomTabScreenProps
->;
+type Props = {
+ navigation: any;
+ onStartRun?: (courseId: number) => void;
+};
const tabs: Array<{ id: RouteTabId; title: string }> = [
{ id: 'created', title: '생성한 경로' },
@@ -88,18 +91,17 @@ export default function Route({ navigation }: Props) {
return (
-
tabs={tabs}
activeTab={activeTab}
onTabPress={handleTabPress}
/>
-
+
);
}
@@ -108,3 +110,14 @@ const Screen = styled.View({
flex: 1,
backgroundColor: '#ffffff',
});
+
+const styles = {
+ defaultButton: {
+ backgroundColor: '#000000',
+ },
+ activeButton: {
+ backgroundColor: theme.colors.secondary[500],
+ elevation: 12,
+ shadowOpacity: 0.5,
+ },
+};
diff --git a/src/pages/RouteSave/index.tsx b/src/pages/RouteSave/index.tsx
index 67cabd9..52754be 100644
--- a/src/pages/RouteSave/index.tsx
+++ b/src/pages/RouteSave/index.tsx
@@ -112,10 +112,9 @@ export default function RouteSave() {
onPress: () => {
clearAll();
clearRouteData();
- navigation.reset({
- index: 0,
- routes: [{ name: 'RouteMain' }],
- });
+ // RouteStackNavigator 내에서 RouteMain으로 돌아가기
+ navigation.goBack(); // Draw로 돌아가기
+ navigation.goBack(); // RouteMain으로 돌아가기
},
},
]);
diff --git a/src/pages/Run/_components/ControlContainer.tsx b/src/pages/Run/_components/ControlContainer.tsx
index 63a3e78..6080218 100644
--- a/src/pages/Run/_components/ControlContainer.tsx
+++ b/src/pages/Run/_components/ControlContainer.tsx
@@ -44,14 +44,20 @@ const ControlContainer: React.FC = ({
const handleToggleTracking = () => {
if (!isTracking && !startTime) {
+ // 새로운 런닝 시작
+ console.log('🎬 [ControlContainer] 새로운 런닝 시작');
startRun();
const initialStats = calculateRunStats([], new Date(), true, 0, null);
setRunning({ stats: initialStats });
toggleTracking();
} else if (!isTracking && startTime) {
+ // 일시정지된 런닝 재시작 - 기존 경로와 통계 유지
+ console.log('▶️ [ControlContainer] 일시정지된 런닝 재시작');
resumeRun();
toggleTracking();
} else if (isTracking) {
+ // 런닝 일시정지 - 경로와 통계 유지
+ console.log('⏸️ [ControlContainer] 런닝 일시정지');
pauseRun();
toggleTracking();
}
@@ -133,7 +139,7 @@ const ControlButton = styled.TouchableOpacity<{
width: 48,
height: 48,
borderRadius: 24,
- backgroundColor: isPrimary ? theme.colors.primary[500] : 'transparent',
+ backgroundColor: isPrimary ? '#2d2d2d' : 'transparent',
justifyContent: 'center',
alignItems: 'center',
marginHorizontal: 4,
diff --git a/src/pages/Run/_components/RecommendationContainer.tsx b/src/pages/Run/_components/RecommendationContainer.tsx
new file mode 100644
index 0000000..a2129fb
--- /dev/null
+++ b/src/pages/Run/_components/RecommendationContainer.tsx
@@ -0,0 +1,143 @@
+import styled from '@emotion/native';
+import { FlatList, Dimensions } from 'react-native';
+import { useState, useEffect } from 'react';
+import Card from '@/components/Card';
+import type { CourseSearchItem } from '@/types/courses.types';
+import { reverseGeocode } from '@/lib/geocoding';
+
+const { width: screenWidth } = Dimensions.get('window');
+
+interface RecommendationContainerProps {
+ onRecommendationPress: (item: CourseSearchItem) => void;
+ recommendations: CourseSearchItem[];
+}
+
+export default function RecommendationContainer({
+ onRecommendationPress,
+ recommendations,
+}: RecommendationContainerProps) {
+ const [geocodedAddresses, setGeocodedAddresses] = useState<
+ Record
+ >({});
+
+ // 추천 경로가 변경될 때마다 지오코딩 수행
+ useEffect(() => {
+ const geocodeRecommendations = async () => {
+ const newAddresses: Record = {};
+
+ for (const item of recommendations) {
+ if (!geocodedAddresses[item.id]) {
+ try {
+ const result = await reverseGeocode([
+ item.departure[0],
+ item.departure[1],
+ ]);
+ newAddresses[item.id] = result.placeName;
+ } catch (error) {
+ console.error('지오코딩 실패:', error);
+ newAddresses[item.id] = '알 수 없는 위치';
+ }
+ }
+ }
+
+ if (Object.keys(newAddresses).length > 0) {
+ setGeocodedAddresses((prev) => ({ ...prev, ...newAddresses }));
+ }
+ };
+
+ if (recommendations.length > 0) {
+ geocodeRecommendations();
+ }
+ }, [recommendations, geocodedAddresses]);
+
+ // 안내 카드 데이터
+ const guideCard = {
+ id: 'guide',
+ title: '🏃♂️ 런닝을 시작해보세요!',
+ subtitle:
+ '바로 달리기 또는 추천 경로를 선택하여 런닝을 시작할 수 있습니다.',
+ };
+
+ // 데이터 배열 생성 (안내 카드 + 추천 경로)
+ const data = [guideCard, ...recommendations];
+
+ return (
+
+ {
+ // 첫 번째 카드는 안내 문구
+ if (index === 0) {
+ const guideItem = item as typeof guideCard;
+ return (
+
+ {guideItem.title}
+ {guideItem.subtitle}
+
+ );
+ }
+
+ // 추천 경로 카드
+ const courseItem = item as CourseSearchItem;
+ const address = geocodedAddresses[courseItem.id] || '로딩 중...';
+ return (
+ onRecommendationPress(courseItem)}
+ fullWidth={true}
+ style={{ width: screenWidth - 32, marginRight: 32 }}
+ />
+ );
+ }}
+ keyExtractor={(item, index) =>
+ index === 0 ? 'guide' : item.id.toString()
+ }
+ horizontal
+ showsHorizontalScrollIndicator={false}
+ pagingEnabled={true}
+ snapToInterval={screenWidth}
+ decelerationRate="fast"
+ />
+
+ );
+}
+
+const RecommendationOverlay = styled.View({
+ position: 'absolute',
+ top: 20,
+ left: 0,
+ right: 0,
+ zIndex: 1000,
+ paddingHorizontal: 16,
+});
+
+const GuideCard = styled.View({
+ backgroundColor: 'rgba(255, 255, 255, 0.95)',
+ borderRadius: 16,
+ padding: 24,
+ alignItems: 'center',
+ justifyContent: 'center',
+ minHeight: 100,
+});
+
+const GuideTitle = styled.Text({
+ fontSize: 20,
+ fontWeight: '700',
+ color: '#2d2d2d',
+ textAlign: 'center',
+ marginBottom: 12,
+});
+
+const GuideSubtitle = styled.Text({
+ fontSize: 14,
+ fontWeight: '400',
+ color: '#666666',
+ textAlign: 'center',
+ lineHeight: 20,
+});
diff --git a/src/pages/Run/_components/RunMap.tsx b/src/pages/Run/_components/RunMap.tsx
index aff08d2..af40973 100644
--- a/src/pages/Run/_components/RunMap.tsx
+++ b/src/pages/Run/_components/RunMap.tsx
@@ -54,7 +54,7 @@ function RunMap({
+
달린 시간
{localStats.runningTime}
@@ -149,7 +155,6 @@ const StatLabel = styled.Text(({ theme }) => ({
}));
const RunningTimeContainer = styled.View(({ theme }) => ({
- backgroundColor: theme.colors.primary[500],
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 20,
@@ -157,18 +162,31 @@ const RunningTimeContainer = styled.View(({ theme }) => ({
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
+ position: 'relative',
+ overflow: 'hidden',
}));
+const RunningTimeGradient = styled(LinearGradient)({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ borderRadius: 12,
+});
+
const RunningTimeLabel = styled.Text({
fontSize: 16,
fontWeight: '600',
color: '#ffffff',
+ zIndex: 1,
});
const RunningTimeValue = styled.Text({
fontSize: 24,
fontWeight: '700',
color: '#ffffff',
+ zIndex: 1,
});
export default React.memo(StatsContainer);
diff --git a/src/pages/Run/index.tsx b/src/pages/Run/index.tsx
index 0ac37c4..2059a54 100644
--- a/src/pages/Run/index.tsx
+++ b/src/pages/Run/index.tsx
@@ -18,7 +18,7 @@ import ControlContainer from './_components/ControlContainer';
import Modal from '@/components/Modal';
import Mapbox from '@rnmapbox/maps';
-type Props = NativeStackScreenProps;
+type Props = NativeStackScreenProps;
export default function Run({ route, navigation }: Props) {
// 전역 Store에서 현재 코스 데이터 가져오기
@@ -79,12 +79,21 @@ export default function Run({ route, navigation }: Props) {
handleCancelExit,
handleRetryExit,
handleConfirmExit,
- } = useRunModals({ navigation, mapRef, cameraRef, courseId });
+ } = useRunModals({
+ navigation: navigation as any,
+ mapRef,
+ cameraRef,
+ courseId,
+ });
useFocusEffect(
useCallback(() => {
- resetLocationTracking();
- resetRunState();
+ // 런닝이 시작되지 않은 상태에서만 초기화
+ const { startTime } = useRunStore.getState();
+ if (!startTime) {
+ resetLocationTracking();
+ resetRunState();
+ }
// courseId가 있을 때만 경로 데이터를 로드
if (courseId) {
loadCourseTopology();
@@ -192,7 +201,7 @@ export default function Run({ route, navigation }: Props) {
위치를 가져올 수 없습니다
- 새로고침
+ 새로고침
)}
@@ -272,8 +281,8 @@ const DeviationAlert = styled.View<{ severity: 'low' | 'medium' | 'high' }>(
severity === 'high'
? '#ef4444'
: severity === 'medium'
- ? '#f59e0b'
- : '#3b82f6',
+ ? '#f87171'
+ : '#fca5a5',
paddingHorizontal: 20,
paddingVertical: 16,
borderRadius: 12,
@@ -309,5 +318,5 @@ const RefreshButton = styled.TouchableOpacity({
padding: 12,
borderRadius: 8,
borderWidth: 1,
- borderColor: '#007AFF',
+ borderColor: '#2d2d2d',
});
diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts
index 2033a6a..df5e879 100644
--- a/src/services/auth.service.ts
+++ b/src/services/auth.service.ts
@@ -16,74 +16,206 @@ export interface RefreshTokenResponse {
}
export function initializeGoogleSignIn(): void {
- GoogleSignin.configure({
- webClientId: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID,
- iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID,
- profileImageSize: 120,
- });
+ try {
+ console.log('🔧 Google Sign-In initialization started');
+
+ const webClientId = process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID;
+ const iosClientId = process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID;
+
+ // CRITICAL: Always log the Web Client ID
+ console.error('EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID:', webClientId);
+ console.error('EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID:', iosClientId);
+
+ console.log('Web Client ID status:', webClientId ? 'SET' : '❌ NOT SET');
+ console.log('iOS Client ID status:', iosClientId ? 'SET' : '❌ NOT SET');
+
+ if (webClientId) {
+ console.log('Web Client ID length:', webClientId.length);
+ console.log(
+ 'Web Client ID first 20 chars:',
+ webClientId.substring(0, 20) + '...',
+ );
+ }
+
+ if (iosClientId) {
+ console.log('iOS Client ID length:', iosClientId.length);
+ console.log(
+ 'iOS Client ID first 20 chars:',
+ iosClientId.substring(0, 20) + '...',
+ );
+ }
+
+ if (!webClientId) {
+ console.error('❌ Web Client ID is NOT SET!');
+ console.error(
+ 'Check EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID environment variable',
+ );
+ }
+
+ if (!iosClientId) {
+ console.error('❌ iOS Client ID is NOT SET!');
+ console.error(
+ 'Check EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID environment variable',
+ );
+ }
+
+ const config = {
+ webClientId,
+ iosClientId,
+ profileImageSize: 120,
+ };
+
+ console.log('Google Sign-In config:', config);
+ GoogleSignin.configure(config);
+
+ console.log('✅ Google Sign-In initialization completed');
+ } catch (error) {
+ console.error('❌ Google Sign-In initialization error:', error);
+ throw error;
+ }
}
export async function signInWithGoogle(): Promise {
try {
+ console.log('Google login started');
+
+ console.log('Checking Play Services...');
await GoogleSignin.hasPlayServices();
- await GoogleSignin.signIn();
- const { idToken } = await GoogleSignin.getTokens();
+ console.log('Play Services check completed');
+
+ console.log('Attempting Google login...');
+ const signInResult = await GoogleSignin.signIn();
+ console.log('Google login successful:', signInResult);
+
+ console.log('Getting tokens...');
+ const tokens = await GoogleSignin.getTokens();
+ console.log('Full token response:', tokens);
+
+ const { idToken, accessToken } = tokens;
+ console.log('ID Token status:', idToken ? 'ISSUED' : 'NOT ISSUED');
+ console.log('Access Token status:', accessToken ? 'ISSUED' : 'NOT ISSUED');
+
+ if (idToken) {
+ console.log('ID Token length:', idToken.length);
+ console.log('ID Token first 20 chars:', idToken.substring(0, 20) + '...');
+ }
if (!idToken) {
- throw new Error('구글 로그인 중 ID 토큰을 받지 못했습니다.');
+ console.error('❌ ID Token was NOT issued');
+ console.error('Possible causes:');
+ console.error('1. Google Console OAuth client ID configuration error');
+ console.error('2. Production bundle ID not registered in Google Console');
+ console.error('3. Redirect URI configuration error');
+ console.error('4. Wrong client ID in .env file');
+ console.error('5. OAuth consent screen not published to production');
+ throw new Error('Failed to receive ID token during Google login.');
}
- const { data } = await api.post('/auth/google', {
- idToken,
+ console.log('Sending login request to server...');
+ console.log('Request URL:', '/auth/google');
+ console.log('Request payload:', {
+ idToken: idToken.substring(0, 20) + '...',
});
+
+ let data: GoogleSignInResponse;
+ try {
+ const response = await api.post('/auth/google', {
+ idToken,
+ });
+ data = response.data;
+ console.log('✅ Server login successful:', data);
+ } catch (serverError: any) {
+ console.error('❌ Server login failed:', serverError);
+ console.error('Server response status:', serverError.response?.status);
+ console.error('Server response data:', serverError.response?.data);
+ console.error('Server response headers:', serverError.response?.headers);
+
+ if (serverError.response?.status === 400) {
+ console.error('400 Error - Possible causes:');
+ console.error('1. Invalid ID token format');
+ console.error('2. Google Console OAuth configuration error');
+ console.error('3. Client ID mismatch between server and app');
+ console.error('4. Wrong OAuth consent screen configuration');
+ } else if (serverError.response?.status === 401) {
+ console.error('401 Error - Possible causes:');
+ console.error('1. ID token expired');
+ console.error('2. Google token verification failed on server');
+ console.error('3. Server Google OAuth configuration error');
+ console.error('4. Invalid client ID or secret on server');
+ } else if (serverError.response?.status === 500) {
+ console.error('500 Error - Server internal error');
+ console.error('Check server logs for details');
+ }
+
+ throw serverError;
+ }
+
return data;
} catch (error) {
+ console.error('Google login overall error:', error);
+
if (isErrorWithCode(error)) {
+ console.error('Google login code error:', error.code);
switch (error.code) {
case statusCodes.SIGN_IN_CANCELLED:
- throw new Error('사용자가 로그인을 취소했습니다.');
+ console.error('User cancelled login');
+ throw new Error('User cancelled login.');
case statusCodes.IN_PROGRESS:
- throw new Error('로그인이 진행 중입니다.');
+ console.error('Login in progress');
+ throw new Error('Login in progress.');
case statusCodes.PLAY_SERVICES_NOT_AVAILABLE:
+ console.error('Play Services not available');
throw new Error(
- '구글 플레이 서비스를 사용할 수 없거나 버전이 오래되었습니다.',
+ 'Google Play Services is not available or version is outdated.',
);
default:
- throw new Error('구글 로그인 중 오류가 발생했습니다.');
+ console.error('Unknown Google login error:', error.code);
+ throw new Error('Error occurred during Google login.');
}
}
- throw new Error('로그인 중 오류가 발생했습니다. 다시 시도해주세요.');
+
+ console.error('General login error:', error);
+ throw new Error('Error occurred during login. Please try again.');
}
}
export async function refreshToken(): Promise {
try {
+ console.log('Token refresh started');
const { data } = await api.post('/auth/refresh');
+ console.log('Token refresh response:', data);
if (!data.accessToken) {
- throw new Error('토큰 갱신에 실패했습니다.');
+ console.error('Token refresh failed: accessToken is missing');
+ throw new Error('Token refresh failed.');
}
+ console.log('Token refresh successful');
return data;
} catch (error) {
- throw new Error('토큰 갱신 중 오류가 발생했습니다.');
+ console.error('Token refresh error:', error);
+ throw new Error('Error occurred during token refresh.');
}
}
export async function signOut(): Promise {
try {
+ console.log('Google logout started');
await GoogleSignin.signOut();
+ console.log('Google logout successful');
} catch (error) {
- console.error('Google 로그아웃 오류:', error);
+ console.error('Google logout error:', error);
}
}
export async function isSignedIn(): Promise {
try {
+ console.log('Checking login status...');
const userInfo = await GoogleSignin.getCurrentUser();
+ console.log('Current user info:', userInfo ? 'LOGGED IN' : 'NOT LOGGED IN');
return userInfo !== null;
} catch (error) {
- console.error('로그인 상태 확인 오류:', error);
+ console.error('Login status check error:', error);
return false;
}
}
diff --git a/src/services/courses.service.ts b/src/services/courses.service.ts
index cd57502..6273733 100644
--- a/src/services/courses.service.ts
+++ b/src/services/courses.service.ts
@@ -79,3 +79,21 @@ export async function searchCompletedCourses(
});
return response.data;
}
+
+export async function searchAdjacentCourses(
+ params: {
+ location: string; // "127.0,37.5" 형식
+ radius: number;
+ limit?: number;
+ cursor?: { id: number; distance: number } | null;
+ },
+ accessToken: string,
+): Promise {
+ const response = await api.get('/api/courses/search/adjacent', {
+ params,
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+ return response.data;
+}
diff --git a/src/services/running.service.ts b/src/services/running.service.ts
index 541e278..3d56a2a 100644
--- a/src/services/running.service.ts
+++ b/src/services/running.service.ts
@@ -3,6 +3,12 @@ import type {
RunningRecordRequest,
RunningRecordResponse,
} from '@/types/run.types';
+import type {
+ RunningRecordsRequest,
+ RunningRecordsResponse,
+ RunningDashboard,
+ RunningDashboardRequest,
+} from '@/types/records.types';
export async function createRunningRecord(
data: RunningRecordRequest,
@@ -17,16 +23,61 @@ export async function createRunningRecord(
};
const params = courseId ? { courseId } : undefined;
+ const endpoint = '/api/running/records';
+
+ console.log('📤 [RunningService] 런닝 기록 저장 API 요청');
+ console.log('📤 [RunningService] 엔드포인트:', endpoint);
+ console.log('📤 [RunningService] 쿼리 파라미터:', params);
+ console.log('📤 [RunningService] 요청 페이로드:', {
+ path: `${requestData.path.length}개 좌표`,
+ startAt: new Date(requestData.startAt).toISOString(),
+ endAt: new Date(requestData.endAt).toISOString(),
+ pace: requestData.pace,
+ calories: requestData.calories,
+ imageUrl: requestData.imageUrl ? '있음' : '없음',
+ });
- const response = await api.post('/api/running/records', requestData, {
+ const response = await api.post(endpoint, requestData, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
params,
});
+
+ console.log('📥 [RunningService] 런닝 기록 저장 API 응답');
+ console.log('📥 [RunningService] 응답 상태:', response.status);
+ console.log('📥 [RunningService] 응답 데이터:', response.data);
+
return response.data;
} catch (error: unknown) {
+ console.error('❌ [RunningService] 런닝 기록 저장 API 오류:', error);
throw error;
}
}
+
+export async function searchRunningRecords(
+ params: RunningRecordsRequest,
+ accessToken: string,
+): Promise {
+ const response = await api.get('/api/running/records', {
+ params,
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+ return response.data;
+}
+
+export async function getRunningDashboard(
+ params: RunningDashboardRequest,
+ accessToken: string,
+): Promise {
+ const response = await api.get('/api/running/dashboards', {
+ params,
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+ return response.data;
+}
diff --git a/src/store/draw.ts b/src/store/draw.ts
index 9d2c958..af48563 100644
--- a/src/store/draw.ts
+++ b/src/store/draw.ts
@@ -44,12 +44,10 @@ const useDrawStore = create((set) => ({
toggleDrawMode: () =>
set((state) => ({
drawMode: state.drawMode === 'draw' ? 'none' : 'draw',
- drawnCoordinates: [],
})),
toggleEraseMode: () =>
set((state) => ({
drawMode: state.drawMode === 'erase' ? 'none' : 'erase',
- drawnCoordinates: [],
})),
eraseRouteByIndex: (routeIndex) =>
set((state) => ({
@@ -66,6 +64,7 @@ const useDrawStore = create((set) => ({
completedDrawings: [],
drawnCoordinates: [],
drawMode: 'none',
+ isLoading: false,
}),
}));
diff --git a/src/store/run.ts b/src/store/run.ts
index d3d9b4f..9785915 100644
--- a/src/store/run.ts
+++ b/src/store/run.ts
@@ -185,23 +185,33 @@ const useRunStore = create((set, get) => ({
// 런닝 액션들
setRunning: (running) => set((state) => ({ ...state, ...running })),
- startRun: () =>
+ startRun: () => {
+ const startTime = new Date();
+ console.log('🏃♂️ [RunStore] 새로운 런닝 시작:', startTime.toISOString());
set({
- startTime: new Date(),
+ startTime,
pausedTime: 0,
pauseStartTime: null,
- }),
+ });
+ },
- pauseRun: () =>
+ pauseRun: () => {
+ const pauseStartTime = new Date();
+ console.log('⏸️ [RunStore] 런닝 일시정지:', pauseStartTime.toISOString());
set({
- pauseStartTime: new Date(),
- }),
+ pauseStartTime,
+ });
+ },
resumeRun: () =>
set((state) => {
if (state.pauseStartTime) {
const pauseDuration =
(new Date().getTime() - state.pauseStartTime.getTime()) / 1000;
+ console.log('▶️ [RunStore] 런닝 재시작:', {
+ pauseDuration: `${pauseDuration.toFixed(1)}초`,
+ totalPausedTime: `${(state.pausedTime + pauseDuration).toFixed(1)}초`,
+ });
return {
pausedTime: state.pausedTime + pauseDuration,
pauseStartTime: null,
@@ -210,13 +220,15 @@ const useRunStore = create((set, get) => ({
return state;
}),
- stopRun: () =>
+ stopRun: () => {
+ console.log('⏹️ [RunStore] 런닝 완전 종료 - 통계 초기화됨');
set({
startTime: null,
pausedTime: 0,
pauseStartTime: null,
stats: initialStats,
- }),
+ });
+ },
// 코스 액션들
setCourseTopology: (courseTopology) => set({ courseTopology }),
@@ -282,15 +294,42 @@ const useRunStore = create((set, get) => ({
}),
resetRunState: () => {
- set((state) => ({
- ...initialUIState,
- ...initialErrorState,
- ...initialRunningState,
- // 코스 데이터는 유지 (currentCourseId, currentCourseData 보존)
- courseTopology: null,
- ...initialLocationTrackingState,
- ...initialCourseValidationState,
- }));
+ set((state) => {
+ // 런닝이 진행 중이면 상태를 보존
+ if (state.startTime) {
+ console.log('🔄 [RunStore] 런닝 상태 보존 - 통계 초기화되지 않음');
+ return {
+ ...initialUIState,
+ ...initialErrorState,
+ // 런닝 상태는 보존
+ startTime: state.startTime,
+ pausedTime: state.pausedTime,
+ pauseStartTime: state.pauseStartTime,
+ stats: state.stats,
+ // 코스 데이터는 유지 (currentCourseId, currentCourseData 보존)
+ courseTopology: null,
+ // 위치 추적 상태는 보존 (경로 데이터 유지)
+ routeCoordinates: state.routeCoordinates,
+ location: state.location,
+ isTracking: state.isTracking,
+ subscriber: state.subscriber,
+ errorMsg: state.errorMsg,
+ ...initialCourseValidationState,
+ };
+ }
+
+ // 런닝이 시작되지 않은 경우에만 완전 초기화
+ console.log('🔄 [RunStore] 런닝 상태 완전 초기화 - 통계 초기화됨');
+ return {
+ ...initialUIState,
+ ...initialErrorState,
+ ...initialRunningState,
+ // 코스 데이터는 유지 (currentCourseId, currentCourseData 보존)
+ courseTopology: null,
+ ...initialLocationTrackingState,
+ ...initialCourseValidationState,
+ };
+ });
},
}));
diff --git a/src/types/courses.types.ts b/src/types/courses.types.ts
index 1f5b338..77b2fde 100644
--- a/src/types/courses.types.ts
+++ b/src/types/courses.types.ts
@@ -39,11 +39,14 @@ export interface CourseSearchItem {
id: number;
title: string;
imageUrl: string;
- departure: RouteCoordinate;
+ departure: [number, number];
length: number;
time: number;
createdAt: string;
- author: string;
+ author: {
+ nickname: string;
+ imageUrl: string;
+ };
bookmarked: boolean;
completed: boolean;
}
diff --git a/src/types/navigation.types.ts b/src/types/navigation.types.ts
index cba9baf..1fbd9b8 100644
--- a/src/types/navigation.types.ts
+++ b/src/types/navigation.types.ts
@@ -16,8 +16,8 @@ export type RootStackParamList = {
export type TabParamList = {
Home: Record;
- Route: Record;
- Run: { courseId?: number } | Record;
+ Records: Record;
+ RunTab: Record;
Community: Record;
Settings: Record;
};
diff --git a/src/types/records.types.ts b/src/types/records.types.ts
new file mode 100644
index 0000000..256b875
--- /dev/null
+++ b/src/types/records.types.ts
@@ -0,0 +1,34 @@
+export interface RunningRecord {
+ id: number;
+ artUrl: string;
+ imageUrl: string;
+}
+
+export interface RunningRecordsResponse {
+ results: RunningRecord[];
+ nextCursor: {
+ id: number;
+ };
+}
+
+export interface RunningRecordsRequest {
+ since?: string;
+ until?: string;
+ cursor?: { id: number } | null;
+ limit?: number;
+}
+
+export interface RunningDashboard {
+ nRecords: number;
+ totalDistance: number;
+ totalDuration: number;
+ totalCalories: number;
+ meanPace: number;
+}
+
+export interface RunningDashboardRequest {
+ since?: string;
+ until?: string;
+}
+
+export type TimeRange = 'week' | 'month' | 'year' | 'all';
diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts
index 1d870dc..1d98ae6 100644
--- a/src/utils/formatters.ts
+++ b/src/utils/formatters.ts
@@ -9,25 +9,29 @@ export const formatDate = (dateString: string): string => {
export const formatDistance = (distance: number): string => {
if (distance < 1000) {
- return `${distance}m`;
+ return `${Math.round(distance * 100) / 100}m`;
}
- return `${(distance / 1000).toFixed(1)}km`;
+ return `${Math.round((distance / 1000) * 100) / 100}km`;
};
export const formatTime = (time: number): string => {
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time % 3600) / 60);
+ const seconds = Math.round((time % 60) * 100) / 100;
if (hours > 0) {
- return `${hours}시간 ${minutes}분`;
+ return `${hours}시간 ${minutes}분 ${seconds}초`;
}
- return `${minutes}분`;
+ if (minutes > 0) {
+ return `${minutes}분 ${seconds}초`;
+ }
+ return `${seconds}초`;
};
export const formatPace = (paceSeconds: number): string => {
if (paceSeconds === 0) return '0\'00"';
const minutes = Math.floor(paceSeconds / 60);
- const seconds = Math.floor(paceSeconds % 60);
- return `${minutes}'${seconds.toString().padStart(2, '0')}"`;
+ const seconds = Math.round((paceSeconds % 60) * 100) / 100;
+ return `${minutes}'${seconds.toFixed(2).padStart(5, '0')}"`;
};