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}
+
+
+ );
+ })}
+
+
+
+
+ );
+ };
+
+// if (loading) {
+// return (
+//
+// 긴급 경로
+// {formatDeadlineKR(deadline)}
+//
+//
+//
+//
+// );
+// }
+if (loading) {
+ return (
+
+
+
+ );
+}
+
+
+ if (error) {
+ return (
+
+ 긴급 경로
+ {formatDeadlineKR(deadline)}
+ {error}
+
+ );
+ }
+
+ if (selectedRoute) {
+ return renderSummary();
+ }
+
+ return (
+
+ 긴급 경로
+ {formatDeadlineKR(deadline)}
+
+ {routes.length === 0 ? (
+ 조건에 맞는 경로가 없습니다.
+ ) : (
+
+ {routes.map((r, idx) => renderRouteItem(r, idx))}
+
+ )}
+
+ );
+}
+
+/* ==================== 기존 BookingScreen (직행 / 메뚜기) ==================== */
export default function BookingScreen({ setActiveTab, searchParams, user }) {
+ // 🔹 긴급 모드인지 먼저 판단
+ const isEmergency = searchParams?.isEmergency;
+
+ // 🔸 긴급이면 전용 화면으로 바로 분기
+ if (isEmergency) {
+ return (
+
+ setActiveTab("home")}
+ />
+
+
+
+ );
+ }
+
+ // ================= 긴급이 아닐 때: 기존 로직 유지 =================
const [mode, setMode] = useState(null); // 'direct' | 'hopper'
const [routeStops, setRouteStops] = useState([]);
const [currentLegIndex, setCurrentLegIndex] = useState(0);
@@ -82,12 +435,10 @@ export default function BookingScreen({ setActiveTab, searchParams, user }) {
const firstCandidates = [];
- // 출발지 → 해당 역 구간에 (오늘/내일 기준) 열차가 있는 역만 남기기
+ // 출발지 → 해당 역 구간에 열차가 있는 역만 남기기
for (const s of baseList) {
const ok = await hasAnyTrain(originName, s.name, baseDate);
- if (ok) {
- firstCandidates.push(s);
- }
+ if (ok) firstCandidates.push(s);
}
setStations(firstCandidates);
@@ -175,14 +526,13 @@ export default function BookingScreen({ setActiveTab, searchParams, user }) {
setStep("train");
};
- /* ====== 예매 저장 로직 ====== */
+ /* ====== 예매 저장 로직 (기존) ====== */
const handleReserve = async () => {
if (!user || !user.id) {
Alert.alert("오류", "로그인 정보가 없습니다.");
return;
}
- // 구간 + 선택된 열차/좌석을 묶어서 legs payload 생성
const legsPayload = legs
.map((leg, idx) => {
const train = selectedTrains[idx];
@@ -190,12 +540,11 @@ export default function BookingScreen({ setActiveTab, searchParams, user }) {
if (!train || !seat) return null;
return {
- // 🔁 백엔드 DTO에 맞게 필드 이름 변경
originStation: leg.from,
destStation: leg.to,
- departureTime: train.departureTime, // "2025-12-16T06:30:00"
+ departureTime: train.departureTime,
arrivalTime: train.arrivalTime,
- trainNo: train.trainNo, // 🔁 trainName 대신 trainNo 하나만 전송
+ trainNo: train.trainNo,
carNo: seat.carNo,
seatCode: seat.seatCode,
};
@@ -246,7 +595,6 @@ export default function BookingScreen({ setActiveTab, searchParams, user }) {
);
} else if (mode === "hopper" && step === "waypoints") {
- // 메뚜기: 경유지 선택 (1번째 또는 2번째)
body = (
);
} else if (step === "train" && currentLeg) {
- // 열차 선택
const from = currentLeg.from;
const to = currentLeg.to;
- // 이전 구간의 도착시간을 after로
let after = null;
if (currentLegIndex > 0) {
const prevTrain = selectedTrains[currentLegIndex - 1];
@@ -297,7 +643,6 @@ export default function BookingScreen({ setActiveTab, searchParams, user }) {
);
} else if (step === "seat") {
- // 좌석 선택
const train = selectedTrains[currentLegIndex];
body = (
@@ -315,7 +660,6 @@ export default function BookingScreen({ setActiveTab, searchParams, user }) {
);
} else if (step === "summary") {
- // 요약
body = (
);
} else {
- // 안전용
body = (
@@ -355,6 +698,22 @@ export default function BookingScreen({ setActiveTab, searchParams, user }) {
);
}
+ // ✅ 홈에서 booking으로 넘어온 직후: step이 아직 init이면 로딩 화면
+ if (searchParams && step === "init") {
+ return (
+
+ setActiveTab("home")}
+ />
+
+
+
+ );
+ }
+
+
return (
{
fetch(`${API_BASE}/stations`)
.then((res) => res.json())
@@ -62,23 +88,41 @@ export default function HomeScreen({
const incrPassengers = () => setPassengers((p) => Math.min(p + 1, 9));
const decrPassengers = () => setPassengers((p) => (p > 1 ? p - 1 : 1));
+ const arrivalDateLabel = arrivalDate
+ ? `${arrivalDate.getFullYear()}-${pad2(arrivalDate.getMonth() + 1)}-${pad2(
+ arrivalDate.getDate()
+ )}`
+ : "날짜 선택";
+
+ const arrivalTimeLabel = arrivalTime
+ ? `${pad2(arrivalTime.getHours())}:${pad2(arrivalTime.getMinutes())}`
+ : "시간 선택";
+
const handleSearch = () => {
+ let emergencyArrival = null;
+ if (isEmergency) {
+ if (!arrivalDate || !arrivalTime) {
+ alert("희망 도착 날짜와 시간을 모두 선택해 주세요.");
+ return;
+ }
+ emergencyArrival = buildArrivalIso(arrivalDate, arrivalTime);
+ }
+
setSearchParams({
originName: origin,
destName: dest,
date,
passengers,
isHopper: ticketType === "hopper",
+ isEmergency,
+ emergencyArrival,
});
setActiveTab("booking");
};
return (
-
+
{/* 승차권 / 메뚜기 토글 */}
@@ -120,28 +164,41 @@ export default function HomeScreen({
{/* 출발/도착/날짜/인원 카드 */}
- 출발
- openStationModal("origin")}>
- {origin}
-
+
+ {/* 출발 */}
+ openStationModal("origin")}
+ activeOpacity={0.7}
+ >
+ 출발
+ {origin}
+
- ➜
+ {/* 화살표 */}
+
+ ➜
+
- 도착
- openStationModal("dest")}>
- {dest}
-
+ {/* 도착 */}
+ openStationModal("dest")}
+ activeOpacity={0.7}
+ >
+ 도착
+ {dest}
+
+
+ {/* 가는 날 */}
가는 날
{DATE_OPTIONS.map((d) => (
setDate(d)}
>
+ {/* 긴급 토글 */}
+
+ setIsEmergency((prev) => !prev)}
+ style={{
+ width: 20,
+ height: 20,
+ borderRadius: 4,
+ borderWidth: 1,
+ borderColor: "#555",
+ justifyContent: "center",
+ alignItems: "center",
+ marginRight: 8,
+ }}
+ >
+ {isEmergency && (
+
+ )}
+
+ 긴급
+
+
+ {/* 희망 도착 일시 */}
+ {isEmergency && (
+
+ 희망 도착 시간
+
+ setArrivalDatePickerVisible(true)}
+ >
+ {arrivalDateLabel}
+
+ setArrivalTimePickerVisible(true)}
+ >
+ {arrivalTimeLabel}
+
+
+
+ )}
+
+ {/* 인원 선택 */}
인원 선택
@@ -210,23 +318,38 @@ export default function HomeScreen({
+
+ {/* 🔹 날짜 선택 모달 */}
+ {
+ setArrivalDatePickerVisible(false);
+ setArrivalDate(date);
+ }}
+ onCancel={() => setArrivalDatePickerVisible(false)}
+ />
+
+ {/* 🔹 시간 선택 모달 */}
+ {
+ setArrivalTimePickerVisible(false);
+ setArrivalTime(time);
+ }}
+ onCancel={() => setArrivalTimePickerVisible(false)}
+ />
+
);
}
const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: "#fff",
- },
- content: {
- flex: 1,
- paddingTop: 16,
- paddingHorizontal: 16,
- paddingBottom: 16,
- },
-
+ container: { flex: 1, backgroundColor: "#fff" },
+ content: { flex: 1, padding: 16 },
ticketTypeRow: { flexDirection: "row", marginBottom: 16 },
ticketTypeButton: {
flex: 1,
@@ -243,7 +366,6 @@ const styles = StyleSheet.create({
},
ticketTypeText: { fontSize: 14, color: "#444" },
ticketTypeTextActive: { color: "#0A84FF", fontWeight: "bold" },
-
card: {
padding: 16,
borderRadius: 12,
@@ -273,7 +395,6 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "center",
},
-
dateRow: { flexDirection: "row", gap: 8 },
dateChip: {
paddingHorizontal: 8,
@@ -285,7 +406,6 @@ const styles = StyleSheet.create({
dateChipActive: { borderColor: "#0A84FF", backgroundColor: "#0A84FF11" },
dateChipText: { fontSize: 12 },
dateChipTextActive: { color: "#0A84FF", fontWeight: "bold" },
-
searchButton: {
marginTop: 8,
paddingVertical: 14,
@@ -296,7 +416,6 @@ const styles = StyleSheet.create({
alignItems: "center",
},
searchButtonText: { fontSize: 16, fontWeight: "600", color: "#555" },
-
modalBackdrop: {
flex: 1,
backgroundColor: "#00000055",
@@ -316,9 +435,43 @@ const styles = StyleSheet.create({
borderBottomWidth: 1,
borderBottomColor: "#eee",
},
- modalClose: {
- marginTop: 8,
- alignSelf: "center",
- padding: 8,
+ modalClose: { marginTop: 8, alignSelf: "center", padding: 8 },
+ emergencyBlock: { marginTop: 8 },
+ emergencyLabel: { fontSize: 13, color: "#777", marginBottom: 4 },
+ emergencyRow: { flexDirection: "row", justifyContent: "space-between" },
+ emergencyInput: {
+ flex: 1,
+ borderWidth: 1,
+ borderColor: "#ccc",
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ marginRight: 8,
+ backgroundColor: "#fff",
+ justifyContent: "center",
},
+ emergencyText: { fontSize: 14, color: "#333" },
+ odRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingVertical: 8,
+ },
+
+ odCol: {
+ flex: 1,
+ },
+
+ odArrowWrap: {
+ width: 40,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+
+ arrowText: {
+ fontSize: 22,
+ fontWeight: "700",
+ color: "#111",
+ },
+
});