diff --git a/tra-in/package-lock.json b/tra-in/package-lock.json index e759077..01633bb 100644 --- a/tra-in/package-lock.json +++ b/tra-in/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@mj-studio/react-native-naver-map": "^2.6.7", + "@react-native-community/datetimepicker": "^8.5.1", "@react-navigation/bottom-tabs": "^7.8.12", "@react-navigation/native": "^7.1.25", "@react-navigation/native-stack": "^7.8.6", @@ -19,6 +20,7 @@ "expo-status-bar": "~3.0.9", "react": "19.1.0", "react-native": "0.81.5", + "react-native-modal-datetime-picker": "^18.0.0", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "^4.18.0", "react-native-svg": "^15.15.1", @@ -2198,6 +2200,30 @@ "react-native": "*" } }, + "node_modules/@react-native-community/datetimepicker": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.5.1.tgz", + "integrity": "sha512-TuwM1ORbxCjOp1GOtONj0QnpDpVfq0F4UlfKZYPxL/vmriaLHt2Kgvw63Bv0Bpep4eOkslVVSS1IRfRI6d392g==", + "license": "MIT", + "peer": true, + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": ">=52.0.0", + "react": "*", + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "react-native-windows": { + "optional": true + } + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -7148,6 +7174,23 @@ "node": ">= 6" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7343,6 +7386,19 @@ "react-native": "*" } }, + "node_modules/react-native-modal-datetime-picker": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/react-native-modal-datetime-picker/-/react-native-modal-datetime-picker-18.0.0.tgz", + "integrity": "sha512-0jdvhhraZQlRACwr7pM6vmZ2kxgzJ4CpnmV6J3TVA6MrXMXK6Zo/upRBKkRp0+fTOiKuNblzesA2U59rYo6SGA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@react-native-community/datetimepicker": ">=6.7.0", + "react-native": ">=0.65.0" + } + }, "node_modules/react-native-safe-area-context": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", diff --git a/tra-in/package.json b/tra-in/package.json index 6b86331..441dace 100644 --- a/tra-in/package.json +++ b/tra-in/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@mj-studio/react-native-naver-map": "^2.6.7", + "@react-native-community/datetimepicker": "^8.5.1", "@react-navigation/bottom-tabs": "^7.8.12", "@react-navigation/native": "^7.1.25", "@react-navigation/native-stack": "^7.8.6", @@ -20,6 +21,7 @@ "expo-status-bar": "~3.0.9", "react": "19.1.0", "react-native": "0.81.5", + "react-native-modal-datetime-picker": "^18.0.0", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "^4.18.0", "react-native-svg": "^15.15.1", diff --git a/tra-in/src/screens/BookingScreen.js b/tra-in/src/screens/BookingScreen.js index 316744d..27c0fc5 100644 --- a/tra-in/src/screens/BookingScreen.js +++ b/tra-in/src/screens/BookingScreen.js @@ -6,6 +6,9 @@ import { ActivityIndicator, Button, Alert, + ScrollView, + Pressable, + Platform, } from "react-native"; import { API_BASE } from "../config/api"; import ScreenHeader from "../components/ScreenHeader"; @@ -19,9 +22,359 @@ import { selectFirstWaypoint, selectSecondWaypoint, } from "../utils/waypointHandlers"; +import LoadingScreen from "./LoadingScreen"; -/* ==================== 메인 화면 ==================== */ + +/** + * ✅ FastAPI 서버 주소 + * - Android 에뮬레이터: 10.0.2.2 + * - iOS 시뮬레이터/웹: 127.0.0.1 + * - 실제 폰(와이파이): PC 로컬 IP로 바꿔야 함 (예: http://192.168.0.10:8000) + */ +const RECOMMEND_API_BASE = "http://10.0.2.2:8000"; + +// 시각 "2025-12-16T12:24:00" -> "12:24" +function formatTimeHM(iso) { + if (!iso) return ""; + return iso.slice(11, 16); +} + +// "2025-12-16T15:20:00" -> "2025년 12월 16일 15시 20분 이전 도착" +function formatDeadlineKR(iso) { + if (!iso) return ""; + const y = iso.slice(0, 4); + const m = String(parseInt(iso.slice(5, 7), 10)); + const d = String(parseInt(iso.slice(8, 10), 10)); + const hh = String(parseInt(iso.slice(11, 13), 10)); + const mm = String(parseInt(iso.slice(14, 16), 10)); + return `${y}년 ${m}월 ${d}일 ${hh}시 ${mm}분 이전 도착`; +} + +// 긴급 경로에서 사용할 임의 좌석(1~4호차, 1A~8D) 생성 +function randomSeat() { + const carNo = Math.floor(Math.random() * 4) + 1; // 1~4 + const row = Math.floor(Math.random() * 8) + 1; // 1~8 + const cols = ["A", "B", "C", "D"]; + const col = cols[Math.floor(Math.random() * cols.length)]; + return { + carNo, + seatCode: `${row}${col}`, + }; +} + +/* ==================== 긴급 경로 전용 화면 컴포넌트 ==================== */ +function EmergencyRouteScreen({ setActiveTab, searchParams, user }) { + const [loading, setLoading] = useState(true); + const [routes, setRoutes] = useState([]); + const [error, setError] = useState(""); + const [selectedRoute, setSelectedRoute] = useState(null); // 선택한 경로 + + const originName = searchParams.originName; + const destName = searchParams.destName; + const deadline = searchParams.emergencyArrival; // "2025-12-16T16:30:00" 같은 형식 + + // FastAPI 에서 추천 경로 가져오기 + useEffect(() => { + let cancelled = false; + + async function load() { + try { + setLoading(true); + setError(""); + + const body = { + from_name: originName, + to_name: destName, + deadline: deadline, + max_transfers: 2, + min_transfer_min: 15, + limit: 300, + topk: 10, + now: new Date().toISOString().slice(0, 19), + }; + + const url = `${RECOMMEND_API_BASE}/recommend/v2`; + // console.log("recommend url:", url); + + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const txt = await res.text(); + throw new Error(`status ${res.status} / ${txt}`); + } + + const json = await res.json(); + const rawRoutes = json.routes || []; + + // 각 leg에 임의 좌석 정보 붙이기 + const enriched = rawRoutes.map((r) => ({ + ...r, + legs: (r.legs || []).map((leg) => ({ + ...leg, + ...randomSeat(), + })), + })); + + if (!cancelled) { + setRoutes(enriched); + } + } catch (e) { + console.error("emergency routes error", e); + if (!cancelled) { + setError("긴급 경로를 불러오는 중 오류가 발생했습니다."); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + load(); + return () => { + cancelled = true; + }; + }, [originName, destName, deadline]); + + // 긴급 경로 예매 저장 + const handleReserveEmergency = async () => { + if (!user || !user.id || !selectedRoute) return; + + const viaNames = (selectedRoute.transfer_summary || []).map( + (t) => t.station + ); + const stationNames = [originName, ...viaNames, destName]; + + const legsPayload = (selectedRoute.legs || []).map((leg, idx) => { + const originStation = stationNames[idx]; + const destStation = stationNames[idx + 1]; + + return { + originStation, + destStation, + departureTime: leg.dep_time, + arrivalTime: leg.arr_time, + trainNo: leg.train_no, + carNo: leg.carNo, + seatCode: leg.seatCode, + }; + }); + + const payload = { + userId: user.id, + isHopper: true, + legs: legsPayload, + }; + + try { + const res = await fetch(`${API_BASE}/user-tickets`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + console.error("reserve emergency error", await res.text()); + Alert.alert("오류", "예매 저장에 실패했습니다."); + return; + } + + Alert.alert("완료", "예매가 저장되었습니다."); + setActiveTab("home"); + } catch (e) { + console.error("reserve emergency error", e); + Alert.alert("오류", "예매 저장 중 문제가 발생했습니다."); + } + }; + + /** + * ✅ 카드 렌더링 (정렬 개선) + * - 지역 이름 아래: 시간 + * - 화살표(→) 아래: 호차/좌석 + * - 기본 연회색, Top3만 노란색 + */ + const renderRouteItem = (route, index) => { + const viaNames = (route.transfer_summary || []).map((t) => t.station); + const stationNames = [originName, ...viaNames, destName]; + + // 역 아래 시간 구성: + // - 첫 역: 1구간 출발 + // - 중간 역: 해당 역에서 다음 leg 출발 (= legs[i].dep_time) + // - 마지막 역: 마지막 leg 도착 + const stationTimes = stationNames.map((_, i) => { + if (!route.legs || route.legs.length === 0) return ""; + if (i === 0) return formatTimeHM(route.legs[0]?.dep_time); + if (i === stationNames.length - 1) + return formatTimeHM(route.legs[route.legs.length - 1]?.arr_time); + return formatTimeHM(route.legs[i]?.dep_time); + }); + + const isTop3 = index < 3; + + return ( + setSelectedRoute(route)} + > + #{route.rank} + + + {stationNames.map((name, si) => ( + + + + {name} + + {stationTimes[si]} + + + {si < stationNames.length - 1 && ( + + + + {route.legs?.[si]?.carNo}호차 {route.legs?.[si]?.seatCode} + + + )} + + ))} + + + + 환승 {route.transfers}회 • 총 소요 {route.total_duration_min}분 • 여유{" "} + {route.arrival_slack_min}분 + + + ); + }; + + // 경로 요약 화면 (선택 후) + const renderSummary = () => { + if (!selectedRoute) return null; + + const viaNames = (selectedRoute.transfer_summary || []).map( + (t) => t.station + ); + const stationNames = [originName, ...viaNames, destName]; + + return ( + + 긴급 경로 + {formatDeadlineKR(deadline)} + + {selectedRoute.legs.map((leg, idx) => { + const from = stationNames[idx]; + const to = stationNames[idx + 1]; + return ( + + + {from} → {to} + + + 시간: {formatTimeHM(leg.dep_time)} → {formatTimeHM(leg.arr_time)} + + + 열차: {leg.train_no} / 좌석: {leg.carNo}호차 {leg.seatCode} + + + ); + })} + + +