diff --git a/HotCha/HotCha.xcodeproj/project.pbxproj b/HotCha/HotCha.xcodeproj/project.pbxproj index 324721a..f4d0b81 100644 --- a/HotCha/HotCha.xcodeproj/project.pbxproj +++ b/HotCha/HotCha.xcodeproj/project.pbxproj @@ -202,7 +202,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"HotCha/Preview Content\""; - DEVELOPMENT_TEAM = UW9295Z57Q; + DEVELOPMENT_TEAM = V9J26YADA8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = HotCha/Info.plist; @@ -355,7 +355,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"HotCha/Preview Content\""; - DEVELOPMENT_TEAM = UW9295Z57Q; + DEVELOPMENT_TEAM = V9J26YADA8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = HotCha/Info.plist; @@ -389,7 +389,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"HotCha/Preview Content\""; - DEVELOPMENT_TEAM = UW9295Z57Q; + DEVELOPMENT_TEAM = V9J26YADA8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = HotCha/Info.plist; diff --git a/HotCha/HotCha/App/HotChaApp.swift b/HotCha/HotCha/App/HotChaApp.swift index 1aeb0ab..8b1e80f 100644 --- a/HotCha/HotCha/App/HotChaApp.swift +++ b/HotCha/HotCha/App/HotChaApp.swift @@ -22,8 +22,9 @@ struct HotChaApp: App { var body: some Scene { WindowGroup { - SplashView() - .modelContainer(HotchaContainer) +// SplashView() +// .modelContainer(HotchaContainer) + TestView() } } } diff --git a/HotCha/HotCha/Info.plist b/HotCha/HotCha/Info.plist index b7fe550..0a865b2 100644 --- a/HotCha/HotCha/Info.plist +++ b/HotCha/HotCha/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + UIAppFonts Pretendard-Bold.otf diff --git a/HotCha/HotCha/Models/Bus/BusArrivalInfo.swift b/HotCha/HotCha/Models/Bus/BusArrivalInfo.swift new file mode 100644 index 0000000..a975d47 --- /dev/null +++ b/HotCha/HotCha/Models/Bus/BusArrivalInfo.swift @@ -0,0 +1,45 @@ +// +// BusArrivalInfo.swift +// HotCha +// +// Created by 문호 on 3/14/25. +// + +import Foundation + +// BusArrivalInfo.swift - 버스 도착 정보 모델 +struct BusArrivalInfo: Codable, Identifiable { + let id = UUID() + let routeId: String // 노선 ID + let routeName: String // 노선명 + let stationId: String // 정류소 ID + let stationName: String // 정류소 명 + let predictTime1: Int // 첫번째 버스 도착 예정 시간(분) + let predictTime2: Int? // 두번째 버스 도착 예정 시간(분) + let locationNo1: Int? // 첫번째 버스 현재 위치(몇 번째 정류소) + let locationNo2: Int? // 두번째 버스 현재 위치(몇 번째 정류소) + + enum CodingKeys: String, CodingKey { + case routeId = "routeid" + case routeName = "routeno" + case stationId = "nodeid" + case stationName = "nodenm" + case predictTime1 = "arrtime" + case predictTime2 = "arrtime2" + case locationNo1 = "nodeno" + case locationNo2 = "nodeno2" + } + + // 테스트용 초기화 메서드 + init(routeId: String, routeName: String, stationId: String, stationName: String, predictTime1: Int, predictTime2: Int? = nil, locationNo1: Int? = nil, locationNo2: Int? = nil) { + self.routeId = routeId + self.routeName = routeName + self.stationId = stationId + self.stationName = stationName + self.predictTime1 = predictTime1 + self.predictTime2 = predictTime2 + self.locationNo1 = locationNo1 + self.locationNo2 = locationNo2 + } +} + diff --git a/HotCha/HotCha/Models/Bus/BusLocationInfo.swift b/HotCha/HotCha/Models/Bus/BusLocationInfo.swift new file mode 100644 index 0000000..023da61 --- /dev/null +++ b/HotCha/HotCha/Models/Bus/BusLocationInfo.swift @@ -0,0 +1,94 @@ +// +// BusLocationInfo.swift +// HotCha +// +// Created by 문호 on 3/14/25. +// + +import Foundation + +// BusLocationInfo.swift - 버스 위치 정보 모델 +struct BusLocationInfo: Codable, Identifiable { + let id = UUID() + let routeId: String // 노선 ID + let routeName: String // 노선명 + let vehicleId: String // 차량 ID + let stationId: String? // 현재/최근 정류소 ID + let stationSeq: Int // 정류소 순번 + let stationName: String? // 현재/최근 정류소명 + let latitude: Double // 위도 + let longitude: Double // 경도 + + enum CodingKeys: String, CodingKey { + case routeId = "routeid" + case routeName = "routeno" + case vehicleId = "vehicleno" + case stationId = "nodeid" + case stationSeq = "nodeord" + case stationName = "nodenm" + case latitude = "gpslati" + case longitude = "gpslong" + } + + // 디코딩 중 데이터 타입 변환을 위한 커스텀 이니셜라이저 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + routeId = try container.decode(String.self, forKey: .routeId) + + // routeno가 String 또는 Int로 올 수 있음 + if let routeNoStr = try? container.decode(String.self, forKey: .routeName) { + routeName = routeNoStr + } else if let routeNoInt = try? container.decode(Int.self, forKey: .routeName) { + routeName = String(routeNoInt) + } else { + routeName = "알 수 없음" + } + + vehicleId = try container.decode(String.self, forKey: .vehicleId) + stationId = try? container.decode(String.self, forKey: .stationId) + + // stationSeq가 String 또는 Int로 올 수 있음 + if let stationSeqStr = try? container.decode(String.self, forKey: .stationSeq), + let stationSeqInt = Int(stationSeqStr) { + stationSeq = stationSeqInt + } else if let stationSeqInt = try? container.decode(Int.self, forKey: .stationSeq) { + stationSeq = stationSeqInt + } else { + stationSeq = 0 + } + + stationName = try? container.decode(String.self, forKey: .stationName) + + // 위도와 경도 처리 + if let latitudeStr = try? container.decode(String.self, forKey: .latitude), + let latitudeDouble = Double(latitudeStr) { + latitude = latitudeDouble + } else if let latitudeDouble = try? container.decode(Double.self, forKey: .latitude) { + latitude = latitudeDouble + } else { + latitude = 0.0 + } + + if let longitudeStr = try? container.decode(String.self, forKey: .longitude), + let longitudeDouble = Double(longitudeStr) { + longitude = longitudeDouble + } else if let longitudeDouble = try? container.decode(Double.self, forKey: .longitude) { + longitude = longitudeDouble + } else { + longitude = 0.0 + } + } + + // 테스트용 초기화 메서드 + init(routeId: String, routeName: String, vehicleId: String, stationId: String?, stationSeq: Int, stationName: String?, latitude: Double, longitude: Double) { + self.routeId = routeId + self.routeName = routeName + self.vehicleId = vehicleId + self.stationId = stationId + self.stationSeq = stationSeq + self.stationName = stationName + self.latitude = latitude + self.longitude = longitude + } +} diff --git a/HotCha/HotCha/Models/Bus/BusRouteInfo.swift b/HotCha/HotCha/Models/Bus/BusRouteInfo.swift new file mode 100644 index 0000000..64377e8 --- /dev/null +++ b/HotCha/HotCha/Models/Bus/BusRouteInfo.swift @@ -0,0 +1,82 @@ +// +// BusRouteInfo.swift +// HotCha +// +// Created by 문호 on 3/14/25. +// + +import Foundation + +// BusRouteInfo.swift - 버스 노선 정보 모델 +struct BusRouteInfo: Codable, Identifiable { + let id = UUID() + let routeId: String // 노선 ID + let routeName: String // 노선명 + let routeTypeName: String // 노선 유형 + let startStationName: String // 기점명 + let endStationName: String // 종점명 + let firstBusTime: String // 첫차 시간 + let lastBusTime: String // 막차 시간 + + enum CodingKeys: String, CodingKey { + case routeId = "routeid" + case routeName = "routeno" + case routeTypeName = "routetp" + case startStationName = "startnodenm" + case endStationName = "endnodenm" + case firstBusTime = "startvehicletime" + case lastBusTime = "endvehicletime" + } + + // 디코딩 중 데이터 타입 변환을 위한 커스텀 이니셜라이저 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + routeId = try container.decode(String.self, forKey: .routeId) + + // routeno가 String 또는 Int로 올 수 있음 + if let routeNoString = try? container.decode(String.self, forKey: .routeName) { + routeName = routeNoString + } else if let routeNoInt = try? container.decode(Int.self, forKey: .routeName) { + routeName = String(routeNoInt) + } else { + throw DecodingError.valueNotFound(String.self, DecodingError.Context( + codingPath: [CodingKeys.routeName], + debugDescription: "Unable to decode routeName" + )) + } + + routeTypeName = try container.decode(String.self, forKey: .routeTypeName) + startStationName = try container.decode(String.self, forKey: .startStationName) + endStationName = try container.decode(String.self, forKey: .endStationName) + + // firstBusTime이 String 또는 Int로 올 수 있음 + if let firstBusTimeString = try? container.decode(String.self, forKey: .firstBusTime) { + firstBusTime = firstBusTimeString + } else if let firstBusTimeInt = try? container.decode(Int.self, forKey: .firstBusTime) { + firstBusTime = String(firstBusTimeInt) + } else { + firstBusTime = "정보 없음" + } + + // lastBusTime이 String 또는 Int로 올 수 있음 + if let lastBusTimeString = try? container.decode(String.self, forKey: .lastBusTime) { + lastBusTime = lastBusTimeString + } else if let lastBusTimeInt = try? container.decode(Int.self, forKey: .lastBusTime) { + lastBusTime = String(lastBusTimeInt) + } else { + lastBusTime = "정보 없음" + } + } + + // 테스트용 초기화 메서드 + init(routeId: String, routeName: String, routeTypeName: String, startStationName: String, endStationName: String, firstBusTime: String, lastBusTime: String) { + self.routeId = routeId + self.routeName = routeName + self.routeTypeName = routeTypeName + self.startStationName = startStationName + self.endStationName = endStationName + self.firstBusTime = firstBusTime + self.lastBusTime = lastBusTime + } +} diff --git a/HotCha/HotCha/Models/Bus/BusStopInfo.swift b/HotCha/HotCha/Models/Bus/BusStopInfo.swift new file mode 100644 index 0000000..58b0772 --- /dev/null +++ b/HotCha/HotCha/Models/Bus/BusStopInfo.swift @@ -0,0 +1,76 @@ +// +// BusStopInfo.swift +// HotCha +// +// Created by 문호 on 3/14/25. +// + +import Foundation + +// BusStopInfo.swift - 버스 정류소 정보 모델 +struct BusStopInfo: Codable, Identifiable { + let id = UUID() + let stationId: String // 정류소 ID + let stationName: String // 정류소 명 + let regionName: String // 지역명 + let mobileNo: String? // 정류소 고유번호 + let latitude: Double // 위도 + let longitude: Double // 경도 + + enum CodingKeys: String, CodingKey { + case stationId = "nodeid" + case stationName = "nodenm" + case regionName = "nodeno" + case mobileNo = "gpslati" + case latitude = "gpslong" + case longitude = "nodeord" + } + + // 디코딩 중 데이터 타입 변환을 위한 커스텀 이니셜라이저 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + stationId = try container.decode(String.self, forKey: .stationId) + stationName = try container.decode(String.self, forKey: .stationName) + + // regionName이 문자열 또는 숫자로 올 수 있음 + if let regionNameStr = try? container.decode(String.self, forKey: .regionName) { + regionName = regionNameStr + } else if let regionNameInt = try? container.decode(Int.self, forKey: .regionName) { + regionName = String(regionNameInt) + } else { + regionName = "알 수 없음" + } + + mobileNo = try? container.decode(String.self, forKey: .mobileNo) + + // 위도와 경도 처리 + if let latitudeStr = try? container.decode(String.self, forKey: .latitude), + let latitudeDouble = Double(latitudeStr) { + latitude = latitudeDouble + } else if let latitudeDouble = try? container.decode(Double.self, forKey: .latitude) { + latitude = latitudeDouble + } else { + latitude = 0.0 + } + + if let longitudeStr = try? container.decode(String.self, forKey: .longitude), + let longitudeDouble = Double(longitudeStr) { + longitude = longitudeDouble + } else if let longitudeDouble = try? container.decode(Double.self, forKey: .longitude) { + longitude = longitudeDouble + } else { + longitude = 0.0 + } + } + + // 테스트용 초기화 메서드 + init(stationId: String, stationName: String, regionName: String, mobileNo: String?, latitude: Double, longitude: Double) { + self.stationId = stationId + self.stationName = stationName + self.regionName = regionName + self.mobileNo = mobileNo + self.latitude = latitude + self.longitude = longitude + } +} diff --git a/HotCha/HotCha/Models/Bus/RouteStationInfo.swift b/HotCha/HotCha/Models/Bus/RouteStationInfo.swift new file mode 100644 index 0000000..eaf4c25 --- /dev/null +++ b/HotCha/HotCha/Models/Bus/RouteStationInfo.swift @@ -0,0 +1,64 @@ +// +// RouteStationInfo.swift +// HotCha +// +// Created by 문호 on 3/14/25. +// + +import Foundation + +// RouteStationInfo.swift - 노선의 정류소 목록 정보 +struct RouteStationInfo: Codable, Identifiable { + let id = UUID() + let routeId: String // 노선 ID + let routeName: String // 노선명 + let stationId: String // 정류소 ID + let stationName: String // 정류소명 + let stationSeq: Int // 정류소 순번 + + enum CodingKeys: String, CodingKey { + case routeId = "routeid" + case routeName = "routeno" + case stationId = "nodeid" + case stationName = "nodenm" + case stationSeq = "nodeord" + } + + // 디코딩 중 데이터 타입 변환을 위한 커스텀 이니셜라이저 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + routeId = try container.decode(String.self, forKey: .routeId) + + // routeno가 String 또는 Int로 올 수 있음 + if let routeNoString = try? container.decode(String.self, forKey: .routeName) { + routeName = routeNoString + } else if let routeNoInt = try? container.decode(Int.self, forKey: .routeName) { + routeName = String(routeNoInt) + } else { + routeName = "알 수 없음" + } + + stationId = try container.decode(String.self, forKey: .stationId) + stationName = try container.decode(String.self, forKey: .stationName) + + // stationSeq가 String 또는 Int로 올 수 있음 + if let stationSeqString = try? container.decode(String.self, forKey: .stationSeq), + let stationSeqInt = Int(stationSeqString) { + stationSeq = stationSeqInt + } else if let stationSeqInt = try? container.decode(Int.self, forKey: .stationSeq) { + stationSeq = stationSeqInt + } else { + stationSeq = 0 + } + } + + // 테스트용 초기화 메서드 + init(routeId: String, routeName: String, stationId: String, stationName: String, stationSeq: Int) { + self.routeId = routeId + self.routeName = routeName + self.stationId = stationId + self.stationName = stationName + self.stationSeq = stationSeq + } +} diff --git a/HotCha/HotCha/Models/NetworkError.swift b/HotCha/HotCha/Models/NetworkError.swift new file mode 100644 index 0000000..40e10fe --- /dev/null +++ b/HotCha/HotCha/Models/NetworkError.swift @@ -0,0 +1,41 @@ +// +// NetworkError.swift +// HotCha +// +// Created by 문호 on 3/14/25. +// + +import Foundation + +// NetworkError.swift - 네트워크 에러 정의 +enum NetworkError: Error { + case invalidURL + case invalidResponse + case invalidData + case networkFailure(Error) + case apiError(String, String) // (resultCode, resultMsg) + case decodingError(Error) + case emptyResponse + case unknownError + + var description: String { + switch self { + case .invalidURL: + return "유효하지 않은 URL입니다." + case .invalidResponse: + return "유효하지 않은 응답입니다." + case .invalidData: + return "유효하지 않은 데이터입니다." + case .networkFailure(let error): + return "네트워크 오류: \(error.localizedDescription)" + case .apiError(let code, let message): + return "API 오류[\(code)]: \(message)" + case .decodingError(let error): + return "데이터 디코딩 오류: \(error.localizedDescription)" + case .emptyResponse: + return "데이터가 비어있습니다." + case .unknownError: + return "알 수 없는 오류가 발생했습니다." + } + } +} diff --git a/HotCha/HotCha/Services/APIResponse.swift b/HotCha/HotCha/Services/APIResponse.swift new file mode 100644 index 0000000..5360e12 --- /dev/null +++ b/HotCha/HotCha/Services/APIResponse.swift @@ -0,0 +1,35 @@ +// +// APIResponse.swift +// HotCha +// +// Created by 문호 on 3/14/25. +// + +import Foundation + +// API 응답 구조 정의 +struct APIResponse: Codable { + let response: ResponseBody + + struct ResponseBody: Codable { + let header: Header + let body: Body? + + struct Header: Codable { + let resultCode: String + let resultMsg: String + } + + struct Body: Codable { + let items: Items? + let numOfRows: Int? + let pageNo: Int? + let totalCount: Int? + + struct Items: Codable { + let item: [T]? + } + } + } +} + diff --git a/HotCha/HotCha/Services/BusAPIService.swift b/HotCha/HotCha/Services/BusAPIService.swift new file mode 100644 index 0000000..20b1d31 --- /dev/null +++ b/HotCha/HotCha/Services/BusAPIService.swift @@ -0,0 +1,651 @@ +// +// BusAPIService.swift +// HotCha +// +// Created by 문호 on 3/14/25. +// + +// BusAPIService.swift +import Foundation +import Combine + +class BusAPIService { + // 싱글톤 인스턴스 + static let shared = BusAPIService() + private init() {} + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .useDefaultKeys + return decoder + }() + + // 기본 API 호출 메서드 + private func fetchData(urlString: String) -> AnyPublisher<[T], NetworkError> { + // 요청 URL 로깅 + print("=== API REQUEST ===") + print("Requesting API URL: \(urlString)") + + guard let url = URL(string: urlString) else { + print("Invalid URL: \(urlString)") + return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher() + } + + return URLSession.shared.dataTaskPublisher(for: url) + .mapError { error -> NetworkError in + print("Network failure: \(error.localizedDescription)") + return NetworkError.networkFailure(error) + } + .flatMap { data, response -> AnyPublisher<[T], NetworkError> in + guard let httpResponse = response as? HTTPURLResponse else { + print("Invalid response type") + return Fail(error: NetworkError.invalidResponse).eraseToAnyPublisher() + } + + // 응답 상태 코드 로깅 + print("=== API RESPONSE ===") + print("API Response Status: \(httpResponse.statusCode)") + + guard (200...299).contains(httpResponse.statusCode) else { + print("HTTP Error: \(httpResponse.statusCode)") + return Fail(error: NetworkError.invalidResponse).eraseToAnyPublisher() + } + + // 원시 응답 데이터 상세 로깅 + print("Raw API Response:") + if let responseString = String(data: data, encoding: .utf8) { + print(responseString) + + // XML 응답 확인 + if responseString.hasPrefix("<") { + print("Response is XML, not JSON. Cannot decode.") + return Just([]).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } + } else { + print("Unable to decode response to string") + } + + // JSON 구조 확인을 위한 추가 로깅 + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + print("JSON Structure:") + print(json) + } + + return Just(data) + .tryMap { data -> Data in + // 응답이 비어있는지 확인 + guard !data.isEmpty else { + throw NetworkError.emptyResponse + } + return data + } + .tryMap { data -> APIResponse in + do { + return try self.decoder.decode(APIResponse.self, from: data) + } catch { + print("=== DECODING ERROR ===") + print("Error: \(error)") + + // 일반적인 디코딩에 실패한 경우 임시 모델로 시도 (5자리 지역코드용) + if let altResponse = try? self.decoder.decode(AlternativeAPIResponse.self, from: data) { + // 대체 응답 구조를 표준 구조로 변환 + return APIResponse(alternativeResponse: altResponse) + } + + // 모든 디코딩 시도 실패 + throw error + } + } + .mapError { error -> NetworkError in + if let networkError = error as? NetworkError { + return networkError + } + + if let decodingError = error as? DecodingError { + switch decodingError { + case .typeMismatch(let type, let context): + print("Type mismatch: expected \(type), context: \(context)") + case .valueNotFound(let type, let context): + print("Value not found: \(type), context: \(context)") + case .keyNotFound(let key, let context): + print("Key not found: \(key), context: \(context)") + case .dataCorrupted(let context): + print("Data corrupted: \(context)") + @unknown default: + print("Unknown decoding error") + } + } + + return NetworkError.decodingError(error) + } + .flatMap { response -> AnyPublisher<[T], NetworkError> in + // API 응답 코드 확인 + if response.response.header.resultCode != "00" { + print("API Error Code: \(response.response.header.resultCode), Message: \(response.response.header.resultMsg)") + return Fail(error: NetworkError.apiError( + response.response.header.resultCode, + response.response.header.resultMsg + )).eraseToAnyPublisher() + } + + // 응답 데이터 확인 + if let body = response.response.body, + let items = body.items, + let itemArray = items.item { + if itemArray.isEmpty { + print("Item array is empty") + } else { + print("Successfully decoded \(itemArray.count) items") + } + return Just(itemArray).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } else { + print("Empty response structure") + return Just([]).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + // MARK: - 버스 도착 정보 API + + /// 정류소 ID로 버스 도착 정보 조회 + func getBusArrivalInfo(stationId: String, cityCode: String) -> AnyPublisher<[BusArrivalInfo], NetworkError> { + let urlString = "\(Constants.API.baseURL)\(Constants.API.busArrival)?serviceKey=\(Constants.apiKey)&cityCode=\(cityCode)&nodeId=\(stationId)&numOfRows=100&_type=json" + return fetchData(urlString: urlString) + } + + // MARK: - 버스 정류소 정보 API + + /// 지역코드와 정류소 이름으로 정류소 정보 조회 + func getBusStopInfo(stationName: String, cityCode: String) -> AnyPublisher<[BusStopInfo], NetworkError> { + // URL에 한글이 포함될 수 있으므로 인코딩 처리 + let encodedStationName = stationName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? stationName + let urlString = "\(Constants.API.baseURL)\(Constants.API.busStop)?serviceKey=\(Constants.apiKey)&cityCode=\(cityCode)&nodeNm=\(encodedStationName)&numOfRows=100&_type=json" + return fetchData(urlString: urlString) + } + + // MARK: - 버스 위치 정보 API + + /// 노선 ID로 버스 위치 정보 조회 + func getBusLocationInfo(routeId: String, cityCode: String) -> AnyPublisher<[BusLocationInfo], NetworkError> { + let urlString = "\(Constants.API.baseURL)\(Constants.API.busLocation)?serviceKey=\(Constants.apiKey)&cityCode=\(cityCode)&routeId=\(routeId)&numOfRows=100&_type=json" + return fetchData(urlString: urlString) + } + + // MARK: - 버스 노선 정보 API + + // 버스 번호로 노선 정보 조회 (수정된 버전 - 2글자 및 5글자 모두 지원) + func getBusRouteInfo(routeName: String, cityCode: String) -> AnyPublisher<[BusRouteInfo], NetworkError> { + let encodedRouteName = routeName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? routeName + + // 우선 2글자 cityCode로 시도 + let urlString = "\(Constants.API.baseURL)\(Constants.API.busRoute)?serviceKey=\(Constants.apiKey)&cityCode=\(cityCode)&routeNo=\(encodedRouteName)&numOfRows=100&_type=json" + + return fetchData(urlString: urlString) + .catch { error -> AnyPublisher<[BusRouteInfo], NetworkError> in + // 첫 번째 요청 실패 시 다른 방법으로 시도 + + // 5글자 코드이면 2글자로 변환하여 시도 + if cityCode.count > 2 { + let shorterCode = String(cityCode.prefix(2)) + let fallbackUrlString = "\(Constants.API.baseURL)\(Constants.API.busRoute)?serviceKey=\(Constants.apiKey)&cityCode=\(shorterCode)&routeNo=\(encodedRouteName)&numOfRows=100&_type=json" + return self.fetchData(urlString: fallbackUrlString) + } + + // 실패하면 원래 오류 반환 + return Fail(error: error).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + // MARK: - 버스 노선별 정류소 목록 API + + /// 노선 ID로 정류소 목록 조회 + func getRouteStationList(routeId: String, cityCode: String) -> AnyPublisher<[RouteStationInfo], NetworkError> { + let urlString = "\(Constants.API.baseURL)\(Constants.API.routeStations)?serviceKey=\(Constants.apiKey)&cityCode=\(cityCode)&routeId=\(routeId)&numOfRows=300&_type=json" + return fetchData(urlString: urlString) + } +} + +// 대체 API 응답 구조 (5자리 지역코드용) +struct AlternativeAPIResponse: Codable { + let result: AlternativeResponseBody + + struct AlternativeResponseBody: Codable { + let status: String + let message: String + let data: AlternativeData? + + struct AlternativeData: Codable { + let list: [T]? + let totalCount: Int? + } + } +} + +// 확장: 대체 응답 구조를 표준 구조로 변환 +extension APIResponse { + init(alternativeResponse: AlternativeAPIResponse) { + let header = ResponseBody.Header( + resultCode: alternativeResponse.result.status == "SUCCESS" ? "00" : "01", + resultMsg: alternativeResponse.result.message + ) + + var body: ResponseBody.Body? = nil + if let data = alternativeResponse.result.data { + let items = ResponseBody.Body.Items(item: data.list ?? []) + body = ResponseBody.Body( + items: items, + numOfRows: data.list?.count ?? 0, + pageNo: 1, + totalCount: data.totalCount ?? 0 + ) + } + + self.response = ResponseBody(header: header, body: body) + } +} + +// Helper extension for String to Int conversion +extension StringProtocol { + var int: Int? { + return Int(self) + } +} + +// 임시 대체 솔루션: JSON 디코딩 대신 수동 파싱 +extension BusAPIService { + + // 수동 JSON 파싱을 통한 버스 노선 정보 조회 + func getBusRouteInfoRobust(routeName: String, cityCode: String) -> AnyPublisher<[BusRouteInfo], NetworkError> { + let encodedRouteName = routeName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? routeName + + // 지역코드 처리 (2자리/5자리 모두 시도) + let urlString = "\(Constants.API.baseURL)\(Constants.API.busRoute)?serviceKey=\(Constants.apiKey)&cityCode=\(cityCode)&routeNo=\(encodedRouteName)&numOfRows=100&_type=json" + + print("API 요청: \(urlString)") + + guard let url = URL(string: urlString) else { + return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher() + } + + return URLSession.shared.dataTaskPublisher(for: url) + .map(\.data) + .mapError { NetworkError.networkFailure($0) } + .flatMap { data -> AnyPublisher<[BusRouteInfo], NetworkError> in + // 응답 로깅 + if let responseString = String(data: data, encoding: .utf8) { + print("Raw API Response: \(responseString)") + } + + // 수동 파싱 시도 + return self.parseRouteInfoManually(from: data) + .catch { error -> AnyPublisher<[BusRouteInfo], NetworkError> in + // 첫 번째 시도 실패 시 2자리 코드로 재시도 (5자리인 경우) + if cityCode.count > 2, let prefixCode = cityCode.prefix(2).int { + print("5자리 코드 실패, 2자리 코드로 재시도: \(prefixCode)") + return self.tryAlternativeRequest(routeName: routeName, cityCode: String(prefixCode)) + } + // 두 번째 시도: 다른 parameter 이름으로 시도 + return self.tryAlternativeRequest(routeName: routeName, cityCode: cityCode) + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + // 대체 요청 시도 + private func tryAlternativeRequest(routeName: String, cityCode: String) -> AnyPublisher<[BusRouteInfo], NetworkError> { + let encodedRouteName = routeName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? routeName + + // 다른 파라미터 이름으로 시도 (cityCode -> areaCode) + let urlString = "\(Constants.API.baseURL)\(Constants.API.busRoute)?serviceKey=\(Constants.apiKey)&areaCode=\(cityCode)&routeNo=\(encodedRouteName)&numOfRows=100&_type=json" + + print("대체 API 요청: \(urlString)") + + guard let url = URL(string: urlString) else { + return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher() + } + + return URLSession.shared.dataTaskPublisher(for: url) + .map(\.data) + .mapError { NetworkError.networkFailure($0) } + .flatMap { data -> AnyPublisher<[BusRouteInfo], NetworkError> in + if let responseString = String(data: data, encoding: .utf8) { + print("대체 API 응답: \(responseString)") + } + return self.parseRouteInfoManually(from: data) + } + .eraseToAnyPublisher() + } + + // 수동 JSON 파싱 (최대한 유연하게) + private func parseRouteInfoManually(from data: Data) -> AnyPublisher<[BusRouteInfo], NetworkError> { + do { + // 1. JSON 파싱 + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return Fail(error: NetworkError.invalidData).eraseToAnyPublisher() + } + + print("JSON 구조: \(json.keys)") + + // 2. 표준 응답 구조 시도 ("response" > "body" > "items" > "item") + if let response = json["response"] as? [String: Any], + let body = response["body"] as? [String: Any], + let items = body["items"] as? [String: Any], + let itemArray = items["item"] as? [[String: Any]] { + + let routes = itemArray.compactMap { self.createBusRouteInfo(from: $0) } + print("파싱 성공: \(routes.count)개 버스 노선") + return Just(routes).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } + + // 3. 대체 응답 구조 시도 #1 ("result" > "data" > "list") + if let result = json["result"] as? [String: Any], + let data = result["data"] as? [String: Any], + let list = data["list"] as? [[String: Any]] { + + let routes = list.compactMap { self.createBusRouteInfo(from: $0) } + print("대체 파싱 #1 성공: \(routes.count)개 버스 노선") + return Just(routes).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } + + // 4. 대체 응답 구조 시도 #2 (직접 "items" > "item") + if let items = json["items"] as? [String: Any], + let itemArray = items["item"] as? [[String: Any]] { + + let routes = itemArray.compactMap { self.createBusRouteInfo(from: $0) } + print("대체 파싱 #2 성공: \(routes.count)개 버스 노선") + return Just(routes).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } + + // 5. 대체 응답 구조 시도 #3 (직접 "item" 배열) + if let itemArray = json["item"] as? [[String: Any]] { + let routes = itemArray.compactMap { self.createBusRouteInfo(from: $0) } + print("대체 파싱 #3 성공: \(routes.count)개 버스 노선") + return Just(routes).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } + + // 6. XML 응답 확인 + if let dataString = String(data: data, encoding: .utf8), dataString.hasPrefix("<") { + print("XML 응답이 감지되었습니다. JSON이 아닙니다.") + return Fail(error: NetworkError.invalidData).eraseToAnyPublisher() + } + + print("알 수 없는 JSON 구조: \(json)") + return Fail(error: NetworkError.invalidData).eraseToAnyPublisher() + + } catch { + print("JSON 파싱 오류: \(error)") + return Fail(error: NetworkError.decodingError(error)).eraseToAnyPublisher() + } + } + + // Dictionary에서 BusRouteInfo 생성 (최대한 안전하게) + private func createBusRouteInfo(from dict: [String: Any]) -> BusRouteInfo? { + // 필수 필드가 없을 경우 기본값으로 대체 + + // routeId (routeid 또는 ROUTE_ID 등 다양한 키를 시도) + let routeId = findStringValue(in: dict, candidates: ["routeid", "ROUTE_ID", "routeId", "route_id"]) ?? "알 수 없음" + + // routeName + let routeName = findStringValue(in: dict, candidates: ["routeno", "ROUTE_NO", "routeName", "route_name", "routeNm"]) ?? "알 수 없음" + + // routeType + let routeTypeName = findStringValue(in: dict, candidates: ["routetp", "ROUTE_TP", "routeType", "route_type", "routeTp"]) ?? "일반" + + // 출발/도착지 + let startStationName = findStringValue(in: dict, candidates: ["startnodenm", "START_NODE_NM", "startNode", "start_node", "stStaNm"]) ?? "알 수 없음" + let endStationName = findStringValue(in: dict, candidates: ["endnodenm", "END_NODE_NM", "endNode", "end_node", "edStaNm"]) ?? "알 수 없음" + + // 첫차/막차 + let firstBusTime = findStringValue(in: dict, candidates: ["startvehicletime", "START_VEHICLE_TIME", "firstBusTime", "first_bus_time", "firstTime"]) ?? "알 수 없음" + let lastBusTime = findStringValue(in: dict, candidates: ["endvehicletime", "END_VEHICLE_TIME", "lastBusTime", "last_bus_time", "lastTime"]) ?? "알 수 없음" + + return BusRouteInfo( + routeId: routeId, + routeName: routeName, + routeTypeName: routeTypeName, + startStationName: startStationName, + endStationName: endStationName, + firstBusTime: firstBusTime, + lastBusTime: lastBusTime + ) + } + + // 여러 후보 키에서 문자열 값 찾기 + private func findStringValue(in dict: [String: Any], candidates: [String]) -> String? { + for key in candidates { + // 문자열인 경우 + if let value = dict[key] as? String { + return value + } + + // 숫자인 경우 문자열로 변환 + if let intValue = dict[key] as? Int { + return String(intValue) + } + + if let doubleValue = dict[key] as? Double { + return String(doubleValue) + } + + // 불리언인 경우 문자열로 변환 + if let boolValue = dict[key] as? Bool { + return boolValue ? "true" : "false" + } + } + + return nil + } +} + +// MARK: - 유니버설 버스 API 파서 +extension BusAPIService { + // 유니버설 버스 정보 검색 메서드 + func universalBusRouteSearch(routeName: String, cityCode: String) -> AnyPublisher<[BusRouteInfo], NetworkError> { + let encodedRouteName = routeName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? routeName + + // 기본 URL 및 파라미터 구성 + let baseUrl = Constants.API.baseURL + let apiKey = Constants.apiKey + + // 기본 요청 URL (광역시/도) + let urlString = "\(baseUrl)\(Constants.API.busRoute)?serviceKey=\(apiKey)&cityCode=\(cityCode)&routeNo=\(encodedRouteName)&numOfRows=100&_type=json" + + // 데이터 요청 + guard let url = URL(string: urlString) else { + return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher() + } + + print("유니버설 API 요청: \(urlString)") + + return URLSession.shared.dataTaskPublisher(for: url) + .map(\.data) + .mapError { NetworkError.networkFailure($0) } + .flatMap { data -> AnyPublisher<[BusRouteInfo], NetworkError> in + // 디버깅을 위해 원본 응답 로그 + if let jsonString = String(data: data, encoding: .utf8) { + print("유니버설 API 응답: \(jsonString.prefix(500))...") + } + + // 기존 파서 시도 + return self.parseWithUniversalParser(data: data) + .catch { error -> AnyPublisher<[BusRouteInfo], NetworkError> in + // 첫 번째 시도 실패 시, 다른 파라미터로 시도 + let alternativeParams: [(name: String, value: String)] = [ + // 5글자인 경우 2글자로 변환하여 cityCode 파라미터 사용 + ("cityCode", cityCode.count > 2 ? String(cityCode.prefix(2)) : cityCode), + // 원래 코드 그대로 areaCode 파라미터 사용 + ("areaCode", cityCode), + // 다른 이름의 파라미터 시도 + ("districtCd", cityCode), + ("admCd", cityCode), + ("nodeId", cityCode) + ] + + // 대체 API 경로 + let alternativePaths = [ + Constants.API.busRoute, + "/BusRouteInfoInqireService/getRouteNoList", + "/BusRouteInfoInqireService/getCtyCodeList" + ] + + // 모든 조합 시도 + return self.tryAllCombinations( + routeName: routeName, + params: alternativeParams, + paths: alternativePaths + ) + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + // 여러 파라미터와 경로 조합 시도 + private func tryAllCombinations( + routeName: String, + params: [(name: String, value: String)], + paths: [String] + ) -> AnyPublisher<[BusRouteInfo], NetworkError> { + // 첫 번째 조합 시도 + guard let firstParam = params.first else { + return Just([]).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } + + let encodedRouteName = routeName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? routeName + let path = paths.first ?? Constants.API.busRoute + + let urlString = "\(Constants.API.baseURL)\(path)?serviceKey=\(Constants.apiKey)&\(firstParam.name)=\(firstParam.value)&routeNo=\(encodedRouteName)&numOfRows=100&_type=json" + + print("대체 조합 시도: \(urlString)") + + return URLSession.shared.dataTaskPublisher(for: URL(string: urlString)!) + .map(\.data) + .mapError { NetworkError.networkFailure($0) } + .flatMap { data -> AnyPublisher<[BusRouteInfo], NetworkError> in + return self.parseWithUniversalParser(data: data) + .catch { _ -> AnyPublisher<[BusRouteInfo], NetworkError> in + // 남은 조합이 있으면 재귀적으로 시도 + if params.count > 1 || paths.count > 1 { + var remainingParams = params + var remainingPaths = paths + + if params.count > 1 { + remainingParams.removeFirst() + } + + if paths.count > 1 && params.count <= 1 { + remainingPaths.removeFirst() + } + + return self.tryAllCombinations( + routeName: routeName, + params: remainingParams, + paths: remainingPaths + ) + } + + return Just([]).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + // 유니버설 파서 - 다양한 JSON 구조 처리 + private func parseWithUniversalParser(data: Data) -> AnyPublisher<[BusRouteInfo], NetworkError> { + // 먼저 원본 데이터 확인 (JSON 형식 확인) + guard let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return Fail(error: NetworkError.invalidData).eraseToAnyPublisher() + } + + print("유니버설 파서 - JSON 키: \(jsonObject.keys)") + + // 1. 표준 TAGO 형식 시도 (response > body > items > item) + if let response = jsonObject["response"] as? [String: Any], + let body = response["body"] as? [String: Any], + let items = body["items"] as? [String: Any], + let itemArray = items["item"] as? [[String: Any]] { + + let routes = self.parseItemsToRouteInfo(items: itemArray) + if !routes.isEmpty { + print("표준 TAGO 형식으로 \(routes.count)개 항목 파싱 성공") + return Just(routes).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } + } + + // 2. 대체 형식 시도 (result > data > list) + if let result = jsonObject["result"] as? [String: Any], + let data = result["data"] as? [String: Any], + let list = data["list"] as? [[String: Any]] { + + let routes = self.parseItemsToRouteInfo(items: list) + if !routes.isEmpty { + print("대체 형식(result>data>list)으로 \(routes.count)개 항목 파싱 성공") + return Just(routes).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } + } + + // 3. 직접 항목 배열 시도 (items > item) + if let items = jsonObject["items"] as? [String: Any], + let itemArray = items["item"] as? [[String: Any]] { + + let routes = self.parseItemsToRouteInfo(items: itemArray) + if !routes.isEmpty { + print("직접 항목 배열로 \(routes.count)개 항목 파싱 성공") + return Just(routes).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } + } + + // 4. 루트 수준 itemList 시도 + if let itemList = jsonObject["itemList"] as? [[String: Any]] { + let routes = self.parseItemsToRouteInfo(items: itemList) + if !routes.isEmpty { + print("루트 itemList로 \(routes.count)개 항목 파싱 성공") + return Just(routes).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } + } + + // 5. msgBody > itemList 시도 (특수 API 형식) + if let msgBody = jsonObject["msgBody"] as? [String: Any], + let itemList = msgBody["itemList"] as? [[String: Any]] { + + let routes = self.parseItemsToRouteInfo(items: itemList) + if !routes.isEmpty { + print("msgBody > itemList로 \(routes.count)개 항목 파싱 성공") + return Just(routes).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } + } + + // 6. 단일 항목 시도 (items > item이 배열이 아닌 객체인 경우) + if let items = jsonObject["items"] as? [String: Any], + let item = items["item"] as? [String: Any] { + + if let route = self.createBusRouteInfo(from: item) { + print("단일 항목 파싱 성공") + return Just([route]).setFailureType(to: NetworkError.self).eraseToAnyPublisher() + } + } + + // 7. 기타 가능한 구조 탐색 (데이터 구조 로깅 후 개발자가 분석) + print("알 수 없는 JSON 구조: \(jsonObject.keys)") + return Fail(error: NetworkError.invalidData).eraseToAnyPublisher() + } + + // 다양한 필드 이름을 가진 항목 배열에서 BusRouteInfo 배열 생성 + private func parseItemsToRouteInfo(items: [[String: Any]]) -> [BusRouteInfo] { + var result: [BusRouteInfo] = [] + + for item in items { + if let route = self.createBusRouteInfo(from: item) { + result.append(route) + } + } + + return result + } +} diff --git a/HotCha/HotCha/Utils/Constants/Constants.swift b/HotCha/HotCha/Utils/Constants/Constants.swift new file mode 100644 index 0000000..763151e --- /dev/null +++ b/HotCha/HotCha/Utils/Constants/Constants.swift @@ -0,0 +1,29 @@ +// +// Constants.swift +// HotCha +// +// Created by 문호 on 3/14/25. +// + +struct Constants { + static let apiKey = "lRR0MF6ORcLObwo0%2B1E7kRXK4Jcol%2B7Tz%2FoB0%2FP2bIyexf%2BRGtBar7DAGbEwpHxIErYwRKsQMrbyew2XFV0bIg%3D%3D" + + struct API { + static let baseURL = "https://apis.data.go.kr/1613000" // HTTP에서 HTTPS로 변경 + + // 버스 도착 정보 API + static let busArrival = "/ArvlInfoInqireService/getSttnAcctoArvlPrearngeInfoList" + + // 버스 정류소 정보 API + static let busStop = "/BusSttnInfoInqireService/getSttnNoList" + + // 버스 위치 정보 API + static let busLocation = "/BusLcInfoInqireService/getRouteAcctoBusLcList" + + // 버스 노선 정보 API + static let busRoute = "/BusRouteInfoInqireService/getRouteNoList" + + // 버스 노선별 정류소 목록 API + static let routeStations = "/BusRouteInfoInqireService/getRouteAcctoThrghSttnList" + } +} diff --git a/HotCha/HotCha/ViewModels/BusSearchViewModel.swift b/HotCha/HotCha/ViewModels/BusSearchViewModel.swift new file mode 100644 index 0000000..7013871 --- /dev/null +++ b/HotCha/HotCha/ViewModels/BusSearchViewModel.swift @@ -0,0 +1,131 @@ +// +// BusSearchViewModel.swift +// HotCha +// +// Created by 문호 on 3/14/25. +// + +import Foundation +import Combine + +// BusSearchViewModel.swift - 통합 검색 ViewModel +class BusSearchViewModel: ObservableObject { + @Published var cityCode: String = "" + @Published var busNumber: String = "" + @Published var selectedRoute: BusRouteInfo? + @Published var isLoading: Bool = false + @Published var errorMessage: String? + + @Published var routeList: [BusRouteInfo] = [] + @Published var selectedRouteStations: [RouteStationInfo] = [] + @Published var busLocations: [BusLocationInfo] = [] + + private var cancellables = Set() + private let busAPIService = BusAPIService.shared + + // 도시 코드 참고 + // 세종:12, 부산:21, 대구:22, 인천:23, 광주:24, 대전:25, 울산:26, 제주도:39 + + // 버스 번호로 노선 검색 + func searchBusRoute() { + guard !busNumber.isEmpty else { + self.errorMessage = "버스 번호를 입력해주세요." + return + } + + self.isLoading = true + self.errorMessage = nil + self.routeList = [] // 기존 결과 초기화 + + // 유니버설 파서 사용 (도시코드 길이 상관없이) + busAPIService.universalBusRouteSearch(routeName: busNumber, cityCode: cityCode) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + self?.isLoading = false + + if case .failure(let error) = completion { + self?.errorMessage = "데이터를 가져올 수 없습니다: \(error.description)" + } + } receiveValue: { [weak self] routeList in + self?.routeList = routeList + + if routeList.isEmpty { + self?.errorMessage = "검색 결과가 없습니다. 다른 버스 번호나 지역 코드를 선택해 보세요." + } + } + .store(in: &cancellables) + } + + // 선택된 노선에 대한 정보 가져오기 + func selectRoute(_ route: BusRouteInfo) { + self.selectedRoute = route + self.fetchRouteStations(routeId: route.routeId) + self.fetchBusLocations(routeId: route.routeId) + } + + // 노선의 정류소 목록 가져오기 + private func fetchRouteStations(routeId: String) { + self.isLoading = true + + busAPIService.getRouteStationList(routeId: routeId, cityCode: cityCode) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + if case .failure(let error) = completion { + self?.errorMessage = error.description + } + self?.isLoading = false + } receiveValue: { [weak self] stations in + self?.selectedRouteStations = stations + } + .store(in: &cancellables) + } + + // 노선의 버스 위치 정보 가져오기 + private func fetchBusLocations(routeId: String) { + self.isLoading = true + + busAPIService.getBusLocationInfo(routeId: routeId, cityCode: cityCode) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + if case .failure(let error) = completion { + self?.errorMessage = error.description + } + self?.isLoading = false + } receiveValue: { [weak self] locations in + self?.busLocations = locations + } + .store(in: &cancellables) + } + + // 버스 위치 주기적으로 업데이트 (30초마다) + func startLocationUpdates() { + guard let routeId = selectedRoute?.routeId else { return } + + Timer.publish(every: 30, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.fetchBusLocations(routeId: routeId) + } + .store(in: &cancellables) + } + + // 특정 정류소의 버스 도착 정보 가져오기 + func getBusArrivalInfo(stationId: String, completion: @escaping ([BusArrivalInfo]?, NetworkError?) -> Void) { + busAPIService.getBusArrivalInfo(stationId: stationId, cityCode: cityCode) + .receive(on: DispatchQueue.main) + .sink { completionStatus in + if case .failure(let error) = completionStatus { + completion(nil, error) + } + } receiveValue: { arrivalInfo in + completion(arrivalInfo, nil) + } + .store(in: &cancellables) + } + + // Cancellables 정리 + func cancelAllRequests() { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } +} diff --git a/HotCha/HotCha/ViewModels/BusStopViewModel.swift b/HotCha/HotCha/ViewModels/BusStopViewModel.swift new file mode 100644 index 0000000..1928e76 --- /dev/null +++ b/HotCha/HotCha/ViewModels/BusStopViewModel.swift @@ -0,0 +1,72 @@ +// +// BusStopViewModel.swift +// HotCha +// +// Created by 문호 on 3/14/25. +// + +import Foundation +import Combine + +class BusStopViewModel: ObservableObject { + @Published var cityCode: String = "" + @Published var stationName: String = "" + @Published var isLoading: Bool = false + @Published var errorMessage: String? + @Published var busStops: [BusStopInfo] = [] + + private var cancellables = Set() + private let busAPIService = BusAPIService.shared + + // 정류소 이름으로 검색 + func searchBusStop() { + guard !stationName.isEmpty else { + self.errorMessage = "정류소 이름을 입력해주세요." + return + } + + self.isLoading = true + self.errorMessage = nil + self.busStops = [] // 기존 결과 초기화 + + busAPIService.getBusStopInfo(stationName: stationName, cityCode: cityCode) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + self?.isLoading = false + + if case .failure(let error) = completion { + if case NetworkError.emptyResponse = error { + self?.errorMessage = "검색 결과가 없습니다. 다른 정류소 이름이나 도시를 선택해 보세요." + } else { + self?.errorMessage = error.description + } + } + } receiveValue: { [weak self] busStops in + self?.busStops = busStops + + if busStops.isEmpty { + self?.errorMessage = "검색 결과가 없습니다. 다른 정류소 이름이나 도시를 선택해 보세요." + } + } + .store(in: &cancellables) + } + + // 정류소 ID로 버스 도착 정보 조회 + func getBusArrivalInfo(stationId: String, completion: @escaping ([BusArrivalInfo]?, NetworkError?) -> Void) { + busAPIService.getBusArrivalInfo(stationId: stationId, cityCode: cityCode) + .receive(on: DispatchQueue.main) + .sink { completionStatus in + if case .failure(let error) = completionStatus { + completion(nil, error) + } + } receiveValue: { arrivalInfo in + completion(arrivalInfo, nil) + } + .store(in: &cancellables) + } + + func cancelAllRequests() { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } +} diff --git a/HotCha/HotCha/Views/TestView/TestView.swift b/HotCha/HotCha/Views/TestView/TestView.swift new file mode 100644 index 0000000..5e22881 --- /dev/null +++ b/HotCha/HotCha/Views/TestView/TestView.swift @@ -0,0 +1,304 @@ +// +// TestView.swift +// APITest3 +// +// Created by 문호 on 3/14/25. +// + +import SwiftUI +import Combine + +struct TestView: View { + @StateObject private var viewModel = BusSearchViewModel() + + @State private var cityCode: String = "" + @State private var selectedRouteId: String? = nil + @State private var showStations: Bool = false + + // 키보드 상태 관리를 위한 변수 + @FocusState private var isCityCodeFocused: Bool + @FocusState private var isBusNumberFocused: Bool + + var body: some View { + VStack(spacing: 0) { + // 제목 + Text("버스 정보 검색") + .font(.title) + .fontWeight(.bold) + .padding() + + // 지역코드 및 버스 번호 입력 영역 + VStack(spacing: 16) { + HStack { + Text("지역코드:") + .font(.headline) + + TextField("지역코드 입력", text: $cityCode) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .keyboardType(.numberPad) + .frame(width: 100) + .focused($isCityCodeFocused) + + Text("(예: 25=대전, 21=부산)") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + // 키보드 해제 버튼 (지역코드 필드에 포커스가 있을 때만 표시) + if isCityCodeFocused { + Button("완료") { + isCityCodeFocused = false + } + .foregroundColor(.blue) + } + } + + HStack { + Text("버스 번호:") + .font(.headline) + + TextField("버스 번호 입력", text: $viewModel.busNumber) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .keyboardType(.numberPad) + .focused($isBusNumberFocused) + + // 키보드 해제 버튼 (버스 번호 필드에 포커스가 있을 때만 표시) + if isBusNumberFocused { + Button("완료") { + isBusNumberFocused = false + } + .foregroundColor(.blue) + } + + Button("검색") { + // 검색 시 키보드 해제 + isCityCodeFocused = false + isBusNumberFocused = false + + viewModel.cityCode = cityCode + viewModel.searchBusRoute() + selectedRouteId = nil + showStations = false + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + .padding(.horizontal) + + // 검색 결과 영역에 탭 제스처 추가하여 키보드 해제 + if showStations, let selectedRoute = viewModel.routeList.first(where: { $0.routeId == selectedRouteId }) { + stationListView(for: selectedRoute) + .onTapGesture { + isCityCodeFocused = false + isBusNumberFocused = false + } + } else { + busSearchResultView + .onTapGesture { + isCityCodeFocused = false + isBusNumberFocused = false + } + } + + Spacer() + } + // 전체 화면에 탭 제스처 추가 + .contentShape(Rectangle()) + .onTapGesture { + isCityCodeFocused = false + isBusNumberFocused = false + } + .onDisappear { + viewModel.cancelAllRequests() + } + } + + // 버스 검색 결과 뷰 + var busSearchResultView: some View { + Group { + if viewModel.isLoading { + ProgressView("로딩 중...") + .padding() + } else if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .foregroundColor(.red) + .padding() + } else if !viewModel.routeList.isEmpty { + // 노선 목록 + VStack { + Text("검색 결과: \(viewModel.routeList.count)개") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.top, 8) + + ScrollView { + LazyVStack(spacing: 8) { + ForEach(viewModel.routeList) { route in + busRouteCard(route) + .onTapGesture { + // 탭 시 키보드 해제 후 노선 선택 + isCityCodeFocused = false + isBusNumberFocused = false + + selectedRouteId = route.routeId + viewModel.selectRoute(route) + showStations = true + } + } + } + .padding(.horizontal) + } + } + } else if viewModel.busNumber.isEmpty { + // 초기 상태 안내 + VStack { + Image(systemName: "bus.fill") + .font(.system(size: 50)) + .foregroundColor(.blue) + .padding() + + Text("지역코드와 버스 번호를 입력하고 검색 버튼을 눌러주세요.") + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + + Text("지역코드 예시: 25=대전, 21=부산, 22=대구, 23=인천, 24=광주") + .font(.caption) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding(.top, 8) + + Text("26=울산, 12=세종, 31=경기, 32=강원, 33=충북, 34=충남") + .font(.caption) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding(.top, 2) + } + .padding() + } else { + // 검색 결과 없음 + Text("검색 결과가 없습니다.") + .foregroundColor(.secondary) + .padding() + } + } + } + + // 버스 노선 카드 뷰 + func busRouteCard(_ route: BusRouteInfo) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text("버스: \(route.routeName)") + .font(.headline) + + Text("유형: \(route.routeTypeName)") + .font(.subheadline) + + Text("\(route.startStationName) → \(route.endStationName)") + .font(.subheadline) + .foregroundColor(.secondary) + + Text("첫차: \(route.firstBusTime), 막차: \(route.lastBusTime)") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemBackground)) + .cornerRadius(8) + .shadow(color: Color(.systemGray4), radius: 2, x: 0, y: 1) + } + + // 정류장 목록 뷰 + func stationListView(for selectedRoute: BusRouteInfo) -> some View { + VStack(alignment: .leading, spacing: 16) { + // 노선 기본 정보 + VStack(alignment: .leading, spacing: 4) { + Text("버스: \(selectedRoute.routeName)") + .font(.title2) + .fontWeight(.bold) + + Text("유형: \(selectedRoute.routeTypeName)") + + Text("\(selectedRoute.startStationName) → \(selectedRoute.endStationName)") + + Text("첫차: \(selectedRoute.firstBusTime), 막차: \(selectedRoute.lastBusTime)") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemGray6)) + .cornerRadius(8) + .padding(.horizontal) + + // 정류장 목록 + Text("정류장 목록") + .font(.headline) + .padding(.horizontal) + + if viewModel.isLoading { + ProgressView("정류장 정보 로딩 중...") + .padding() + } else if viewModel.selectedRouteStations.isEmpty { + Text("정류장 정보가 없습니다.") + .foregroundColor(.secondary) + .padding() + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(viewModel.selectedRouteStations) { station in + VStack(alignment: .leading, spacing: 4) { + Text("\(station.stationName)") + .font(.headline) + + Text("정류장 번호: \(station.stationId)") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemBackground)) + .cornerRadius(8) + } + } + .padding(.horizontal) + } + } + + // 뒤로 가기 버튼 + Button("버스 목록으로 돌아가기") { + showStations = false + selectedRouteId = nil + } + .padding() + .frame(maxWidth: .infinity) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + .padding(.horizontal) + } + } +} + +// iOS 13/14 호환성을 위한 키보드 해제 확장 +#if canImport(UIKit) +extension View { + func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} +#endif + +struct TestView_Previews: PreviewProvider { + static var previews: some View { + TestView() + } +}