From 41ace7d35eafb298fa392e7fa62de9a52bf22962 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 18 Mar 2026 07:35:27 +0900 Subject: [PATCH 01/30] =?UTF-8?q?[BUGFIX]=20=EC=95=8C=EB=9E=8C=20=EC=9E=AC?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=8B=9C=20=EC=9D=B4=EC=A0=84=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EA=B0=80=20=EB=82=A8=EB=8A=94=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserDefaults/UserDefaultsWrapper.swift | 9 ++++++++ .../BusRealTimeInfoRequest.swift | 2 +- Atcha-iOS/Domain/Entity/Course.swift | 12 +++++------ .../DetailRoute/DetailRouteViewModel.swift | 21 +++++++++++++++++++ .../Presentation/Location/MainViewModel.swift | 18 +++++++++------- 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/Atcha-iOS/Core/Storages/UserDefaults/UserDefaultsWrapper.swift b/Atcha-iOS/Core/Storages/UserDefaults/UserDefaultsWrapper.swift index 5bf947a0..880419c9 100644 --- a/Atcha-iOS/Core/Storages/UserDefaults/UserDefaultsWrapper.swift +++ b/Atcha-iOS/Core/Storages/UserDefaults/UserDefaultsWrapper.swift @@ -6,6 +6,7 @@ // import Foundation +import Combine final public class UserDefaultsWrapper { private init() {} @@ -13,6 +14,10 @@ final public class UserDefaultsWrapper { private let userDefaults: UserDefaults = .standard + private let legInfoSubject = PassthroughSubject() + var legInfoPublisher: AnyPublisher { + legInfoSubject.eraseToAnyPublisher() + } // MARK: - 저장 public func set(_ value: Int, forKey key: String) { @@ -43,6 +48,10 @@ final public class UserDefaultsWrapper { if let data = try? JSONEncoder().encode(value) { userDefaults.set(data, forKey: key) } + + if key == Key.legInfo.rawValue, let info = value as? LegInfo { + legInfoSubject.send(info) + } } diff --git a/Atcha-iOS/Data/Model/BusInfoDTO/BusRealTimeInfo/BusRealTimeInfoRequest.swift b/Atcha-iOS/Data/Model/BusInfoDTO/BusRealTimeInfo/BusRealTimeInfoRequest.swift index 3c9bf569..cb8c85c7 100644 --- a/Atcha-iOS/Data/Model/BusInfoDTO/BusRealTimeInfo/BusRealTimeInfoRequest.swift +++ b/Atcha-iOS/Data/Model/BusInfoDTO/BusRealTimeInfo/BusRealTimeInfoRequest.swift @@ -15,7 +15,7 @@ struct BusRealTimeInfoRequest: Codable { let passStations: [PassStations]? } -struct PassStations: Codable { +struct PassStations: Codable, Equatable { let index: Int? let stationName: String? let lat: String? diff --git a/Atcha-iOS/Domain/Entity/Course.swift b/Atcha-iOS/Domain/Entity/Course.swift index ba4c07dd..f258a75d 100644 --- a/Atcha-iOS/Domain/Entity/Course.swift +++ b/Atcha-iOS/Domain/Entity/Course.swift @@ -128,13 +128,13 @@ struct Legs: Codable, Hashable { } } -struct LegInfo: Codable { +struct LegInfo: Codable, Equatable { let pathInfo: [LegPathInfo] let trafficInfo: [LegTrafficInfo] let busInfo: [BusDetailInfo] } -struct LegTrafficInfo: Hashable, Codable { +struct LegTrafficInfo: Hashable, Codable, Equatable { var id: UUID = UUID() let distance: Int? let departureDateTime: String? @@ -258,7 +258,7 @@ extension AddressInfo { } } -struct PassStopList: Codable, Hashable { +struct PassStopList: Codable, Hashable, Equatable { let index: Int? let stationName: String? let lon: String? @@ -272,13 +272,13 @@ struct Step: Codable, Hashable{ let linestring: String? } -struct TargetBusStation: Codable, Hashable { +struct TargetBusStation: Codable, Hashable, Equatable { let busStationId: String? let busStationNumber: String? let busStationName: String? } -enum TransportMode: String, Codable { +enum TransportMode: String, Codable, Equatable { case walk = "WALK" case bus = "BUS" case subway = "SUBWAY" @@ -353,7 +353,7 @@ struct LegPathInfo: Codable, Equatable { let passShape: String? } -struct BusDetailInfo: Codable { +struct BusDetailInfo: Codable, Equatable { let routeName: String? let start: AddressInfo? let passStations: [PassStations]? diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index f1d25b8a..1e518121 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift @@ -87,6 +87,27 @@ final class DetailRouteViewModel: BaseViewModel { super.init() self.fetchInfo() self.requestPermissionAndStartTracking() + bindUserDefaults() + } + + private func bindUserDefaults() { + UserDefaultsWrapper.shared.legInfoPublisher + .compactMap { $0 } // nil이 아닐 때만 + .receive(on: RunLoop.main) + .sink { [weak self] newInfo in + print("새로운 경로 정보 감지됨: UI 업데이트 시작") + self?.updateWithNewInfo(newInfo) + } + .store(in: &cancellables) + } + + private func updateWithNewInfo(_ info: LegInfo) { + // 1. 데이터 갱신 + self.legtPathInfo = info.pathInfo + self.legTrafficInfo = info.trafficInfo + + // 2. 경로선 다시 그리기 위해 딕셔너리 갱신 로직 등 실행 + self.fetchInfo() } func fetchInfo() { diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index afe1fcbd..1a354194 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -111,13 +111,17 @@ final class MainViewModel: BaseViewModel{ } .store(in: &cancellables) - // $address - // .compactMap { $0 } - // .removeDuplicates() - // .sink { [weak self] _ in - // Task { await self?.refreshRegionAndFareForCurrentAddress() } - // } - // .store(in: &cancellables) + UserDefaultsWrapper.shared.legInfoPublisher + .compactMap { $0 } + .receive(on: RunLoop.main) + .sink { [weak self] newInfo in + guard let self = self else { return } + + if self.legInfo != newInfo { + self.drawRoute(address: self.addressDesc, info: newInfo) + } + } + .store(in: &cancellables) } private func updateAddressOnly(for location: CLLocationCoordinate2D) async { From b2846c7f422ddc216b25a52b10f42090d3c5bd93 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 18 Mar 2026 07:39:09 +0900 Subject: [PATCH 02/30] =?UTF-8?q?[FEAT]=20=EC=84=9C=EB=B2=84=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=9A=A9=20=ED=8C=9D=EC=97=85=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift | 7 +++++-- .../Presentation/Popup/AtchaPopupViewController.swift | 5 ++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift b/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift index 44747d58..50d350f5 100644 --- a/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift +++ b/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift @@ -16,6 +16,7 @@ enum AtcahPopuInfo { case announeExit case alarmTimeout case arrive + case serverError var title: String { switch self { @@ -27,6 +28,7 @@ enum AtcahPopuInfo { case .announeExit: return "" case .alarmTimeout: return "예정된 출발 시간이 지나\n알람이 자동으로 종료됐어요" case .arrive: return "목적지 부근에 도착해\n안내를 종료합니다" + case .serverError: return "잠시 후 다시 시도해주세요\n앗차팀에서 확인 및 대응 중입니다" } } @@ -40,20 +42,21 @@ enum AtcahPopuInfo { case .announeExit: return "확인" case .alarmTimeout: return "닫기" case .arrive: return "확인" + case .serverError: return "확인" } } var confrimBackgroundColor: UIColor { switch self { case .alarm, .re_register, .course, .arrive: return .main - case .alarmTimeout: return .gray910 + case .alarmTimeout, .serverError: return .gray910 default: return .white } } var confrimForegroundColor: UIColor { switch self { - case .alarmTimeout: return .white + case .alarmTimeout, .serverError: return .white default: return .black } } diff --git a/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift b/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift index 33fef770..cb16df7d 100644 --- a/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift +++ b/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift @@ -80,8 +80,7 @@ final class AtchaPopupViewController: BaseViewController { confirmButton.setAttributedTitle(confirmAttr, for: .normal) confirmButton.backgroundColor = info.confrimBackgroundColor - // alarmTimeout에서는 cancel이 없으니, cancel 세팅은 조건부로 - if info != .alarmTimeout || info != .arrive { + if info != .alarmTimeout || info != .arrive || info != .serverError { let cancelAttr = AtchaFont.B5_SB_14(info.cancelTitle, color: info.cancelForegroundColor) cancelButton.setAttributedTitle(cancelAttr, for: .normal) cancelButton.backgroundColor = info.cancelBackgroundColor @@ -94,7 +93,7 @@ final class AtchaPopupViewController: BaseViewController { $0.removeFromSuperview() } - if info == .alarmTimeout || info == .arrive{ + if info == .alarmTimeout || info == .arrive || info == .serverError { buttonStackView.addArrangedSubview(confirmButton) } else { buttonStackView.addArrangedSubview(cancelButton) From 83f3c0e46366e0e1521a4efd5cf095dc771ab68f Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 18 Mar 2026 08:07:19 +0900 Subject: [PATCH 03/30] =?UTF-8?q?[FEAT]=20=EC=A0=84=EC=97=AD=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=ED=8C=9D=EC=97=85=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20=EC=98=B5=EC=A0=80=EB=B2=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Network/API/APIServiceImpl.swift | 30 ++++++++++------ .../Core/Notification/NotificationKeys.swift | 1 + .../Common/BaseViewController.swift | 36 +++++++++++++++++++ 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/Atcha-iOS/Core/Network/API/APIServiceImpl.swift b/Atcha-iOS/Core/Network/API/APIServiceImpl.swift index 75387370..9db8792b 100644 --- a/Atcha-iOS/Core/Network/API/APIServiceImpl.swift +++ b/Atcha-iOS/Core/Network/API/APIServiceImpl.swift @@ -15,17 +15,17 @@ private let insecureSession = Session(serverTrustManager: trustManager) final class APIServiceImpl: APIService { private let session: Session - + /// 기본 초기화 - SSL 우회 세션 사용 init(session: Session = insecureSession) { self.session = session } - + func request(_ endpoint: Endpoint) async throws -> T { guard let url = URL(string: NetworkConstant.baseURL + endpoint.path) else { throw APIError.invalidURL } - + return try await withCheckedThrowingContinuation { continuation in session.request(url, method: endpoint.method, parameters: endpoint.parameters, encoding: endpoint.encoding, headers: endpoint.headers) .validate() @@ -36,7 +36,7 @@ final class APIServiceImpl: APIService { continuation.resume(returning: APIEmptyResponse() as! T) return } - + switch response.result { case .success(let apiResponse): if apiResponse.responseCode == "SUCCESS" { @@ -48,10 +48,14 @@ final class APIServiceImpl: APIService { continuation.resume(throwing: APIError.noData) } } else { - continuation.resume(throwing: APIError.serverError(statusCode: response.response?.statusCode ?? -1)) + let error = APIError.serverError(statusCode: response.response?.statusCode ?? -1) + NotificationCenter.default.post(name: .apiErrorOccurred, object: error) + continuation.resume(throwing: error) } case .failure(let error): - continuation.resume(throwing: APIError.unknown(error: error)) + let apiError = APIError.unknown(error: error) + NotificationCenter.default.post(name: .apiErrorOccurred, object: apiError) + continuation.resume(throwing: apiError) } } } @@ -66,7 +70,7 @@ extension APIServiceImpl { guard let url = URL(string: NetworkConstant.baseURL + endpoint.path) else { throw APIError.invalidURL } - + return try await withCheckedThrowingContinuation { continuation in session.request( url, @@ -83,7 +87,7 @@ extension APIServiceImpl { continuation.resume(returning: APIEmptyResponse() as! T) return } - + switch response.result { case .success(let apiResponse): if apiResponse.responseCode == "SUCCESS" { @@ -95,11 +99,15 @@ extension APIServiceImpl { continuation.resume(throwing: APIError.noData) } } else { - continuation.resume(throwing: APIError.serverError(statusCode: response.response?.statusCode ?? -1)) + let error = APIError.serverError(statusCode: response.response?.statusCode ?? -1) + NotificationCenter.default.post(name: .apiErrorOccurred, object: error) + continuation.resume(throwing: error) } - + case .failure(let error): - continuation.resume(throwing: APIError.unknown(error: error)) + let apiError = APIError.unknown(error: error) + NotificationCenter.default.post(name: .apiErrorOccurred, object: apiError) + continuation.resume(throwing: apiError) } } } diff --git a/Atcha-iOS/Core/Notification/NotificationKeys.swift b/Atcha-iOS/Core/Notification/NotificationKeys.swift index b1ea7353..8dfc912b 100644 --- a/Atcha-iOS/Core/Notification/NotificationKeys.swift +++ b/Atcha-iOS/Core/Notification/NotificationKeys.swift @@ -11,4 +11,5 @@ extension Notification.Name { static let fcmDidReceiveRefresh = Notification.Name("fcmDidReceiveRefresh") static let refreshDidUpdate = Notification.Name("refreshDidUpdate") static let alarmPushTapped = Notification.Name("alarmPushTapped") + static let apiErrorOccurred = Notification.Name("apiErrorOccurred") } diff --git a/Atcha-iOS/Presentation/Common/BaseViewController.swift b/Atcha-iOS/Presentation/Common/BaseViewController.swift index ad6297bc..6f3211cf 100644 --- a/Atcha-iOS/Presentation/Common/BaseViewController.swift +++ b/Atcha-iOS/Presentation/Common/BaseViewController.swift @@ -45,6 +45,7 @@ class BaseViewController: UIViewController { setupBindings() setupKeyboardDismiss() observeNetwork() + setupErrorObserver() } override func viewDidAppear(_ animated: Bool) { @@ -210,6 +211,41 @@ class BaseViewController: UIViewController { reconnectView?.removeFromSuperview() reconnectView = nil } + + private func setupErrorObserver() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleServerError(_:)), + name: .apiErrorOccurred, + object: nil + ) + } + + @objc private func handleServerError(_ notification: Notification) { + // 최상단에 있는 뷰컨트롤러만 팝업을 띄우도록 보장 (중복 방지) + guard self.presentedViewController == nil else { return } + + DispatchQueue.main.async { [weak self] in + self?.showAtchaErrorPopup() + } + } + + private func showAtchaErrorPopup() { + // 이전에 만드신 앗차팝업 호출 (에러 케이스용) + let popupVM = AtchaPopupViewModel(info: .serverError) // Enum에 .serverError 추가 필요 + let popupVC = AtchaPopupViewController(viewModel: popupVM) + + popupVC.confirmButton.addAction(UIAction { [weak popupVC] _ in + popupVC?.dismiss(animated: false) + }, for: .touchUpInside) + + popupVC.modalPresentationStyle = .overFullScreen + self.present(popupVC, animated: false) + } + + deinit { + NotificationCenter.default.removeObserver(self, name: .apiErrorOccurred, object: nil) + } } extension BaseViewController { From 65da08db7c9585efade897b5e74878eeedbf8663 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 18 Mar 2026 08:15:38 +0900 Subject: [PATCH 04/30] =?UTF-8?q?[BUGFIX]=20=EB=B2=84=EC=A0=84=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Atcha-iOS.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Atcha-iOS.xcodeproj/project.pbxproj b/Atcha-iOS.xcodeproj/project.pbxproj index f3833992..0d6cc731 100644 --- a/Atcha-iOS.xcodeproj/project.pbxproj +++ b/Atcha-iOS.xcodeproj/project.pbxproj @@ -2511,7 +2511,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.9.1; PRODUCT_BUNDLE_IDENTIFIER = com.atcha.iOS; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2558,7 +2558,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.9.1; OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.atcha.iOS; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2606,7 +2606,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.9.1; PRODUCT_BUNDLE_IDENTIFIER = com.atcha.iOS; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From 472e4d57bf59584f12217a508418bcdd78b4c7b0 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 18 Mar 2026 08:40:47 +0900 Subject: [PATCH 05/30] =?UTF-8?q?[FEAT]=20=EC=84=9C=EB=B2=84=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=B0=9C=EC=83=9D=20=EC=8B=9C=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=94=94=EC=8A=A4=EC=BD=94=EB=93=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Atcha-iOS.xcodeproj/project.pbxproj | 34 +++++++++++++-- .../Manager/{ => Alarm}/AlarmManager.swift | 0 .../Discord/DiscordWebhookManager.swift | 43 +++++++++++++++++++ .../{ => Location}/HomeArrivalManager.swift | 0 .../{ => Location}/LocationSmoother.swift | 0 .../Common/BaseViewController.swift | 13 +++++- 6 files changed, 86 insertions(+), 4 deletions(-) rename Atcha-iOS/Core/Manager/{ => Alarm}/AlarmManager.swift (100%) create mode 100644 Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift rename Atcha-iOS/Core/Manager/{ => Location}/HomeArrivalManager.swift (100%) rename Atcha-iOS/Core/Manager/{ => Location}/LocationSmoother.swift (100%) diff --git a/Atcha-iOS.xcodeproj/project.pbxproj b/Atcha-iOS.xcodeproj/project.pbxproj index 0d6cc731..07f9f8cc 100644 --- a/Atcha-iOS.xcodeproj/project.pbxproj +++ b/Atcha-iOS.xcodeproj/project.pbxproj @@ -58,6 +58,7 @@ 6D5E03CA2E2882290065AFBE /* Course.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5E03C92E2882290065AFBE /* Course.swift */; }; 6D5E03CD2E2882BB0065AFBE /* CourseSearchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5E03CC2E2882BB0065AFBE /* CourseSearchRequest.swift */; }; 6D5E03D02E28853E0065AFBE /* CourseSearchResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5E03CF2E28853E0065AFBE /* CourseSearchResponse.swift */; }; + 6D6056C72F6A18BF0026FA9D /* DiscordWebhookManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6056C62F6A18BF0026FA9D /* DiscordWebhookManager.swift */; }; 6D61ABE32F57158000111C9B /* IntroViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D61ABE22F57158000111C9B /* IntroViewController.swift */; }; 6D61ABE52F57158700111C9B /* IntroViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D61ABE42F57158700111C9B /* IntroViewModel.swift */; }; 6D61ABE82F57174D00111C9B /* IntroDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D61ABE72F57174D00111C9B /* IntroDIContainer.swift */; }; @@ -394,6 +395,7 @@ 6D5E03C92E2882290065AFBE /* Course.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Course.swift; sourceTree = ""; }; 6D5E03CC2E2882BB0065AFBE /* CourseSearchRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseSearchRequest.swift; sourceTree = ""; }; 6D5E03CF2E28853E0065AFBE /* CourseSearchResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseSearchResponse.swift; sourceTree = ""; }; + 6D6056C62F6A18BF0026FA9D /* DiscordWebhookManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscordWebhookManager.swift; sourceTree = ""; }; 6D61ABE22F57158000111C9B /* IntroViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroViewController.swift; sourceTree = ""; }; 6D61ABE42F57158700111C9B /* IntroViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroViewModel.swift; sourceTree = ""; }; 6D61ABE72F57174D00111C9B /* IntroDIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroDIContainer.swift; sourceTree = ""; }; @@ -869,6 +871,31 @@ path = CourseSearchDTO; sourceTree = ""; }; + 6D6056C32F6A188F0026FA9D /* Discord */ = { + isa = PBXGroup; + children = ( + 6D6056C62F6A18BF0026FA9D /* DiscordWebhookManager.swift */, + ); + path = Discord; + sourceTree = ""; + }; + 6D6056C42F6A18960026FA9D /* Alarm */ = { + isa = PBXGroup; + children = ( + B61C448D2E3F57B600285A4B /* AlarmManager.swift */, + ); + path = Alarm; + sourceTree = ""; + }; + 6D6056C52F6A18A20026FA9D /* Location */ = { + isa = PBXGroup; + children = ( + 6DC617A62F60EB1A002DD641 /* LocationSmoother.swift */, + 6DC617A82F610238002DD641 /* HomeArrivalManager.swift */, + ); + path = Location; + sourceTree = ""; + }; 6D61ABE12F5714EF00111C9B /* Intro */ = { isa = PBXGroup; children = ( @@ -1199,12 +1226,12 @@ B61C448A2E3F57A900285A4B /* Manager */ = { isa = PBXGroup; children = ( + 6D6056C52F6A18A20026FA9D /* Location */, + 6D6056C42F6A18960026FA9D /* Alarm */, + 6D6056C32F6A188F0026FA9D /* Discord */, 6D26DFA62F289E58005097A4 /* HeadingManager */, 6DADA5952EA09B9500CA9BE2 /* Amplitude */, - B61C448D2E3F57B600285A4B /* AlarmManager.swift */, 6DD632B72E52E8D000C6A66E /* Proximity */, - 6DC617A62F60EB1A002DD641 /* LocationSmoother.swift */, - 6DC617A82F610238002DD641 /* HomeArrivalManager.swift */, ); path = Manager; sourceTree = ""; @@ -2276,6 +2303,7 @@ 6D368BB22E03BD2C00144EE7 /* SearchTextField.swift in Sources */, B65C12E02E042D260016D2F0 /* UserRepository.swift in Sources */, B6713CC02E129ABC00AF8486 /* RequestLocationAuthorizationRepositoryImpl.swift in Sources */, + 6D6056C72F6A18BF0026FA9D /* DiscordWebhookManager.swift in Sources */, 6D368BA82E02E92000144EE7 /* BackOnlyNavigationBar.swift in Sources */, 6D368BB42E03BE9A00144EE7 /* AtchaTextField.swift in Sources */, B673C48F2E0424F600EE4AD0 /* SplashViewController.swift in Sources */, diff --git a/Atcha-iOS/Core/Manager/AlarmManager.swift b/Atcha-iOS/Core/Manager/Alarm/AlarmManager.swift similarity index 100% rename from Atcha-iOS/Core/Manager/AlarmManager.swift rename to Atcha-iOS/Core/Manager/Alarm/AlarmManager.swift diff --git a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift new file mode 100644 index 00000000..5eedbb94 --- /dev/null +++ b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift @@ -0,0 +1,43 @@ +// +// DiscordWebhookManager.swift +// Atcha-iOS +// +// Created by wodnd on 3/18/26. +// + +import Foundation + +final class DiscordWebhookManager { + static let shared = DiscordWebhookManager() + private init() {} + + // 디스코드 채널 설정에서 만든 Webhook URL + private let webhookURLString = "https://discord.com/api/webhooks/1483605689031983336/gqPjN3OU9ciMCF5qgPodT_KV3fE1giuuD6M4ODCJdenNru8UHezuYZfWBfc4Vnj4GWIZ" + + func sendErrorLog(statusCode: Int, message: String) { + guard let url = URL(string: webhookURLString) else { return } + + // 디코가 좋아하는 JSON 형식 (Embed를 쓰면 더 예쁘게 나옵니다) + let payload: [String: Any] = [ + "content": "🚨 [Atcha-iOS] API 에러 발생!", + "embeds": [[ + "title": "서버 에러 상세 보고", + "color": 16711680, // 빨간색 + "fields": [ + ["name": "Status Code", "value": "\(statusCode)", "inline": true], + ["name": "App Version", "value": AppInfoProvider.currentVersion, "inline": true], + ["name": "Error Message", "value": message, "inline": false] + ], + "footer": ["text": "발생 시각: \(Date().description)"] + ]] + ] + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONSerialization.data(withJSONObject: payload) + + // 백그라운드에서 조용히 전송 + URLSession.shared.dataTask(with: request).resume() + } +} diff --git a/Atcha-iOS/Core/Manager/HomeArrivalManager.swift b/Atcha-iOS/Core/Manager/Location/HomeArrivalManager.swift similarity index 100% rename from Atcha-iOS/Core/Manager/HomeArrivalManager.swift rename to Atcha-iOS/Core/Manager/Location/HomeArrivalManager.swift diff --git a/Atcha-iOS/Core/Manager/LocationSmoother.swift b/Atcha-iOS/Core/Manager/Location/LocationSmoother.swift similarity index 100% rename from Atcha-iOS/Core/Manager/LocationSmoother.swift rename to Atcha-iOS/Core/Manager/Location/LocationSmoother.swift diff --git a/Atcha-iOS/Presentation/Common/BaseViewController.swift b/Atcha-iOS/Presentation/Common/BaseViewController.swift index 6f3211cf..5313066e 100644 --- a/Atcha-iOS/Presentation/Common/BaseViewController.swift +++ b/Atcha-iOS/Presentation/Common/BaseViewController.swift @@ -222,9 +222,20 @@ class BaseViewController: UIViewController { } @objc private func handleServerError(_ notification: Notification) { - // 최상단에 있는 뷰컨트롤러만 팝업을 띄우도록 보장 (중복 방지) guard self.presentedViewController == nil else { return } + // 알림에 실려온 에러 정보를 꺼냅니다. + if let error = notification.object as? APIError { + let statusCode = if case let .serverError(code) = error { code } else { -1 } + + // 1. 디스코드로 웹훅 발송 + DiscordWebhookManager.shared.sendErrorLog( + statusCode: statusCode, + message: "\(error)" + ) + } + + // 2. 사용자에게는 팝업 노출 DispatchQueue.main.async { [weak self] in self?.showAtchaErrorPopup() } From 5a653593cfc67a2e9df63f4d08ef2c05c69b3fda Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 18 Mar 2026 09:42:43 +0900 Subject: [PATCH 06/30] =?UTF-8?q?[FEAT]=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=83=81=EC=84=B8=20=EB=A1=9C=EA=B7=B8=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Discord/DiscordWebhookManager.swift | 53 ++++++++++++++--- .../Core/Network/API/APIServiceImpl.swift | 58 ++++++++++++++----- .../Common/BaseViewController.swift | 12 ---- 3 files changed, 90 insertions(+), 33 deletions(-) diff --git a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift index 5eedbb94..2cac8eec 100644 --- a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift +++ b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift @@ -11,22 +11,60 @@ final class DiscordWebhookManager { static let shared = DiscordWebhookManager() private init() {} - // 디스코드 채널 설정에서 만든 Webhook URL private let webhookURLString = "https://discord.com/api/webhooks/1483605689031983336/gqPjN3OU9ciMCF5qgPodT_KV3fE1giuuD6M4ODCJdenNru8UHezuYZfWBfc4Vnj4GWIZ" - func sendErrorLog(statusCode: Int, message: String) { + func sendErrorLog( + statusCode: Int, + method: String, + path: String, + responseCode: String, + message: String, + requestHeaders: [String: String], + requestBody: [String: Any]? = nil, + requestParameters: [String: Any]? = nil + ) { guard let url = URL(string: webhookURLString) else { return } - // 디코가 좋아하는 JSON 형식 (Embed를 쓰면 더 예쁘게 나옵니다) + // Authorization 토큰 앞 30자만 노출 + let headersText = requestHeaders.map { key, value in + let safeValue = key == "Authorization" ? String(value.prefix(30)) + "..." : value + return "\(key): \(safeValue)" + }.joined(separator: "\n") + + // body JSON 변환 + let bodyText: String + if let body = requestBody, + let data = try? JSONSerialization.data(withJSONObject: body, options: .prettyPrinted), + let str = String(data: data, encoding: .utf8) { + bodyText = "```json\n\(str)\n```" + } else { + bodyText = "None" + } + + // ✅ query parameters JSON 변환 + let paramsText: String + if let params = requestParameters, + let data = try? JSONSerialization.data(withJSONObject: params, options: .prettyPrinted), + let str = String(data: data, encoding: .utf8) { + paramsText = "```json\n\(str)\n```" + } else { + paramsText = "None" + } + let payload: [String: Any] = [ "content": "🚨 [Atcha-iOS] API 에러 발생!", "embeds": [[ "title": "서버 에러 상세 보고", - "color": 16711680, // 빨간색 + "color": 16711680, "fields": [ - ["name": "Status Code", "value": "\(statusCode)", "inline": true], - ["name": "App Version", "value": AppInfoProvider.currentVersion, "inline": true], - ["name": "Error Message", "value": message, "inline": false] + ["name": "Method & Path", "value": "`\(method) \(path)`", "inline": false], + ["name": "HTTP Status", "value": "\(statusCode)", "inline": true], + ["name": "responseCode", "value": responseCode, "inline": true], + ["name": "App Version", "value": AppInfoProvider.currentVersion, "inline": true], + ["name": "Error Message", "value": message, "inline": false], + ["name": "Request Headers", "value": "```\n\(headersText)\n```", "inline": false], + ["name": "Request Parameters", "value": paramsText, "inline": false], + ["name": "Request Body", "value": bodyText, "inline": false] ], "footer": ["text": "발생 시각: \(Date().description)"] ]] @@ -37,7 +75,6 @@ final class DiscordWebhookManager { request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONSerialization.data(withJSONObject: payload) - // 백그라운드에서 조용히 전송 URLSession.shared.dataTask(with: request).resume() } } diff --git a/Atcha-iOS/Core/Network/API/APIServiceImpl.swift b/Atcha-iOS/Core/Network/API/APIServiceImpl.swift index 9db8792b..23f310ed 100644 --- a/Atcha-iOS/Core/Network/API/APIServiceImpl.swift +++ b/Atcha-iOS/Core/Network/API/APIServiceImpl.swift @@ -13,7 +13,7 @@ private let trustManager = ServerTrustManager(evaluators: [ ]) private let insecureSession = Session(serverTrustManager: trustManager) -final class APIServiceImpl: APIService { +final class APIServiceImpl: APIService, @unchecked Sendable { private let session: Session /// 기본 초기화 - SSL 우회 세션 사용 @@ -48,14 +48,10 @@ final class APIServiceImpl: APIService { continuation.resume(throwing: APIError.noData) } } else { - let error = APIError.serverError(statusCode: response.response?.statusCode ?? -1) - NotificationCenter.default.post(name: .apiErrorOccurred, object: error) - continuation.resume(throwing: error) + self.handleFailure(response: response, endpoint: endpoint, continuation: continuation) } case .failure(let error): - let apiError = APIError.unknown(error: error) - NotificationCenter.default.post(name: .apiErrorOccurred, object: apiError) - continuation.resume(throwing: apiError) + self.handleFailure(response: response, endpoint: endpoint, continuation: continuation) } } } @@ -99,17 +95,53 @@ extension APIServiceImpl { continuation.resume(throwing: APIError.noData) } } else { - let error = APIError.serverError(statusCode: response.response?.statusCode ?? -1) - NotificationCenter.default.post(name: .apiErrorOccurred, object: error) - continuation.resume(throwing: error) + self.handleFailure(response: response, endpoint: endpoint, requestBody: body.toDictionary(), continuation: continuation) } case .failure(let error): - let apiError = APIError.unknown(error: error) - NotificationCenter.default.post(name: .apiErrorOccurred, object: apiError) - continuation.resume(throwing: apiError) + self.handleFailure(response: response, endpoint: endpoint, requestBody: body.toDictionary(), continuation: continuation) } } } } } + +extension APIServiceImpl { + private func handleFailure( + response: DataResponse, AFError>, + endpoint: Endpoint, + requestBody: [String: Any]? = nil, + continuation: CheckedContinuation + ) { + let statusCode = response.response?.statusCode ?? -1 + let method = endpoint.method.rawValue.uppercased() + let path = endpoint.path + let requestHeaders = endpoint.headers?.dictionary ?? [:] + + var responseCode = "UNKNOWN" + var serverMessage = "(메시지 없음)" + var serverPath = path + + if let data = response.data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + responseCode = json["responseCode"] as? String ?? "UNKNOWN" + serverMessage = json["message"] as? String ?? "(메시지 없음)" + serverPath = json["path"] as? String ?? path + } + + DiscordWebhookManager.shared.sendErrorLog( + statusCode: statusCode, + method: method, + path: serverPath, + responseCode: responseCode, + message: serverMessage, + requestHeaders: requestHeaders, + requestBody: requestBody, // POST/PUT body + requestParameters: endpoint.parameters // GET query params + ) + + let apiError = APIError.serverError(statusCode: statusCode) + NotificationCenter.default.post(name: .apiErrorOccurred, object: apiError) + continuation.resume(throwing: apiError) + } +} diff --git a/Atcha-iOS/Presentation/Common/BaseViewController.swift b/Atcha-iOS/Presentation/Common/BaseViewController.swift index 5313066e..7352c9d1 100644 --- a/Atcha-iOS/Presentation/Common/BaseViewController.swift +++ b/Atcha-iOS/Presentation/Common/BaseViewController.swift @@ -223,19 +223,7 @@ class BaseViewController: UIViewController { @objc private func handleServerError(_ notification: Notification) { guard self.presentedViewController == nil else { return } - - // 알림에 실려온 에러 정보를 꺼냅니다. - if let error = notification.object as? APIError { - let statusCode = if case let .serverError(code) = error { code } else { -1 } - - // 1. 디스코드로 웹훅 발송 - DiscordWebhookManager.shared.sendErrorLog( - statusCode: statusCode, - message: "\(error)" - ) - } - // 2. 사용자에게는 팝업 노출 DispatchQueue.main.async { [weak self] in self?.showAtchaErrorPopup() } From bbc0195f0f3f00eee216e5b85db20ddc603920fc Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 18 Mar 2026 09:47:07 +0900 Subject: [PATCH 07/30] =?UTF-8?q?[FEAT]=20=EB=A1=9C=EA=B7=B8=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=20=EC=8B=9C=EA=B0=81=20KST=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Manager/Discord/DiscordWebhookManager.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift index 2cac8eec..47593c5a 100644 --- a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift +++ b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift @@ -41,7 +41,6 @@ final class DiscordWebhookManager { bodyText = "None" } - // ✅ query parameters JSON 변환 let paramsText: String if let params = requestParameters, let data = try? JSONSerialization.data(withJSONObject: params, options: .prettyPrinted), @@ -66,7 +65,7 @@ final class DiscordWebhookManager { ["name": "Request Parameters", "value": paramsText, "inline": false], ["name": "Request Body", "value": bodyText, "inline": false] ], - "footer": ["text": "발생 시각: \(Date().description)"] + "footer": ["text": "발생 시각: \(Date().kstString)"] ]] ] @@ -78,3 +77,13 @@ final class DiscordWebhookManager { URLSession.shared.dataTask(with: request).resume() } } + +private extension Date { + var kstString: String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + formatter.locale = Locale(identifier: "ko_KR") + return formatter.string(from: self) + " KST" + } +} From 2bec77432a98fcf4fd12f55659de3e32697f9b25 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Thu, 19 Mar 2026 00:01:52 +0900 Subject: [PATCH 08/30] =?UTF-8?q?[BUGFIX]=20detailRoute=20info=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=95=88?= =?UTF-8?q?=EB=90=A8=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/DIContainer/AppCompositionRoot.swift | 25 ++++----- .../DIContainer/Login/LoginDIContainer.swift | 15 ++++-- .../DIContainer/Main/MainDIContainer.swift | 15 +++--- .../App/DIContainer/NetworkDIContainer.swift | 31 +++++++++-- .../Repository/SubwayInfoRepositoryImpl.swift | 4 +- .../DetailRoute/DetailRouteViewModel.swift | 4 +- .../Presentation/Location/MainViewModel.swift | 1 + .../Presentation/Login/LoginViewModel.swift | 54 +++++++++---------- 8 files changed, 93 insertions(+), 56 deletions(-) diff --git a/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift b/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift index 0fc8fed5..04955fc4 100644 --- a/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift +++ b/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift @@ -18,7 +18,7 @@ final class AppCompositionRoot { let apiService: APIService let noHeaderApiService: APIService let locationStateHolder: LocationStateHolder - + // MARK: Feature DI containers let splashDIContainer: SplashDIContainer let loginDIContainer: LoginDIContainer @@ -26,7 +26,7 @@ final class AppCompositionRoot { let mainDIContainer: MainDIContainer let lockScreenDIContainer: LockScreenDIContainer let introDIContainer: IntroDIContainer - + // MARK: - Init init() { // Core @@ -35,14 +35,15 @@ final class AppCompositionRoot { self.apiService = networkDIContainer.makeAPIService() self.noHeaderApiService = networkDIContainer.makeAPIService(useInterceptor: false) self.locationStateHolder = LocationStateHolder() - + // Features self.splashDIContainer = SplashDIContainer(apiService: apiService) - self.loginDIContainer = LoginDIContainer(apiService: noHeaderApiService) + self.loginDIContainer = LoginDIContainer(apiService: apiService, tokenStorage: tokenStorage) self.onboardingDIContainer = OnboardingDIContainer(apiService: apiService, locationStateHolder: locationStateHolder) self.mainDIContainer = MainDIContainer(apiService: apiService, - locationStateHolder: locationStateHolder) + locationStateHolder: locationStateHolder, + tokenStorage: tokenStorage) self.lockScreenDIContainer = LockScreenDIContainer(apiService: apiService) self.introDIContainer = IntroDIContainer() } @@ -50,27 +51,27 @@ final class AppCompositionRoot { // MARK: - Coordinator factories forwarding extension AppCompositionRoot: SplashCoordinatorFactory, - LoginCoordinatorFactory, - OnboardingCoordinatorFactory, - MainCoordinatorFactory, + LoginCoordinatorFactory, + OnboardingCoordinatorFactory, + MainCoordinatorFactory, LockScreenCoordinatorFactory, IntroCoordinatorFactory { func makeSplashCoordinator(navigationController: UINavigationController) -> SplashCoordinator { return splashDIContainer.makeSplashCoordinator(navigationController: navigationController) } - + func makeLoginCoordinator(navigationController: UINavigationController) -> LoginCoordinator { return loginDIContainer.makeLoginCoordinator(navigationController: navigationController) } - + func makeOnboardingCoordinator(navigationController: UINavigationController) -> OnboardingCoordinator { return onboardingDIContainer.makeOnboardingCoordinator(navigationController: navigationController) } - + func makeMainCoordinator(navigationController: UINavigationController) -> MainCoordinator { return mainDIContainer.makeMainCoordinator(navigationController: navigationController) } - + func makeLockScreenCoordinator(navigationController: UINavigationController) -> LockScreenCoordinator { return lockScreenDIContainer.makeLockScreenCoordinator(navigationController: navigationController) } diff --git a/Atcha-iOS/App/DIContainer/Login/LoginDIContainer.swift b/Atcha-iOS/App/DIContainer/Login/LoginDIContainer.swift index cc3189c3..b257274b 100644 --- a/Atcha-iOS/App/DIContainer/Login/LoginDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Login/LoginDIContainer.swift @@ -10,18 +10,23 @@ import Foundation final class LoginDIContainer { private let apiService: APIService - - init(apiService: APIService) { + private let tokenStorage: TokenStorage + + init(apiService: APIService, tokenStorage: TokenStorage) { self.apiService = apiService + self.tokenStorage = tokenStorage } - + func makeLoginUseCase() -> LoginUseCase { let repository: UserRepository = UserRepositoryImpl(apiService: apiService) return LoginUseCaseImpl(repository: repository) } - + func makeLoginViewModel() -> LoginViewModel { - LoginViewModel(loginUseCase: makeLoginUseCase()) + LoginViewModel( + loginUseCase: makeLoginUseCase(), + tokenStorage: tokenStorage + ) } func makeLoginCoordinator(navigationController: UINavigationController) -> LoginCoordinator { diff --git a/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift b/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift index 636b6a63..a33c7906 100644 --- a/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift @@ -11,6 +11,8 @@ import Foundation final class MainDIContainer { private let apiService: APIService private let locationStateHolder: LocationStateHolder + private let tokenStorage: TokenStorage + private lazy var searchAddressUseCase = SearchAddressUseCaseImpl(repository: AddressRepositoryImpl(apiService: apiService)) private lazy var requestUseCase = RequestLocationAuthorizationUseCaseImpl(repository: PermissionRepositoryImpl()) private lazy var streamUseCase = ObserLocationStreamUseCaseImpl(repository: LocationStreamRepositoryImpl()) @@ -45,17 +47,18 @@ final class MainDIContainer { }() private lazy var loginDI: LoginDIContainer = { - LoginDIContainer(apiService: apiService) - }() + LoginDIContainer(apiService: apiService, tokenStorage: tokenStorage) + }() private lazy var homeRegisterDI: HomeRegisterDIContainer = { HomeRegisterDIContainer(apiService: apiService, locationStateHolder: locationStateHolder) }() - init(apiService: APIService, locationStateHolder: LocationStateHolder) { - self.apiService = apiService - self.locationStateHolder = locationStateHolder - } + init(apiService: APIService, locationStateHolder: LocationStateHolder, tokenStorage: TokenStorage) { + self.apiService = apiService + self.locationStateHolder = locationStateHolder + self.tokenStorage = tokenStorage + } func makeMainiewModel() -> MainViewModel { let fetchTaxiFareUseCase = FetchTaxiFareUseCaseImpl(repository: FetchTaxiFareRepositoryImpl(apiService: apiService)) diff --git a/Atcha-iOS/App/DIContainer/NetworkDIContainer.swift b/Atcha-iOS/App/DIContainer/NetworkDIContainer.swift index e4ce0e68..cf54abf0 100644 --- a/Atcha-iOS/App/DIContainer/NetworkDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/NetworkDIContainer.swift @@ -8,28 +8,51 @@ import Foundation import Alamofire + final class NetworkDIContainer { private let tokenStorage: TokenStorage + // 인터셉터가 있는 세션 (메모리 유지를 위해 프로퍼티로 선언) + private lazy var authenticatedSession: Session = makeSession(useInterceptor: true) + + // 인터셉터가 없는 세션 (로그인용) + private lazy var publicSession: Session = makeSession(useInterceptor: false) + init(tokenStorage: TokenStorage) { self.tokenStorage = tokenStorage } - func makeSession(useInterceptor: Bool = true) -> Session { + private func makeSession(useInterceptor: Bool) -> Session { let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = NetworkConstant.timeoutInterval - let interceptor: RequestInterceptor? = useInterceptor ? TokenInterceptor(tokenStorage: tokenStorage) : nil + let interceptor: RequestInterceptor? + if useInterceptor { + interceptor = TokenInterceptor( + tokenStorage: tokenStorage, + refreshSession: publicSession + ) + } else { + interceptor = nil + } + + let evaluators: [String: ServerTrustEvaluating] = [ + "atcha.p-e.kr": DisabledTrustEvaluator(), + "atcha.online": DisabledTrustEvaluator() + ] + let trustManager = ServerTrustManager(evaluators: evaluators) return Session( configuration: configuration, interceptor: interceptor, + serverTrustManager: trustManager, eventMonitors: [NetworkLogger()] ) } + // APIService 생성 시 미리 만들어둔(공유된) 세션을 주입합니다. func makeAPIService(useInterceptor: Bool = true) -> APIService { - return APIServiceImpl(session: makeSession(useInterceptor: useInterceptor)) + let session = useInterceptor ? authenticatedSession : publicSession + return APIServiceImpl(session: session) } } - diff --git a/Atcha-iOS/Data/Repository/SubwayInfoRepositoryImpl.swift b/Atcha-iOS/Data/Repository/SubwayInfoRepositoryImpl.swift index 0204fe0a..46e32116 100644 --- a/Atcha-iOS/Data/Repository/SubwayInfoRepositoryImpl.swift +++ b/Atcha-iOS/Data/Repository/SubwayInfoRepositoryImpl.swift @@ -22,10 +22,12 @@ final class SubwayInfoRepositoryImpl: SubwayInfoRepository { path: "/routes/user-routes/subway-arrival", method: .get, parameters: ["routeName": request.routeName], - encoding: URLEncoding.queryString + encoding: URLEncoding.queryString, + headers: ["Authorization": "Bearer \(AppDIContainer.shared.tokenStorage.accessToken ?? "")"], ) let result: [SubwayRealTimeInfoResponse] = try await apiService.request(endpoint) return result } } + diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index 1e518121..3e970398 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift @@ -23,7 +23,7 @@ final class DetailRouteViewModel: BaseViewModel { private let alarmUseCase: AlarmUseCase private var streamTask: Task? - let infos: LegInfo + var infos: LegInfo var onBusDetail: ((BusDetailInfo) -> Void)? var getAlarmTapped: ((String, LegInfo) -> Void)? @@ -93,6 +93,7 @@ final class DetailRouteViewModel: BaseViewModel { private func bindUserDefaults() { UserDefaultsWrapper.shared.legInfoPublisher .compactMap { $0 } // nil이 아닐 때만 + .removeDuplicates() .receive(on: RunLoop.main) .sink { [weak self] newInfo in print("새로운 경로 정보 감지됨: UI 업데이트 시작") @@ -103,6 +104,7 @@ final class DetailRouteViewModel: BaseViewModel { private func updateWithNewInfo(_ info: LegInfo) { // 1. 데이터 갱신 + self.infos = info self.legtPathInfo = info.pathInfo self.legTrafficInfo = info.trafficInfo diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index 1a354194..cc4c1e3b 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -159,6 +159,7 @@ final class MainViewModel: BaseViewModel{ } func drawRoute(address: String?, info: LegInfo?) { + guard let address, let info else { return } addressDesc = address legInfo = info diff --git a/Atcha-iOS/Presentation/Login/LoginViewModel.swift b/Atcha-iOS/Presentation/Login/LoginViewModel.swift index cccca474..d26b1a94 100644 --- a/Atcha-iOS/Presentation/Login/LoginViewModel.swift +++ b/Atcha-iOS/Presentation/Login/LoginViewModel.swift @@ -10,10 +10,12 @@ import AuthenticationServices final class LoginViewModel: BaseViewModel { private let loginUseCase: LoginUseCase + private var tokenStorage: TokenStorage - init(loginUseCase: LoginUseCase) { - self.loginUseCase = loginUseCase - } + init(loginUseCase: LoginUseCase, tokenStorage: TokenStorage) { + self.loginUseCase = loginUseCase + self.tokenStorage = tokenStorage + } var isExistUser: ((Bool) -> Void)? var loginCancelled: (() -> Void)? @@ -54,32 +56,30 @@ extension LoginViewModel { type: LoginType) async { let request: LoginRequest = LoginRequest(accessToken: token, provider: type.rawValue) - Task { - do { - let response = try await loginUseCase.login(request) - - AppDIContainer.shared.tokenStorage.accessToken = response.accessToken - AppDIContainer.shared.tokenStorage.refreshToken = response.refreshToken - - if let lat = response.latitude, - let lon = response.longitude, - let id = response.id { - UserDefaultsWrapper.shared.set(lat, forKey: UserDefaultsWrapper.Key.homeLat.rawValue) - UserDefaultsWrapper.shared.set(lon, forKey: UserDefaultsWrapper.Key.homeLon.rawValue) - UserDefaultsWrapper.shared.set(id, forKey: UserDefaultsWrapper.Key.userId - .rawValue) - UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.reVisit - .rawValue) - - AmplitudeManager.shared.bindUser(id: String(id)) - AmplitudeManager.shared.flush() - } + do { + let response = try await loginUseCase.login(request) + + tokenStorage.accessToken = response.accessToken + tokenStorage.refreshToken = response.refreshToken + + if let lat = response.latitude, + let lon = response.longitude, + let id = response.id { + UserDefaultsWrapper.shared.set(lat, forKey: UserDefaultsWrapper.Key.homeLat.rawValue) + UserDefaultsWrapper.shared.set(lon, forKey: UserDefaultsWrapper.Key.homeLon.rawValue) + UserDefaultsWrapper.shared.set(id, forKey: UserDefaultsWrapper.Key.userId + .rawValue) + UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.reVisit + .rawValue) - UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) - print("로그인 완료") - } catch { - print("로그인 실패: \(error.localizedDescription)") + AmplitudeManager.shared.bindUser(id: String(id)) + AmplitudeManager.shared.flush() } + + UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) + print("로그인 완료") + } catch { + print("로그인 실패: \(error.localizedDescription)") } } From 905d8a4b53b8d5fe6960c5e71ca4645410ad1bbc Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Thu, 19 Mar 2026 00:06:47 +0900 Subject: [PATCH 09/30] =?UTF-8?q?[BUGFIX]=20=EC=8A=A4=ED=94=8C=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=EB=B6=80=EB=B6=84=20=ED=86=A0=ED=81=B0=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85(DI)=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/DIContainer/AppCompositionRoot.swift | 3 +- .../Splash/SplashDIContainer.swift | 21 +++++---- .../Presentation/Splash/SplashViewModel.swift | 46 ++----------------- 3 files changed, 20 insertions(+), 50 deletions(-) diff --git a/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift b/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift index 04955fc4..8928ae4e 100644 --- a/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift +++ b/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift @@ -37,7 +37,8 @@ final class AppCompositionRoot { self.locationStateHolder = LocationStateHolder() // Features - self.splashDIContainer = SplashDIContainer(apiService: apiService) + self.splashDIContainer = SplashDIContainer(apiService: apiService, + tokenStorage: tokenStorage) self.loginDIContainer = LoginDIContainer(apiService: apiService, tokenStorage: tokenStorage) self.onboardingDIContainer = OnboardingDIContainer(apiService: apiService, locationStateHolder: locationStateHolder) diff --git a/Atcha-iOS/App/DIContainer/Splash/SplashDIContainer.swift b/Atcha-iOS/App/DIContainer/Splash/SplashDIContainer.swift index 7d5aed90..0ce117b9 100644 --- a/Atcha-iOS/App/DIContainer/Splash/SplashDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Splash/SplashDIContainer.swift @@ -10,16 +10,18 @@ import Foundation final class SplashDIContainer { private let apiService: APIService - - init(apiService: APIService) { + private let tokenStorage: TokenStorage + + init(apiService: APIService, tokenStorage: TokenStorage) { self.apiService = apiService + self.tokenStorage = tokenStorage } func makeFetchUserUseCase() -> FetchUserUseCase { let repository: UserRepository = UserRepositoryImpl(apiService: apiService) return FetchUserUseCaseImpl(repositoy: repository) } - + func makeCheckAppVersionUseCase() -> CheckAppVersionUseCase { let repository = AppVersionRepositoryImpl(apiService: apiService) return CheckAppVersionUseCaseImpl(repository: repository) @@ -29,17 +31,20 @@ final class SplashDIContainer { let repository = AppVersionRepositoryImpl(apiService: apiService) return UpdateAppVersionUseCaseImpl(repository: repository) } - + func makeSplashViewModel() -> SplashViewModel { - SplashViewModel(fetchUserUseCase: makeFetchUserUseCase(), - checkAppVersionUseCase: makeCheckAppVersionUseCase(), - updateAppVersionUseCase: makeUpdateAppVersionUseCase()) + return SplashViewModel( + fetchUserUseCase: makeFetchUserUseCase(), + checkAppVersionUseCase: makeCheckAppVersionUseCase(), + updateAppVersionUseCase: makeUpdateAppVersionUseCase(), + tokenStorage: tokenStorage + ) } func makeSplashViewController(viewModel: SplashViewModel) -> SplashViewController { return SplashViewController(viewModel: viewModel) } - + func makeSplashCoordinator(navigationController: UINavigationController) -> SplashCoordinator { SplashCoordinator(navigationController: navigationController, diContainer: self) diff --git a/Atcha-iOS/Presentation/Splash/SplashViewModel.swift b/Atcha-iOS/Presentation/Splash/SplashViewModel.swift index b68ef628..5ae6004a 100644 --- a/Atcha-iOS/Presentation/Splash/SplashViewModel.swift +++ b/Atcha-iOS/Presentation/Splash/SplashViewModel.swift @@ -13,15 +13,18 @@ final class SplashViewModel: BaseViewModel { private let fetchUserUseCase: FetchUserUseCase private let checkAppVersionUseCase: CheckAppVersionUseCase private let updateAppVersionUseCase: UpdateAppVersionUseCase + private let tokenStorage: TokenStorage var routerHandler: ((SplashRouter) -> Void)? init(fetchUserUseCase: FetchUserUseCase, checkAppVersionUseCase: CheckAppVersionUseCase, - updateAppVersionUseCase: UpdateAppVersionUseCase) { + updateAppVersionUseCase: UpdateAppVersionUseCase, + tokenStorage: TokenStorage) { self.fetchUserUseCase = fetchUserUseCase self.checkAppVersionUseCase = checkAppVersionUseCase self.updateAppVersionUseCase = updateAppVersionUseCase + self.tokenStorage = tokenStorage super.init() self.checkAppVersion() @@ -102,7 +105,7 @@ final class SplashViewModel: BaseViewModel { return } - if AppDIContainer.shared.tokenStorage.accessToken != nil { + if tokenStorage.accessToken != nil { // 1. 토큰이 있는 경우 (로그인 유저) -> 유저정보 받고 메인으로! fetchUserInfo() routerHandler?(.main) @@ -158,42 +161,3 @@ final class SplashViewModel: BaseViewModel { return diff >= 120 // 120초 = 2분 } } - -// 막차를 등록한 경우 -// if let legInfo: LegInfo = wrapper.object(forKey: UserDefaultsWrapper.Key.legInfo.rawValue, of: LegInfo.self), -// let address: String = wrapper.string(forKey: UserDefaultsWrapper.Key.addressDesc.rawValue) { -// guard let time = legInfo.pathInfo.first?.departureDateTime else { return } // 막차를 타기위해 출발해야 하는 시간 -//// AlarmManager.shared.startAlarm(after: time, title: "눌러서 출발 알람 끄기", body: "자리에서 일어나야 할 시간이에요!") -// -// if checkFutureTimeOver(dateString: time) { -// // 현재 시간 > 알람 시간 -> 알람 화면 -// routerHandler?(.alarm(info: legInfo, address: address)) -// } else { -// // 현재 시간 < 알림 시간 -// -// // 만약에 내가 실시간 조회 -> 타이머 시간이 존재하면 !! -// if let _ = wrapper.integer(forKey: UserDefaultsWrapper.Key.trainRealTime.rawValue) { -// routerHandler?(.alarm(info: legInfo, address: address)) -// // TODO: 상세경로 화면으로 이동 -// } else { -// -// if let arrivalTime = wrapper.object(forKey: UserDefaultsWrapper.Key.arrivalTime.rawValue, of: Date.self) { -// if isMoreThanSeconds(from: arrivalTime, seconds: 60 * 30) { // 30분이 넘게 지난 경우 -// routerHandler?(.main) -// } else { -// routerHandler?(.finishTime(info: legInfo, address: address)) -// } -// return -// } -// -// // 현재 시간 - 알람 시간 2분 이내에 진입 한 경우 (time이랑 Date() 차이가 2분) -// if isMoreThanSeconds(from: time, seconds: 120) { -//// routerHandler?(.main) // 진입 이후, 알람 팝업 -// routerHandler?(.alarm(info: legInfo, address: address)) -// } else { -// routerHandler?(.lockScreen(info: legInfo, address: address)) // 2분 이내에 재 진입, 잠금화면 -// } -// } -// } -// return -// } From 14a1aa1b895250e05b0a0a07b0a6f8d94e848865 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Thu, 19 Mar 2026 00:11:21 +0900 Subject: [PATCH 10/30] =?UTF-8?q?[BUGFIX]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=B6=80=EB=B6=84=20=ED=86=A0=ED=81=B0=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85(DI)=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DIContainer/Main/MainDIContainer.swift | 2 +- .../User/Home/HomeRegisterDIContainer.swift | 8 +++++-- .../User/Home/HomeFindViewModel.swift | 23 +++++++++++-------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift b/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift index a33c7906..ce708d21 100644 --- a/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift @@ -51,7 +51,7 @@ final class MainDIContainer { }() private lazy var homeRegisterDI: HomeRegisterDIContainer = { - HomeRegisterDIContainer(apiService: apiService, locationStateHolder: locationStateHolder) + HomeRegisterDIContainer(apiService: apiService, locationStateHolder: locationStateHolder, tokenStorage: tokenStorage) }() init(apiService: APIService, locationStateHolder: LocationStateHolder, tokenStorage: TokenStorage) { diff --git a/Atcha-iOS/App/DIContainer/User/Home/HomeRegisterDIContainer.swift b/Atcha-iOS/App/DIContainer/User/Home/HomeRegisterDIContainer.swift index b9b5ab22..36cce66f 100644 --- a/Atcha-iOS/App/DIContainer/User/Home/HomeRegisterDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/User/Home/HomeRegisterDIContainer.swift @@ -10,11 +10,14 @@ import Foundation final class HomeRegisterDIContainer { private let locationStateHolder: LocationStateHolder private let apiService: APIService + private let tokenStorage: TokenStorage init(apiService: APIService, - locationStateHolder: LocationStateHolder) { + locationStateHolder: LocationStateHolder, + tokenStorage: TokenStorage) { self.apiService = apiService self.locationStateHolder = locationStateHolder + self.tokenStorage = tokenStorage } private lazy var authorizationRequestUseCase = RequestLocationAuthorizationUseCaseImpl(repository: PermissionRepositoryImpl()) @@ -41,7 +44,8 @@ final class HomeRegisterDIContainer { let viewModel = HomeFindViewModel(context: context, searchAddressUseCase: searchAddressUseCase, homePatchUseCase: homePatchUseCase, - locationStateHolder: locationStateHolder, streamUseCase: streamUseCase, signUpUseCase: signUpUseCase) + locationStateHolder: locationStateHolder, streamUseCase: streamUseCase, signUpUseCase: signUpUseCase, + tokenStorage: tokenStorage) return viewModel } diff --git a/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift b/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift index 30fc5c18..eb1ed40d 100644 --- a/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift +++ b/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift @@ -29,13 +29,15 @@ final class HomeFindViewModel: BaseViewModel { private let locationStateHolder: LocationStateHolder private let streamUseCase: ObserveLocationStreamUseCase private let signUpUseCase: SignUpUseCase + private var tokenStorage: TokenStorage init(context: HomeRegisterContext, searchAddressUseCase: SearchAddressUseCase, homePatchUseCase: HomePatchUseCase, locationStateHolder: LocationStateHolder, streamUseCase: ObserveLocationStreamUseCase, - signUpUseCase: SignUpUseCase) { + signUpUseCase: SignUpUseCase, + tokenStorage: TokenStorage) { self.context = context self.searchAddressUseCase = searchAddressUseCase @@ -43,6 +45,7 @@ final class HomeFindViewModel: BaseViewModel { self.signUpUseCase = signUpUseCase self.locationStateHolder = locationStateHolder self.streamUseCase = streamUseCase + self.tokenStorage = tokenStorage self.buildingName = locationStateHolder.buildingName self.address = locationStateHolder.address @@ -110,13 +113,13 @@ final class HomeFindViewModel: BaseViewModel { isInitialReqeust = true return } - + if let saved = locationStateHolder.currentLocation { currentLocation = saved - + let hasPresetText = (locationStateHolder.address?.isEmpty == false) || - (locationStateHolder.buildingName?.isEmpty == false) - + (locationStateHolder.buildingName?.isEmpty == false) + if !hasPresetText { Task { @MainActor in await refreshAddress() @@ -134,7 +137,7 @@ final class HomeFindViewModel: BaseViewModel { latitude: location.coordinate.latitude, longitude: location.coordinate.longitude ) - await self.refreshAddress() + await self.refreshAddress() break } } @@ -189,7 +192,7 @@ final class HomeFindViewModel: BaseViewModel { @MainActor func applyDefaultLocationIfPermissionDenied() async { self.currentLocation = defaultCoord - + do { let addr = try await fetchCurrentAddress( lat: defaultCoord.latitude, @@ -233,7 +236,7 @@ extension HomeFindViewModel { return } - guard let fcmToken = AppDIContainer.shared.tokenStorage.fcmToken else { + guard let fcmToken = tokenStorage.fcmToken else { print("FCM 토큰이 없습니다.") return } @@ -256,8 +259,8 @@ extension HomeFindViewModel { do { let response = try await signUpUseCase.excute(request) - AppDIContainer.shared.tokenStorage.accessToken = response.accessToken - AppDIContainer.shared.tokenStorage.refreshToken = response.refreshToken + self.tokenStorage.accessToken = response.accessToken + self.tokenStorage.refreshToken = response.refreshToken UserDefaultsWrapper.shared.set(response.id, forKey: UserDefaultsWrapper.Key.userId.rawValue) if let lat = response.lat, let lon = response.lon, let id = response.id { From 9c2c646494de91538a2604a49e9974ba21b7625b Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Thu, 19 Mar 2026 00:28:08 +0900 Subject: [PATCH 11/30] =?UTF-8?q?[BUGFIX]=20=EA=B2=BD=EB=A1=9C=ED=83=90?= =?UTF-8?q?=EC=83=89=20=EB=B6=80=EB=B6=84=20=ED=86=A0=ED=81=B0=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85(DI)=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Course/CourseDIContainer.swift | 12 +++- .../DIContainer/Main/MainDIContainer.swift | 25 ++++---- .../Setting/MyAccountDIContainer.swift | 20 +++++-- .../DIContainer/User/MyPageDIContainer.swift | 15 +++-- .../Repository/CourseRepositoryImpl.swift | 47 ++++----------- .../PushAlarm/PushAlarmViewModel.swift | 59 ------------------- .../User/MyAccount/MyAccountViewModel.swift | 13 ++-- .../User/Withdraw/WithdrawViewModel.swift | 14 +++-- 8 files changed, 81 insertions(+), 124 deletions(-) diff --git a/Atcha-iOS/App/DIContainer/Course/CourseDIContainer.swift b/Atcha-iOS/App/DIContainer/Course/CourseDIContainer.swift index 289776c9..688bddc4 100644 --- a/Atcha-iOS/App/DIContainer/Course/CourseDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Course/CourseDIContainer.swift @@ -11,18 +11,26 @@ import UIKit final class CourseDIContainer { private let apiService: APIService private let locationStateHolder: LocationStateHolder + private let tokenStorage: TokenStorage private lazy var searchAddressUseCase = SearchAddressUseCaseImpl(repository: AddressRepositoryImpl(apiService: apiService)) private lazy var requestUseCase = RequestLocationAuthorizationUseCaseImpl(repository: PermissionRepositoryImpl()) private lazy var streamUseCase = ObserLocationStreamUseCaseImpl(repository: LocationStreamRepositoryImpl()) - init(apiService: APIService, locationStateHolder: LocationStateHolder) { + init(apiService: APIService, + locationStateHolder: LocationStateHolder, + tokenStorage: TokenStorage) { self.apiService = apiService self.locationStateHolder = locationStateHolder + self.tokenStorage = tokenStorage } func makeCourseSearchViewModel(startLat: String, startLon: String, startAddress: String) -> CourseSearchViewModel { - let courseUseCase = CourseUseCaseImpl(repository: CourseRepositoryImpl(apiService: apiService)) + let courseUseCase = CourseUseCaseImpl( + repository: CourseRepositoryImpl(apiService: apiService, tokenStorage: tokenStorage) + ) + let alarmUseCase = AlarmUseCaseImpl(repository: AlarmRepositoryImpl(apiService: apiService)) + return CourseSearchViewModel(courseUseCase: courseUseCase, alarmUseCase: alarmUseCase, startLat: startLat, diff --git a/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift b/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift index ce708d21..6e59fd3e 100644 --- a/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift @@ -18,16 +18,19 @@ final class MainDIContainer { private lazy var streamUseCase = ObserLocationStreamUseCaseImpl(repository: LocationStreamRepositoryImpl()) private lazy var busInfoUseCase = BusInfoUseCaseImpl(repository: BusInfoRepositoryImpl(apiService: apiService)) private lazy var alarmUseCase = AlarmUseCaseImpl(repository: AlarmRepositoryImpl(apiService: apiService)) - private lazy var courseUseCase = CourseUseCaseImpl(repository: CourseRepositoryImpl(apiService: apiService)) + private lazy var courseUseCase = CourseUseCaseImpl(repository: CourseRepositoryImpl(apiService: apiService, tokenStorage: tokenStorage)) private lazy var myPageDI: MyPageDIContainer = { MyPageDIContainer(apiService: apiService, - locationStateHolder: locationStateHolder) + tokenStorage: tokenStorage, locationStateHolder: locationStateHolder) }() private lazy var courseDI: CourseDIContainer = { - CourseDIContainer(apiService: apiService, - locationStateHolder: locationStateHolder) + CourseDIContainer( + apiService: apiService, + locationStateHolder: locationStateHolder, + tokenStorage: tokenStorage + ) }() private lazy var rotueDI: RouteDIContainer = { @@ -47,18 +50,18 @@ final class MainDIContainer { }() private lazy var loginDI: LoginDIContainer = { - LoginDIContainer(apiService: apiService, tokenStorage: tokenStorage) - }() + LoginDIContainer(apiService: apiService, tokenStorage: tokenStorage) + }() private lazy var homeRegisterDI: HomeRegisterDIContainer = { HomeRegisterDIContainer(apiService: apiService, locationStateHolder: locationStateHolder, tokenStorage: tokenStorage) }() - init(apiService: APIService, locationStateHolder: LocationStateHolder, tokenStorage: TokenStorage) { - self.apiService = apiService - self.locationStateHolder = locationStateHolder - self.tokenStorage = tokenStorage - } + init(apiService: APIService, locationStateHolder: LocationStateHolder, tokenStorage: TokenStorage) { + self.apiService = apiService + self.locationStateHolder = locationStateHolder + self.tokenStorage = tokenStorage + } func makeMainiewModel() -> MainViewModel { let fetchTaxiFareUseCase = FetchTaxiFareUseCaseImpl(repository: FetchTaxiFareRepositoryImpl(apiService: apiService)) diff --git a/Atcha-iOS/App/DIContainer/Setting/MyAccountDIContainer.swift b/Atcha-iOS/App/DIContainer/Setting/MyAccountDIContainer.swift index 4ee6434d..6855198d 100644 --- a/Atcha-iOS/App/DIContainer/Setting/MyAccountDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Setting/MyAccountDIContainer.swift @@ -9,15 +9,27 @@ import Foundation final class MyAccountDIContainer { private let apiService: APIService + private let tokenStorage: TokenStorage + private let locationStateHolder: LocationStateHolder + private lazy var repository: UserRepository = UserRepositoryImpl(apiService: apiService) - init(apiService: APIService) { + + init(apiService: APIService, + tokenStorage: TokenStorage, + locationStateHolder: LocationStateHolder) { self.apiService = apiService + self.tokenStorage = tokenStorage + self.locationStateHolder = locationStateHolder } func makeMyAccountViewModel() -> MyAccountViewModel { - let logoutUseCase: LogoutuseCase = LogoutuseCaseCaseImpl(repository: repository) - return MyAccountViewModel(logoutUseCase: logoutUseCase) + + return MyAccountViewModel( + logoutUseCase: logoutUseCase, + tokenStorage: tokenStorage, + locationStateHolder: locationStateHolder + ) } func makeMyAccountViewController(viewModel: MyAccountViewModel) -> MyAccountViewController { @@ -26,7 +38,7 @@ final class MyAccountDIContainer { func makeWithdrawViewModel() -> WithdrawViewModel { let useCase: SignOutUseCase = SignOutUseCaseImpl(repository: repository) - return WithdrawViewModel(signOutUseCase: useCase) + return WithdrawViewModel(signOutUseCase: useCase, tokenStorage: tokenStorage, locationStateHolder: locationStateHolder) } func makeWithdrawViewController(viewModel: WithdrawViewModel) -> WithdrawViewController { diff --git a/Atcha-iOS/App/DIContainer/User/MyPageDIContainer.swift b/Atcha-iOS/App/DIContainer/User/MyPageDIContainer.swift index bdf0645d..383cfba7 100644 --- a/Atcha-iOS/App/DIContainer/User/MyPageDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/User/MyPageDIContainer.swift @@ -10,28 +10,31 @@ import Foundation final class MyPageDIContainer { private let apiService: APIService + private let tokenStorage: TokenStorage private let locationStateHolder: LocationStateHolder var signoutFinish: (() -> Void)? private lazy var homeDI: HomeRegisterDIContainer = { - HomeRegisterDIContainer(apiService: apiService, locationStateHolder: locationStateHolder) + HomeRegisterDIContainer(apiService: apiService, locationStateHolder: locationStateHolder, tokenStorage: tokenStorage) }() private lazy var pushDI: PushRegisterDIContainer = { PushRegisterDIContainer(apiService: apiService, locationStateHolder: locationStateHolder) }() private lazy var myAccountDI: MyAccountDIContainer = { - MyAccountDIContainer(apiService: apiService) + MyAccountDIContainer(apiService: apiService, tokenStorage: tokenStorage, locationStateHolder: locationStateHolder) }() private lazy var alarmSettingDI: AlarmSettingDIContainer = { AlarmSettingDIContainer() }() init(apiService: APIService, - locationStateHolder: LocationStateHolder) { - self.apiService = apiService - self.locationStateHolder = locationStateHolder - } + tokenStorage: TokenStorage, + locationStateHolder: LocationStateHolder) { + self.apiService = apiService + self.tokenStorage = tokenStorage + self.locationStateHolder = locationStateHolder + } func makeMyPageViewModel() -> MyPageViewModel { MyPageViewModel() diff --git a/Atcha-iOS/Data/Repository/CourseRepositoryImpl.swift b/Atcha-iOS/Data/Repository/CourseRepositoryImpl.swift index f0669a5d..78df090e 100644 --- a/Atcha-iOS/Data/Repository/CourseRepositoryImpl.swift +++ b/Atcha-iOS/Data/Repository/CourseRepositoryImpl.swift @@ -10,38 +10,18 @@ import Alamofire final class CourseRepositoryImpl: CourseRepository { private let apiService: APIService + private var tokenStorage: TokenStorage - init(apiService: APIService) { + init(apiService: APIService, tokenStorage: TokenStorage) { self.apiService = apiService + self.tokenStorage = tokenStorage } func courseSearch(_ routeId: String) async throws -> CourseSearchResponse { - guard let token = AppDIContainer.shared.tokenStorage.accessToken else { - throw NSError(domain: "CourseRepository", code: 401, userInfo: [NSLocalizedDescriptionKey: "인증 토큰이 없습니다."]) - } - - let headers: HTTPHeaders = [ - "Authorization": "Bearer \(token)" - ] - - return try await apiService.request( - Endpoint( - path: "/routes/last-routes/\(routeId)", - method: .get, - headers: headers - ) - ) + return try await apiService.request(Endpoint(path: "/routes/last-routes/\(routeId)", method: .get)) } func courseSearch(_ request: CourseSearchRequest) async throws -> [CourseSearchResponse] { - guard let token = AppDIContainer.shared.tokenStorage.accessToken else { - throw NSError(domain: "CourseRepository", code: 401, userInfo: [NSLocalizedDescriptionKey: "인증 토큰이 없습니다."]) - } - - let headers: HTTPHeaders = [ - "Authorization": "Bearer \(token)" - ] - return try await apiService.request( Endpoint( path: "/routes/last-routes", @@ -51,8 +31,7 @@ final class CourseRepositoryImpl: CourseRepository { "startLon": request.startLon, "endLat": request.endLat, "endLon": request.endLon - ], - headers: headers + ] ) ) } @@ -69,7 +48,7 @@ final class CourseRepositoryImpl: CourseRepository { _ request: CourseSearchRequest, continuation: AsyncThrowingStream.Continuation ) async { - guard let token = AppDIContainer.shared.tokenStorage.accessToken else { + guard let token = tokenStorage.accessToken else { continuation.finish(throwing: NSError(domain: "CourseRepository", code: 401, userInfo: [NSLocalizedDescriptionKey: "인증 토큰이 없습니다."])) return } @@ -96,8 +75,8 @@ final class CourseRepositoryImpl: CourseRepository { // 401 외의 코드도 명확히 분기 if http.statusCode == 401 { if let tokens = await refreshToken() { - AppDIContainer.shared.tokenStorage.accessToken = tokens.accessToken - if let rt = tokens.refreshToken { AppDIContainer.shared.tokenStorage.refreshToken = rt } + tokenStorage.accessToken = tokens.accessToken + if let rt = tokens.refreshToken { tokenStorage.refreshToken = rt } print("재발급 성공 → 스트림 재연결") await startStream(request, continuation: continuation) return @@ -152,7 +131,7 @@ final class CourseRepositoryImpl: CourseRepository { private func refreshToken() async -> (accessToken: String, refreshToken: String?)? { - guard let refreshToken = AppDIContainer.shared.tokenStorage.refreshToken else { + guard let refreshToken = tokenStorage.refreshToken else { print("refreshToken 없음") SessionController.shared.expireAndRouteToLogin() return nil @@ -169,7 +148,7 @@ final class CourseRepositoryImpl: CourseRepository { let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { print("reissue: HTTPURLResponse 아님") -// SessionController.shared.expireAndRouteToLogin() + // SessionController.shared.expireAndRouteToLogin() return nil } @@ -187,19 +166,19 @@ final class CourseRepositoryImpl: CourseRepository { let decoded = try decoder.decode(APIResponse.self, from: data) guard let result = decoded.result else { print("reissue: result nil (responseCode=\(decoded.responseCode))") -// SessionController.shared.expireAndRouteToLogin() + // SessionController.shared.expireAndRouteToLogin() return nil } return (accessToken: result.accessToken, refreshToken: result.refreshToken) } catch { print("reissue 디코딩 실패:", error) -// SessionController.shared.expireAndRouteToLogin() + // SessionController.shared.expireAndRouteToLogin() return nil } } catch { print("reissue 네트워크 오류:", error) -// SessionController.shared.expireAndRouteToLogin() + // SessionController.shared.expireAndRouteToLogin() return nil } } diff --git a/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewModel.swift b/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewModel.swift index 7644eea7..bcfe7b6c 100644 --- a/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewModel.swift +++ b/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewModel.swift @@ -30,63 +30,4 @@ final class PushAlarmViewModel: BaseViewModel { self.pushAlarmPatchUseCase = pushAlarmPatchUseCase self.locationStateHolder = locationStateHolder } - - func signUp() { - guard let provider = UserDefaultsWrapper.shared.integer(forKey: UserDefaultsWrapper.Key.provider.rawValue) else { - print("플랫폼 정보 없음") - return - } - - guard let fcmToken = AppDIContainer.shared.tokenStorage.fcmToken else { - print("FCM 토큰이 없습니다.") - return - } - - let request = SignUpRequest( - provider: provider, - userName: "", - address: locationStateHolder.address ?? "", - lat: locationStateHolder.currentLocation?.latitude ?? 0.0, - lon: locationStateHolder.currentLocation?.longitude ?? 0.0, - alertFrequencies: [1, 10], - fcmToken: fcmToken - ) - - // TODO: 위치 변경해야할 듯 - UserDefaultsWrapper.shared.set(locationStateHolder.currentLocation?.latitude ?? 0.0, forKey: UserDefaultsWrapper.Key.homeLat.rawValue) - UserDefaultsWrapper.shared.set(locationStateHolder.currentLocation?.longitude ?? 0.0, forKey: UserDefaultsWrapper.Key.homeLon.rawValue) - - Task { - do { - let response = try await signUpUseCase.excute(request) - - AppDIContainer.shared.tokenStorage.accessToken = response.accessToken - AppDIContainer.shared.tokenStorage.refreshToken = response.refreshToken - - UserDefaultsWrapper.shared.set(response.id, forKey: UserDefaultsWrapper.Key.userId.rawValue) - if let lat = response.lat, let lon = response.lon, let id = response.id { - UserDefaultsWrapper.shared.set(lat, forKey: UserDefaultsWrapper.Key.homeLat.rawValue) - UserDefaultsWrapper.shared.set(lon, forKey: UserDefaultsWrapper.Key.homeLon.rawValue) - UserDefaultsWrapper.shared.set(id, forKey: UserDefaultsWrapper.Key.userId - .rawValue) - UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.reVisit - .rawValue) - - print("회원가입 lat/lon 저장 완료: \(lat), \(lon)") - } else { - print("회원가입 응답에 lat/lon 없음") - } - - onFinish?(true) - } catch { - onFinish?(false) - } - } - } - - -// func pushAlarmPatch(selectedAlarms: [AlarmTimeOption]) async throws { -// let request = PushAlarmPatchRequest(alertFrequencies: selectedAlarms.map { $0.rawValue }) -// let response = try await pushAlarmPatchUseCase.pushAlarmPatch(request) -// } } diff --git a/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift b/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift index 0f5ee9e0..212cd1af 100644 --- a/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift +++ b/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift @@ -12,9 +12,15 @@ final class MyAccountViewModel: BaseViewModel { var signOutFinish: (() -> Void)? private let logoutUseCase: LogoutuseCase + private let tokenStorage: TokenStorage + private let locationStateHolder: LocationStateHolder - init(logoutUseCase: LogoutuseCase) { + init(logoutUseCase: LogoutuseCase, + tokenStorage: TokenStorage, + locationStateHolder: LocationStateHolder) { self.logoutUseCase = logoutUseCase + self.tokenStorage = tokenStorage + self.locationStateHolder = locationStateHolder } func logoutTapped() { @@ -24,10 +30,9 @@ final class MyAccountViewModel: BaseViewModel { AmplitudeManager.shared.track(.logout) AmplitudeManager.shared.reset() - AppDIContainer.shared.tokenStorage.clearAccessToken() - AppDIContainer.shared.tokenStorage.clearRefreshToken() + tokenStorage.clearAllTokens() UserDefaultsWrapper.shared.removeAll() - AppDIContainer.shared.locationStateHolder.clear() + locationStateHolder.clear() UserDefaults.standard.set(true, forKey: "IsAppFirstLaunchedEver") UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) diff --git a/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift b/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift index 23e4d074..9ea0f983 100644 --- a/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift +++ b/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift @@ -9,10 +9,16 @@ import Foundation final class WithdrawViewModel: BaseViewModel { var signOutFinish: (() -> Void)? - + private let tokenStorage: TokenStorage + private let locationStateHolder: LocationStateHolder private let signOutUseCase: SignOutUseCase - init(signOutUseCase: SignOutUseCase) { + + init(signOutUseCase: SignOutUseCase, + tokenStorage: TokenStorage, + locationStateHolder: LocationStateHolder) { self.signOutUseCase = signOutUseCase + self.tokenStorage = tokenStorage + self.locationStateHolder = locationStateHolder } func signOutTapped(_ request: WithdrawRequest) { @@ -28,9 +34,9 @@ final class WithdrawViewModel: BaseViewModel { ) AmplitudeManager.shared.reset() - AppDIContainer.shared.tokenStorage.clearAllTokens() + tokenStorage.clearAllTokens() UserDefaultsWrapper.shared.removeAll() - AppDIContainer.shared.locationStateHolder.clear() + locationStateHolder.clear() UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.hasSeenIntro.rawValue) signOutFinish?() } catch { From e58aa72c681b03625f0db99a13ed7d0510bdc45d Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Thu, 19 Mar 2026 00:31:29 +0900 Subject: [PATCH 12/30] =?UTF-8?q?[BUGFIX]=20=EC=9C=A0=EC=A0=80=20=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85(DI)=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/DIContainer/Login/LoginDIContainer.swift | 2 +- .../DIContainer/Setting/MyAccountDIContainer.swift | 2 +- .../Setting/PushRegisterDIContainer.swift | 12 +++++++++--- .../App/DIContainer/Splash/SplashDIContainer.swift | 2 +- .../User/Home/HomeRegisterDIContainer.swift | 2 +- Atcha-iOS/Data/Repository/UserRepositoryImpl.swift | 6 ++++-- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Atcha-iOS/App/DIContainer/Login/LoginDIContainer.swift b/Atcha-iOS/App/DIContainer/Login/LoginDIContainer.swift index b257274b..181afd36 100644 --- a/Atcha-iOS/App/DIContainer/Login/LoginDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Login/LoginDIContainer.swift @@ -18,7 +18,7 @@ final class LoginDIContainer { } func makeLoginUseCase() -> LoginUseCase { - let repository: UserRepository = UserRepositoryImpl(apiService: apiService) + let repository: UserRepository = UserRepositoryImpl(apiService: apiService, tokenStorage: tokenStorage) return LoginUseCaseImpl(repository: repository) } diff --git a/Atcha-iOS/App/DIContainer/Setting/MyAccountDIContainer.swift b/Atcha-iOS/App/DIContainer/Setting/MyAccountDIContainer.swift index 6855198d..1a242995 100644 --- a/Atcha-iOS/App/DIContainer/Setting/MyAccountDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Setting/MyAccountDIContainer.swift @@ -12,7 +12,7 @@ final class MyAccountDIContainer { private let tokenStorage: TokenStorage private let locationStateHolder: LocationStateHolder - private lazy var repository: UserRepository = UserRepositoryImpl(apiService: apiService) + private lazy var repository: UserRepository = UserRepositoryImpl(apiService: apiService, tokenStorage: tokenStorage) init(apiService: APIService, tokenStorage: TokenStorage, diff --git a/Atcha-iOS/App/DIContainer/Setting/PushRegisterDIContainer.swift b/Atcha-iOS/App/DIContainer/Setting/PushRegisterDIContainer.swift index 0d28dd0b..8ab4c1ad 100644 --- a/Atcha-iOS/App/DIContainer/Setting/PushRegisterDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Setting/PushRegisterDIContainer.swift @@ -10,16 +10,22 @@ import Foundation final class PushRegisterDIContainer { private let apiService: APIService private let locationStateHolder: LocationStateHolder + private let tokenStorage: TokenStorage init(apiService: APIService, - locationStateHolder: LocationStateHolder) { + locationStateHolder: LocationStateHolder, + tokenStorage: TokenStorage) { self.apiService = apiService self.locationStateHolder = locationStateHolder + self.tokenStorage = tokenStorage } func makePushRegisterViewModel(context: PushAlarmContext) -> PushAlarmViewModel { - let signUpUseCase: SignUpUseCase = SignUpUseCaseImpl(repository: UserRepositoryImpl(apiService: apiService)) - let pushAlarmPatchUseCase: PushAlarmPatchUseCase = PushAlarmPatchUseCaseImpl(repository: UserRepositoryImpl(apiService: apiService)) + let userRepository = UserRepositoryImpl(apiService: apiService, tokenStorage: tokenStorage) + + let signUpUseCase: SignUpUseCase = SignUpUseCaseImpl(repository: userRepository) + let pushAlarmPatchUseCase: PushAlarmPatchUseCase = PushAlarmPatchUseCaseImpl(repository: userRepository) + return PushAlarmViewModel(context: context, signUpUseCase: signUpUseCase, pushAlarmPatchUseCase: pushAlarmPatchUseCase, diff --git a/Atcha-iOS/App/DIContainer/Splash/SplashDIContainer.swift b/Atcha-iOS/App/DIContainer/Splash/SplashDIContainer.swift index 0ce117b9..c523b766 100644 --- a/Atcha-iOS/App/DIContainer/Splash/SplashDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Splash/SplashDIContainer.swift @@ -18,7 +18,7 @@ final class SplashDIContainer { } func makeFetchUserUseCase() -> FetchUserUseCase { - let repository: UserRepository = UserRepositoryImpl(apiService: apiService) + let repository: UserRepository = UserRepositoryImpl(apiService: apiService, tokenStorage: tokenStorage) return FetchUserUseCaseImpl(repositoy: repository) } diff --git a/Atcha-iOS/App/DIContainer/User/Home/HomeRegisterDIContainer.swift b/Atcha-iOS/App/DIContainer/User/Home/HomeRegisterDIContainer.swift index 36cce66f..ca2381c8 100644 --- a/Atcha-iOS/App/DIContainer/User/Home/HomeRegisterDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/User/Home/HomeRegisterDIContainer.swift @@ -22,7 +22,7 @@ final class HomeRegisterDIContainer { private lazy var authorizationRequestUseCase = RequestLocationAuthorizationUseCaseImpl(repository: PermissionRepositoryImpl()) private lazy var addressRepository: AddressRepository = AddressRepositoryImpl(apiService: apiService) - private lazy var userRepository: UserRepository = UserRepositoryImpl(apiService: apiService) + private lazy var userRepository: UserRepository = UserRepositoryImpl(apiService: apiService, tokenStorage: tokenStorage) private lazy var searchAddressUseCase: SearchAddressUseCase = SearchAddressUseCaseImpl(repository: addressRepository) private lazy var homePatchUseCase: HomePatchUseCase = HomePatchUseCaseImpl(repository: userRepository) private lazy var streamUseCase: ObserveLocationStreamUseCase = ObserLocationStreamUseCaseImpl(repository: LocationStreamRepositoryImpl()) diff --git a/Atcha-iOS/Data/Repository/UserRepositoryImpl.swift b/Atcha-iOS/Data/Repository/UserRepositoryImpl.swift index 7e6cba93..59770740 100644 --- a/Atcha-iOS/Data/Repository/UserRepositoryImpl.swift +++ b/Atcha-iOS/Data/Repository/UserRepositoryImpl.swift @@ -10,9 +10,11 @@ import Alamofire final class UserRepositoryImpl: UserRepository { private let apiService: APIService + private let tokenStorage: TokenStorage - init(apiService: APIService) { + init(apiService: APIService, tokenStorage: TokenStorage) { self.apiService = apiService + self.tokenStorage = tokenStorage } func fetchUser() async throws -> UserInfoResponse { @@ -54,7 +56,7 @@ final class UserRepositoryImpl: UserRepository { method: .get, parameters: [ "provider": "\(request.provider)", - "fcmToken": AppDIContainer.shared.tokenStorage.fcmToken ?? "" + "fcmToken": tokenStorage.fcmToken ?? "" ], headers: ["Authorization": "Bearer \(request.accessToken)"] ) From e4722dbc828e1009bb0777701865d80209ff56fa Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Thu, 19 Mar 2026 00:34:03 +0900 Subject: [PATCH 13/30] =?UTF-8?q?[BUGFIX]=20=EB=8C=80=EC=A4=91=EA=B5=90?= =?UTF-8?q?=ED=86=B5=20=EB=B6=80=EB=B6=84=20=EA=B0=95=EC=A0=9C=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EC=82=BD=EC=9E=85=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Atcha-iOS/App/DIContainer/AppCompositionRoot.swift | 2 +- Atcha-iOS/Data/Repository/BusInfoRepositoryImpl.swift | 3 +-- Atcha-iOS/Data/Repository/SubwayInfoRepositoryImpl.swift | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift b/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift index 8928ae4e..5c29f12e 100644 --- a/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift +++ b/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift @@ -32,7 +32,7 @@ final class AppCompositionRoot { // Core self.tokenStorage = TokenStorageImpl() self.networkDIContainer = NetworkDIContainer(tokenStorage: tokenStorage) - self.apiService = networkDIContainer.makeAPIService() + self.apiService = networkDIContainer.makeAPIService(useInterceptor: true) self.noHeaderApiService = networkDIContainer.makeAPIService(useInterceptor: false) self.locationStateHolder = LocationStateHolder() diff --git a/Atcha-iOS/Data/Repository/BusInfoRepositoryImpl.swift b/Atcha-iOS/Data/Repository/BusInfoRepositoryImpl.swift index c2e984f3..2102af20 100644 --- a/Atcha-iOS/Data/Repository/BusInfoRepositoryImpl.swift +++ b/Atcha-iOS/Data/Repository/BusInfoRepositoryImpl.swift @@ -32,8 +32,7 @@ final class BusInfoRepositoryImpl: BusInfoRepository { Endpoint( path: "/routes/user-routes/bus-arrival", method: .get, - parameters: ["routeName" : request], - headers: ["Authorization": "Bearer \(AppDIContainer.shared.tokenStorage.accessToken ?? "")"] + parameters: ["routeName" : request] ) ) } diff --git a/Atcha-iOS/Data/Repository/SubwayInfoRepositoryImpl.swift b/Atcha-iOS/Data/Repository/SubwayInfoRepositoryImpl.swift index 46e32116..9f4b6b5d 100644 --- a/Atcha-iOS/Data/Repository/SubwayInfoRepositoryImpl.swift +++ b/Atcha-iOS/Data/Repository/SubwayInfoRepositoryImpl.swift @@ -12,6 +12,7 @@ final class SubwayInfoRepositoryImpl: SubwayInfoRepository { private let apiService: APIService + init(apiService: APIService) { self.apiService = apiService } @@ -23,7 +24,6 @@ final class SubwayInfoRepositoryImpl: SubwayInfoRepository { method: .get, parameters: ["routeName": request.routeName], encoding: URLEncoding.queryString, - headers: ["Authorization": "Bearer \(AppDIContainer.shared.tokenStorage.accessToken ?? "")"], ) let result: [SubwayRealTimeInfoResponse] = try await apiService.request(endpoint) From 5c14a6902e29c990081b1208b42f4ec7fed53a59 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Thu, 19 Mar 2026 00:43:23 +0900 Subject: [PATCH 14/30] =?UTF-8?q?[BUGFIX]=20=ED=86=A0=ED=81=B0=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EC=85=89=ED=84=B0=20=EC=98=88=EC=99=B8=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/DIContainer/AppCompositionRoot.swift | 2 +- .../Onboarding/OnboardingDIContainer.swift | 15 +++++++++++---- .../App/DIContainer/User/MyPageDIContainer.swift | 2 +- .../Core/Network/Token/TokenInterceptor.swift | 13 +++++++++---- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift b/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift index 5c29f12e..adc27d04 100644 --- a/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift +++ b/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift @@ -41,7 +41,7 @@ final class AppCompositionRoot { tokenStorage: tokenStorage) self.loginDIContainer = LoginDIContainer(apiService: apiService, tokenStorage: tokenStorage) self.onboardingDIContainer = OnboardingDIContainer(apiService: apiService, - locationStateHolder: locationStateHolder) + locationStateHolder: locationStateHolder, tokenStorage: tokenStorage) self.mainDIContainer = MainDIContainer(apiService: apiService, locationStateHolder: locationStateHolder, tokenStorage: tokenStorage) diff --git a/Atcha-iOS/App/DIContainer/Onboarding/OnboardingDIContainer.swift b/Atcha-iOS/App/DIContainer/Onboarding/OnboardingDIContainer.swift index 08dbc7d2..1681b178 100644 --- a/Atcha-iOS/App/DIContainer/Onboarding/OnboardingDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Onboarding/OnboardingDIContainer.swift @@ -11,23 +11,30 @@ import Foundation final class OnboardingDIContainer { private let apiService: APIService private let locationStateHolder: LocationStateHolder + private let tokenStorage: TokenStorage private lazy var homeDI: HomeRegisterDIContainer = { - HomeRegisterDIContainer(apiService: apiService, locationStateHolder: locationStateHolder) + HomeRegisterDIContainer(apiService: apiService, + locationStateHolder: locationStateHolder, + tokenStorage: tokenStorage) }() private lazy var pushDI: PushRegisterDIContainer = { - PushRegisterDIContainer(apiService: apiService, locationStateHolder: locationStateHolder) + PushRegisterDIContainer(apiService: apiService, + locationStateHolder: locationStateHolder, + tokenStorage: tokenStorage) }() - + private lazy var permissionDI: PermissionDIContainer = { PermissionDIContainer(locationStateHolder: locationStateHolder) }() init(apiService: APIService, - locationStateHolder: LocationStateHolder) { + locationStateHolder: LocationStateHolder, + tokenStorage: TokenStorage) { self.apiService = apiService self.locationStateHolder = locationStateHolder + self.tokenStorage = tokenStorage } func makeHomeRegisterViewModel() -> HomeRegisterViewModel { homeDI.makeHomeRegisterViewModel(context: .onboarding) } diff --git a/Atcha-iOS/App/DIContainer/User/MyPageDIContainer.swift b/Atcha-iOS/App/DIContainer/User/MyPageDIContainer.swift index 383cfba7..d16ba2ff 100644 --- a/Atcha-iOS/App/DIContainer/User/MyPageDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/User/MyPageDIContainer.swift @@ -19,7 +19,7 @@ final class MyPageDIContainer { HomeRegisterDIContainer(apiService: apiService, locationStateHolder: locationStateHolder, tokenStorage: tokenStorage) }() private lazy var pushDI: PushRegisterDIContainer = { - PushRegisterDIContainer(apiService: apiService, locationStateHolder: locationStateHolder) + PushRegisterDIContainer(apiService: apiService, locationStateHolder: locationStateHolder, tokenStorage: tokenStorage) }() private lazy var myAccountDI: MyAccountDIContainer = { MyAccountDIContainer(apiService: apiService, tokenStorage: tokenStorage, locationStateHolder: locationStateHolder) diff --git a/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift b/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift index 5638b1b2..fa7ccc5b 100644 --- a/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift +++ b/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift @@ -28,11 +28,16 @@ final class TokenInterceptor: RequestInterceptor, @unchecked Sendable { var request = urlRequest let path = request.url?.path ?? "" - // if request.value(forHTTPHeaderField: "Authorization") != nil { - // completion(.success(request)); return - // } + let publicPaths = [ + "/auth/check", + "/auth/login", + "/app/version", + "/locations", + "/locations/is-service-region", + "/api/locations/rgeo" + ] - if path.contains("/auth/check") || path.contains("/auth/login") { + if publicPaths.contains(where: { path.contains($0) }) { completion(.success(request)) return } From 2ceaac79a0061bafe8f061cf527c325e9b34dbae Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Thu, 19 Mar 2026 01:51:06 +0900 Subject: [PATCH 15/30] =?UTF-8?q?[BUGFIX]=20=EB=B0=B1=EA=B7=B8=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=20=ED=82=A4=EC=B2=B4=EC=9D=B8=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EB=B6=88=EA=B0=80=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20?= =?UTF-8?q?=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC(400)=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Discord/DiscordWebhookManager.swift | 2 +- .../Core/Network/API/APIServiceImpl.swift | 10 +-- .../Core/Network/Token/TokenInterceptor.swift | 9 +- .../Core/Network/Token/TokenStorage.swift | 89 +++++++++++-------- 4 files changed, 59 insertions(+), 51 deletions(-) diff --git a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift index 47593c5a..eab4bf1e 100644 --- a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift +++ b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift @@ -11,7 +11,7 @@ final class DiscordWebhookManager { static let shared = DiscordWebhookManager() private init() {} - private let webhookURLString = "https://discord.com/api/webhooks/1483605689031983336/gqPjN3OU9ciMCF5qgPodT_KV3fE1giuuD6M4ODCJdenNru8UHezuYZfWBfc4Vnj4GWIZ" + private let webhookURLString = "https://discord.com/api/webhooks/1418789389923913758/AKnOWLlcYPFR4gVlvpKIBNEi1IdutibBCu8M2FLP2c2MDomxcBAvSLAm1lpB4WMWCeUm" func sendErrorLog( statusCode: Int, diff --git a/Atcha-iOS/Core/Network/API/APIServiceImpl.swift b/Atcha-iOS/Core/Network/API/APIServiceImpl.swift index 23f310ed..844d7496 100644 --- a/Atcha-iOS/Core/Network/API/APIServiceImpl.swift +++ b/Atcha-iOS/Core/Network/API/APIServiceImpl.swift @@ -8,16 +8,10 @@ import Alamofire import Foundation -private let trustManager = ServerTrustManager(evaluators: [ - "atcha.p-e.kr": DisabledTrustEvaluator() -]) -private let insecureSession = Session(serverTrustManager: trustManager) - final class APIServiceImpl: APIService, @unchecked Sendable { private let session: Session - /// 기본 초기화 - SSL 우회 세션 사용 - init(session: Session = insecureSession) { + init(session: Session) { self.session = session } @@ -117,7 +111,7 @@ extension APIServiceImpl { let method = endpoint.method.rawValue.uppercased() let path = endpoint.path let requestHeaders = endpoint.headers?.dictionary ?? [:] - + var responseCode = "UNKNOWN" var serverMessage = "(메시지 없음)" var serverPath = path diff --git a/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift b/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift index fa7ccc5b..ce6be6d1 100644 --- a/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift +++ b/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift @@ -32,15 +32,14 @@ final class TokenInterceptor: RequestInterceptor, @unchecked Sendable { "/auth/check", "/auth/login", "/app/version", - "/locations", "/locations/is-service-region", "/api/locations/rgeo" ] - if publicPaths.contains(where: { path.contains($0) }) { - completion(.success(request)) - return - } + if publicPaths.contains(where: { path.hasSuffix($0) }) { + completion(.success(request)) + return + } if path.contains("/auth/logout") { if let refreshToken = tokenStorage.refreshToken { diff --git a/Atcha-iOS/Core/Network/Token/TokenStorage.swift b/Atcha-iOS/Core/Network/Token/TokenStorage.swift index 85658565..7b2f9800 100644 --- a/Atcha-iOS/Core/Network/Token/TokenStorage.swift +++ b/Atcha-iOS/Core/Network/Token/TokenStorage.swift @@ -28,92 +28,107 @@ protocol TokenStorage { final class TokenStorageImpl: TokenStorage { private let keychain = KeychainWrapper() + private var cachedAccessToken: String? + private var cachedRefreshToken: String? + private var cachedFCMToken: String? private let accessTokenKey: KeychainWrapper.Key = "accessToken" private let refreshTokenKey: KeychainWrapper.Key = "refreshToken" private let fcmTokenKey: KeychainWrapper.Key = "fcmToken" var accessToken: String? { - get { keychain.string(forKey: accessTokenKey.rawValue) } - set { - if let token = newValue { - keychain.set(token, forKey: accessTokenKey.rawValue) - } else { - keychain.remove(forKey: accessTokenKey.rawValue) + get { + if let cached = cachedAccessToken { return cached } + let token = keychain.string(forKey: accessTokenKey.rawValue) + cachedAccessToken = token + return token + } + set { + cachedAccessToken = newValue + if let token = newValue { keychain.set(token, forKey: accessTokenKey.rawValue) } + else { keychain.remove(forKey: accessTokenKey.rawValue) } } } - } - - var refreshToken: String? { - get { keychain.string(forKey: refreshTokenKey.rawValue) } - set { - if let token = newValue { - keychain.set(token, forKey: refreshTokenKey.rawValue) - } else { - keychain.remove(forKey: refreshTokenKey.rawValue) + + var refreshToken: String? { + get { + if let cached = cachedRefreshToken { return cached } + let token = keychain.string(forKey: refreshTokenKey.rawValue) + cachedRefreshToken = token + return token + } + set { + cachedRefreshToken = newValue + if let token = newValue { keychain.set(token, forKey: refreshTokenKey.rawValue) } + else { keychain.remove(forKey: refreshTokenKey.rawValue) } } } - } - - var fcmToken: String? { - get { keychain.string(forKey: fcmTokenKey.rawValue) } - set { - if let token = newValue { - keychain.set(token, forKey: fcmTokenKey.rawValue) - } else { - keychain.remove(forKey: fcmTokenKey.rawValue) + + var fcmToken: String? { + get { + if let cached = cachedFCMToken { return cached } + let token = keychain.string(forKey: fcmTokenKey.rawValue) + cachedFCMToken = token + return token + } + set { + cachedFCMToken = newValue + if let token = newValue { keychain.set(token, forKey: fcmTokenKey.rawValue) } + else { keychain.remove(forKey: fcmTokenKey.rawValue) } } } - } } // MARK: - Delete extension TokenStorageImpl { + // 프로퍼티 setter를 사용하면 캐시와 키체인이 동시에 지워집니다! func clearAllTokens() { - keychain.remove(forKey: accessTokenKey.rawValue) - keychain.remove(forKey: refreshTokenKey.rawValue) -// keychain.remove(forKey: fcmTokenKey.rawValue) + self.accessToken = nil + self.refreshToken = nil + // self.fcmToken = nil // 기존 로직처럼 FCM 토큰은 유지 } func clearAccessToken() { - keychain.remove(forKey: accessTokenKey.rawValue) + self.accessToken = nil } func clearRefreshToken() { - keychain.remove(forKey: refreshTokenKey.rawValue) + self.refreshToken = nil } func clearFCMToken() { - keychain.remove(forKey: fcmTokenKey.rawValue) + self.fcmToken = nil } } // MARK: - Update extension TokenStorageImpl { + // 프로퍼티 setter를 사용하면 캐시와 키체인이 동시에 업데이트됩니다! func updateAccessToken(_ token: String) { - keychain.set(token, forKey: accessTokenKey.rawValue) + self.accessToken = token } func updateRefreshToken(_ token: String) { - keychain.set(token, forKey: refreshTokenKey.rawValue) + self.refreshToken = token } func updateFCMToken(_ token: String) { - keychain.set(token, forKey: fcmTokenKey.rawValue) + self.fcmToken = token } } // MARK: - Check extension TokenStorageImpl { + // 메모리 캐시까지 확인하도록 getter를 통과하게 만듭니다. func hasAccessToken() -> Bool { - return keychain.string(forKey: accessTokenKey.rawValue) != nil + return self.accessToken != nil } func hasRefreshToken() -> Bool { - return keychain.string(forKey: refreshTokenKey.rawValue) != nil + return self.refreshToken != nil } func hasFCMToken() -> Bool { - return keychain.string(forKey: fcmTokenKey.rawValue) != nil + return self.fcmToken != nil } } From 1d3985613cd1c870dedfba6620f86d3f2b98c40d Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Thu, 19 Mar 2026 01:54:48 +0900 Subject: [PATCH 16/30] =?UTF-8?q?[BUGFIX]=20=EC=97=90=EB=9F=AC=EC=B1=84?= =?UTF-8?q?=EB=84=90=20=EC=B4=88=EA=B8=B0=ED=99=94=EB=A1=9C=20=EC=9B=B9?= =?UTF-8?q?=ED=9B=85=20URL=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift index eab4bf1e..ce9f1120 100644 --- a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift +++ b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift @@ -11,7 +11,7 @@ final class DiscordWebhookManager { static let shared = DiscordWebhookManager() private init() {} - private let webhookURLString = "https://discord.com/api/webhooks/1418789389923913758/AKnOWLlcYPFR4gVlvpKIBNEi1IdutibBCu8M2FLP2c2MDomxcBAvSLAm1lpB4WMWCeUm" + private let webhookURLString = "https://discord.com/api/webhooks/1483870710018474066/qyzNBI1Bwr7J5tQDrPx2-mOcej_9yLSOk5Bmlmza2D-4nSWqvWgcMd4CZDziG4vkpKrm" func sendErrorLog( statusCode: Int, From 6b169fc50dd17971e23087bd53972913553bbce1 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Fri, 20 Mar 2026 13:25:36 +0900 Subject: [PATCH 17/30] =?UTF-8?q?[BUGFIX]=20500=EB=B2=88=EB=8C=80=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=97=90=EB=9F=AC=20=EB=B0=9C=EC=83=9D=20?= =?UTF-8?q?=EC=8B=9C=EC=97=90=EB=A7=8C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EB=85=B8=EC=B6=9C=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/BaseViewController.swift | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Atcha-iOS/Presentation/Common/BaseViewController.swift b/Atcha-iOS/Presentation/Common/BaseViewController.swift index 7352c9d1..f3a7f5af 100644 --- a/Atcha-iOS/Presentation/Common/BaseViewController.swift +++ b/Atcha-iOS/Presentation/Common/BaseViewController.swift @@ -220,12 +220,25 @@ class BaseViewController: UIViewController { object: nil ) } - - @objc private func handleServerError(_ notification: Notification) { - guard self.presentedViewController == nil else { return } - DispatchQueue.main.async { [weak self] in - self?.showAtchaErrorPopup() + @objc private func handleServerError(_ notification: Notification) { + // 1. 전달된 오브젝트가 APIError인지 확인 + guard let apiError = notification.object as? APIError else { return } + + // 2. 에러 케이스와 상태 코드 추출 (APIError가 statusCode를 가지고 있다고 가정) + if case .serverError(let statusCode) = apiError { + + // 3. 500번대 에러인 경우에만 팝업 노출 + if (500...599).contains(statusCode) { + guard self.presentedViewController == nil else { return } + + DispatchQueue.main.async { [weak self] in + self?.showAtchaErrorPopup() + } + } else { + // 400번대 등 기타 에러는 팝업을 띄우지 않고 로그만 남기거나 별도 처리 + print("UI 팝업 제외 대상 에러: \(statusCode)") + } } } From d51c532383bb0f9f21a6879fa23bfd6627443c5b Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Fri, 20 Mar 2026 13:29:16 +0900 Subject: [PATCH 18/30] =?UTF-8?q?[BUGFIX]=20=EC=97=90=EB=9F=AC=20=ED=8C=9D?= =?UTF-8?q?=EC=97=85=20=EC=A4=91=EB=B3=B5=20=EB=85=B8=EC=B6=9C=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Atcha-iOS/Presentation/Common/BaseViewController.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Atcha-iOS/Presentation/Common/BaseViewController.swift b/Atcha-iOS/Presentation/Common/BaseViewController.swift index f3a7f5af..9b1c7642 100644 --- a/Atcha-iOS/Presentation/Common/BaseViewController.swift +++ b/Atcha-iOS/Presentation/Common/BaseViewController.swift @@ -9,6 +9,10 @@ import UIKit import Combine import CoreLocation +private struct ErrorState { + static var isShowing500Error = false +} + class BaseViewController: UIViewController { var activePermissionToast: AtchaActionToast? var activeAlarmPermissionToast: AtchaActionToast? @@ -229,7 +233,7 @@ class BaseViewController: UIViewController { if case .serverError(let statusCode) = apiError { // 3. 500번대 에러인 경우에만 팝업 노출 - if (500...599).contains(statusCode) { + if (500...599).contains(statusCode) && !ErrorState.isShowing500Error { guard self.presentedViewController == nil else { return } DispatchQueue.main.async { [weak self] in @@ -243,11 +247,14 @@ class BaseViewController: UIViewController { } private func showAtchaErrorPopup() { + ErrorState.isShowing500Error = true + // 이전에 만드신 앗차팝업 호출 (에러 케이스용) let popupVM = AtchaPopupViewModel(info: .serverError) // Enum에 .serverError 추가 필요 let popupVC = AtchaPopupViewController(viewModel: popupVM) popupVC.confirmButton.addAction(UIAction { [weak popupVC] _ in + ErrorState.isShowing500Error = false popupVC?.dismiss(animated: false) }, for: .touchUpInside) From 93c7726a8254cedbdc007640dd601dc47fc75a30 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Fri, 20 Mar 2026 13:45:17 +0900 Subject: [PATCH 19/30] =?UTF-8?q?[BUGFIX]=20=EC=A7=80=ED=95=98=EC=B2=A0=20?= =?UTF-8?q?=EB=AA=A9=EC=A0=81=EC=A7=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EA=B0=80=20=EB=B9=88=20=EB=AC=B8=EC=9E=90=EC=97=B4("")?= =?UTF-8?q?=EC=9D=BC=20=EB=95=8C=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DetailRouteInfo/Cell/DetailRouteSubwayCell.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift index 68bcb1de..f0b2dbe4 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift @@ -461,7 +461,7 @@ extension DetailRouteSubwayCell { return } - if let destination = matched.destination { + if let destination = matched.destination, destination != "" { subwayDirectionLabel.attributedText = AtchaFont.B6_R_14("\(destination)행", color: .white) } else { subwayDirectionLabel.attributedText = AtchaFont.B6_R_14("", color: .white) From 3adfb3957c7fa7c800ebb2744a5222d522b35db7 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Fri, 20 Mar 2026 14:17:11 +0900 Subject: [PATCH 20/30] =?UTF-8?q?[FEAT]=20=EB=9D=BD=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=B0=20=EA=B2=80=EC=83=89=20=EC=A7=84=EC=9E=85=20=EC=8B=9C?= =?UTF-8?q?=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EC=8A=A4?= =?UTF-8?q?=ED=83=9D=20=EA=B3=84=EC=B8=B5=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Course/CourseDIContainer.swift | 5 +-- .../CourseSearchViewController.swift | 14 +++++++- .../CourseSearch/CourseSearchViewModel.swift | 11 +++++- .../Location/Coordinator/MainRoute.swift | 2 +- .../Location/MainViewController.swift | 2 +- .../Presentation/Location/MainViewModel.swift | 5 +-- .../Lock/LockViewController.swift | 2 +- .../Presentation/Main/MainCoordinator.swift | 35 +++++++++++++------ 8 files changed, 57 insertions(+), 19 deletions(-) diff --git a/Atcha-iOS/App/DIContainer/Course/CourseDIContainer.swift b/Atcha-iOS/App/DIContainer/Course/CourseDIContainer.swift index 688bddc4..9ef1a20a 100644 --- a/Atcha-iOS/App/DIContainer/Course/CourseDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Course/CourseDIContainer.swift @@ -24,7 +24,7 @@ final class CourseDIContainer { self.tokenStorage = tokenStorage } - func makeCourseSearchViewModel(startLat: String, startLon: String, startAddress: String) -> CourseSearchViewModel { + func makeCourseSearchViewModel(startLat: String, startLon: String, startAddress: String, context: CourseSearchContext) -> CourseSearchViewModel { let courseUseCase = CourseUseCaseImpl( repository: CourseRepositoryImpl(apiService: apiService, tokenStorage: tokenStorage) ) @@ -35,7 +35,8 @@ final class CourseDIContainer { alarmUseCase: alarmUseCase, startLat: startLat, startLon: startLon, - startAddress: startAddress) + startAddress: startAddress, + context: context) } func makeCourseSearchViewController(viewModel: CourseSearchViewModel) -> UIViewController { diff --git a/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift b/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift index 8c908ec3..ea88086c 100644 --- a/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift +++ b/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift @@ -13,7 +13,7 @@ final class CourseSearchViewController: BaseViewController { let address = wrapper.string(forKey: UserDefaultsWrapper.Key.startAddress.rawValue) ?? "" amp_track(.later_course_click) - viewModel.routerHandler?(.courseSearch(startLat: lat, startLon: lon, startAddress: address)) + viewModel.routerHandler?(.courseSearch(startLat: lat, startLon: lon, startAddress: address, context: .afterReigster)) } private func observeAlarmTimeout() { diff --git a/Atcha-iOS/Presentation/Main/MainCoordinator.swift b/Atcha-iOS/Presentation/Main/MainCoordinator.swift index 736777a5..56e99caa 100644 --- a/Atcha-iOS/Presentation/Main/MainCoordinator.swift +++ b/Atcha-iOS/Presentation/Main/MainCoordinator.swift @@ -93,11 +93,12 @@ final class MainCoordinator: NSObject { } myPageCoordinator.start() - case let .courseSearch(startLat, startLon, startAddress): + case let .courseSearch(startLat, startLon, startAddress, context): let courseDI = diContainer.makeCourseDIContainer() let vm = courseDI.makeCourseSearchViewModel(startLat: startLat, startLon: startLon, - startAddress: startAddress) + startAddress: startAddress, + context: context) let vc = courseDI.makeCourseSearchViewController(viewModel: vm) vm.getAlarmTapped = { [weak self] address, infos in guard let self else { return } @@ -130,7 +131,8 @@ final class MainCoordinator: NSObject { let searchVM = courseDI.makeCourseSearchViewModel( startLat: "\(coordinate.latitude)", startLon: "\(coordinate.longitude)", - startAddress: locationInfo.name ?? "주소 없음" + startAddress: locationInfo.name ?? "주소 없음", + context: .beforeRegister ) searchVM.getAlarmTapped = { [weak self] address, infos in @@ -192,7 +194,8 @@ final class MainCoordinator: NSObject { let searchVM = courseDI.makeCourseSearchViewModel( startLat: "\(coordinate.latitude)", startLon: "\(coordinate.longitude)", - startAddress: locationInfo.name ?? "주소 없음" + startAddress: locationInfo.name ?? "주소 없음", + context: .beforeRegister ) searchVM.getAlarmTapped = { [weak self] address, infos in @@ -234,7 +237,8 @@ final class MainCoordinator: NSObject { let searchVM = courseDI.makeCourseSearchViewModel( startLat: "\(coordinate.latitude)", startLon: "\(coordinate.longitude)", - startAddress: locationInfo.name ?? "주소 없음" + startAddress: locationInfo.name ?? "주소 없음", + context: .beforeRegister ) searchVM.getAlarmTapped = { [weak self] address, infos in @@ -298,11 +302,22 @@ final class MainCoordinator: NSObject { infos: info, context: .afterReigster)) } - case .courseSearch(let startLat, let startLon, let startAddress): - self?.navigationController.dismiss(animated: false) { - self?.handle(route: .courseSearch(startLat: startLat, - startLon: startLon, - startAddress: startAddress)) + case .courseSearch(let startLat, let startLon, let startAddress, _): + self?.navigationController.dismiss(animated: false) { [weak self] in + guard let self = self else { return } + + let wrapper = UserDefaultsWrapper.shared + let currentInfo = wrapper.object(forKey: UserDefaultsWrapper.Key.legInfo.rawValue, of: LegInfo.self) + let currentAddress = wrapper.string(forKey: UserDefaultsWrapper.Key.addressDesc.rawValue) ?? "" + + if let info = currentInfo { + self.handle(route: .detailRoute(address: currentAddress, infos: info, context: .afterReigster)) + } + + self.handle(route: .courseSearch(startLat: startLat, + startLon: startLon, + startAddress: startAddress, + context: .afterReigster)) } case .dismissLockScreen: self?.navigationController.dismiss(animated: true) From 0c30f43eac74fc20ce447a80beb1acb36511a617 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Fri, 20 Mar 2026 14:37:55 +0900 Subject: [PATCH 21/30] =?UTF-8?q?[FEAT]=20=EC=95=B1=20=EC=A2=85=EB=A3=8C?= =?UTF-8?q?=20=EC=8B=9C=20=EC=95=8C=EB=9E=8C=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=B6=84=EA=B8=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=98=B5=EC=85=94=EB=84=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Atcha-iOS/App/SceneDelegate.swift | 20 ++++++++++++++++--- .../Presentation/Popup/AtchaPopupInfo.swift | 2 +- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Atcha-iOS/App/SceneDelegate.swift b/Atcha-iOS/App/SceneDelegate.swift index 10804dd9..6c61134c 100644 --- a/Atcha-iOS/App/SceneDelegate.swift +++ b/Atcha-iOS/App/SceneDelegate.swift @@ -37,9 +37,23 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // This occurs shortly after the scene enters the background, or when its session is discarded. // Release any resources associated with this scene that can be re-created the next time the scene connects. // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). - if let _: LegInfo = UserDefaultsWrapper.shared.object(forKey: UserDefaultsWrapper.Key.legInfo.rawValue, of: LegInfo.self) { - AlarmManager.shared.sendBackgroundPush(title: "앗차를 다시 켜주세요", - body: "제 시간에 출발 시간을 알려드릴 수 있도록 앱을 다시 실행해 주세요.") + + let wrapper = UserDefaultsWrapper.shared + + if let _: LegInfo = wrapper.object(forKey: UserDefaultsWrapper.Key.legInfo.rawValue, of: LegInfo.self) { + let didFire = wrapper.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false + + if didFire { + AlarmManager.shared.sendBackgroundPush( + title: "앗차를 다시 켜주세요", + body: "목적지까지 안내할 수 있도록 앱을 다시 실행해 주세요" + ) + } else { + AlarmManager.shared.sendBackgroundPush( + title: "앗차를 다시 켜주세요", + body: "제 시간에 출발 시간을 알려드릴 수 있도록 앱을 다시 실행해 주세요." + ) + } } } diff --git a/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift b/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift index 50d350f5..ab22fc16 100644 --- a/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift +++ b/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift @@ -23,7 +23,7 @@ enum AtcahPopuInfo { case .logout: return "로그아웃하시겠어요?" case .withdraw: return "탈퇴하시겠어요?" case .alarm: return "막차 알람을 종료할까요?" - case .re_register: return "기존 막차 알림을 종료하고\n선택한 알림으로 변경할까요?" + case .re_register: return "기존 막차 알림을 종료하고\n선택한 알람으로 변경할까요?" case .course : return "배차 간격이 긴 버스가 포함되어\n환승 대기 시간이 길어질 수 있어요.\n막차 알람을 등록할까요?" case .announeExit: return "" case .alarmTimeout: return "예정된 출발 시간이 지나\n알람이 자동으로 종료됐어요" From 88d0df0ace47b01249226853dc19a530f60fe702 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Fri, 20 Mar 2026 15:16:38 +0900 Subject: [PATCH 22/30] =?UTF-8?q?[BUGFIX]=20=ED=95=98=EB=8B=A8=20=EB=B7=B0?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=84=20=EC=A0=84=ED=99=98=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EB=AC=B4=ED=95=9C=20?= =?UTF-8?q?=EB=A3=A8=ED=94=84=20=ED=81=AC=EB=9E=98=EC=8B=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Location/MainViewController.swift | 84 +++++++++++++------ .../Presentation/Location/MainViewModel.swift | 6 +- .../View/LastTrainDepartBottomView.swift | 62 +++++++++----- 3 files changed, 102 insertions(+), 50 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 331d6c58..94104372 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -605,33 +605,69 @@ extension MainViewController { .store(in: &cancellables) } +// private func bindLegPathUpdates() { +// viewModel.$legInfo +// .receive(on: DispatchQueue.main) +// .combineLatest(viewModel.$bottomType) +// .sink { [weak self] info, bottomType in +// self?.commonAlarmSetupView() +// self?.addRouteLine(pathInfos: info?.pathInfo ?? []) +// +// switch bottomType { +// case .departure: +// self?.shouldCenterToCurrentLocationOnce = false +// self?.lastTrainDepartView.setupLegInfo(info: info) +// default: do {} +// } +// +// self?.setupBottomType(bottomType) +// } +// .store(in: &cancellables) +// +// viewModel.$bottomType +// .removeDuplicates() +// .receive(on: RunLoop.main) +// .sink { [weak self] type in +// self?.setupBottomType(type) +// } +// .store(in: &cancellables) +// +// viewModel.$departureTime +// .compactMap { $0 } +// .receive(on: RunLoop.main) +// .sink { [weak self] time in +// self?.lastTrainDepartView.refreshDepartureTime(departureStr: time) +// } +// .store(in: &cancellables) +// } + private func bindLegPathUpdates() { - viewModel.$legInfo - .receive(on: DispatchQueue.main) - .combineLatest(viewModel.$bottomType) - .sink { [weak self] info, bottomType in - self?.commonAlarmSetupView() - self?.addRouteLine(pathInfos: info?.pathInfo ?? []) - - switch bottomType { - case .departure: - self?.shouldCenterToCurrentLocationOnce = false - self?.lastTrainDepartView.setupLegInfo(info: info) - default: do {} - } - - self?.setupBottomType(bottomType) - } - .store(in: &cancellables) - - viewModel.$bottomType - .removeDuplicates() - .receive(on: RunLoop.main) - .sink { [weak self] type in - self?.setupBottomType(type) + // 1. 경로 정보, 2. 알람 실행 여부, 3. 현재 바텀 뷰 타입을 묶어서 감시 + Publishers.CombineLatest3( + viewModel.$legInfo, + UserDefaults.standard.publisher(for: \.departureAlarmDidFire).removeDuplicates(), + viewModel.$bottomType + ) + .receive(on: RunLoop.main) + .sink { [weak self] info, isFired, bottomType in + guard let self = self else { return } + + // 지도 경로 선 그리기 및 뷰 설정 + self.commonAlarmSetupView() + self.addRouteLine(pathInfos: info?.pathInfo ?? []) + + // 핵심: 알람 상태(isFired)를 setupLegInfo에 함께 전달 + if bottomType == .departure { + // LastTrainDepartBottomView의 데이터를 업데이트 + self.lastTrainDepartView.setupLegInfo(info: info, isFired: isFired) } - .store(in: &cancellables) + + // 바텀 뷰 노출/숨김 처리 + self.setupBottomType(bottomType) + } + .store(in: &cancellables) + // departureTime 바인딩 (서버에서 실시간 시간이 갱신될 때를 위해 유지) viewModel.$departureTime .compactMap { $0 } .receive(on: RunLoop.main) diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index 13cb8d23..9824ad9e 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -164,9 +164,9 @@ final class MainViewModel: BaseViewModel{ addressDesc = address legInfo = info - let wrapper = UserDefaultsWrapper.shared - wrapper.set(address, forKey: UserDefaultsWrapper.Key.addressDesc.rawValue) - wrapper.set(info, forKey: UserDefaultsWrapper.Key.legInfo.rawValue) +// let wrapper = UserDefaultsWrapper.shared +// wrapper.set(address, forKey: UserDefaultsWrapper.Key.addressDesc.rawValue) +// wrapper.set(info, forKey: UserDefaultsWrapper.Key.legInfo.rawValue) setupLegInfo(info: info) } diff --git a/Atcha-iOS/Presentation/Location/View/LastTrainDepartBottomView.swift b/Atcha-iOS/Presentation/Location/View/LastTrainDepartBottomView.swift index 3a9bf776..79cd9a01 100644 --- a/Atcha-iOS/Presentation/Location/View/LastTrainDepartBottomView.swift +++ b/Atcha-iOS/Presentation/Location/View/LastTrainDepartBottomView.swift @@ -22,7 +22,7 @@ final class LastTrainDepartBottomView: UIView { private let titleView: UIView = UIView() private let trainTimeLabel: UILabel = UILabel() private let trainRigtImageView: UIImageView = UIImageView() -// private let reloadImageView: UIImageView = UIImageView() + // private let reloadImageView: UIImageView = UIImageView() private let timeView: UIView = UIView() private let hourTimeLabel: UILabel = UILabel() @@ -75,11 +75,11 @@ final class LastTrainDepartBottomView: UIView { trainRigtImageView.contentMode = .scaleAspectFit trainRigtImageView.tintColor = .gray500 -// reloadImageView.image = UIImage.refreshOutlined -// reloadImageView.contentMode = .scaleAspectFit -// reloadImageView.tintColor = .white -// reloadImageView.setContentHuggingPriority(.required, for: .horizontal) - + // reloadImageView.image = UIImage.refreshOutlined + // reloadImageView.contentMode = .scaleAspectFit + // reloadImageView.tintColor = .white + // reloadImageView.setContentHuggingPriority(.required, for: .horizontal) + hourTimeLabel.attributedText = AtchaFont.D2_EB_48("--", color: .white) minuteTimeLabel.attributedText = AtchaFont.D2_EB_48("--", color: .white) hourLabel.attributedText = AtchaFont.B1_R_17("시", color: .white) @@ -111,11 +111,11 @@ final class LastTrainDepartBottomView: UIView { make.size.equalTo(14) } -// reloadImageView.snp.makeConstraints { make in -// make.centerY.equalToSuperview() -// make.trailing.equalToSuperview() -// make.size.equalTo(28) -// } + // reloadImageView.snp.makeConstraints { make in + // make.centerY.equalToSuperview() + // make.trailing.equalToSuperview() + // make.size.equalTo(28) + // } timeView.snp.makeConstraints { make in make.leading.equalToSuperview().inset(16) @@ -161,8 +161,8 @@ final class LastTrainDepartBottomView: UIView { private func setupActions() { exitButton.addTarget(self, action: #selector(handleExitTapped), for: .touchUpInside) detailRoadMapButton.addTarget(self, action: #selector(handleDetailRoadTapped), for: .touchUpInside) -// reloadImageView.isUserInteractionEnabled = true -// reloadImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleReloadTapped))) + // reloadImageView.isUserInteractionEnabled = true + // reloadImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleReloadTapped))) timeView.isUserInteractionEnabled = true timeView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTimeTapped))) locationLabel.isUserInteractionEnabled = true @@ -180,17 +180,33 @@ final class LastTrainDepartBottomView: UIView { // MARK: Binding Leg Info extension LastTrainDepartBottomView { - func setupLegInfo(info: LegInfo?) { - guard let info, let departureStr = info.pathInfo.first?.departureDateTime else { return } - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" - formatter.locale = .current + func setupLegInfo(info: LegInfo?, isFired: Bool) { + guard let info = info, let timeText = info.trafficInfo.first?.timeText else { return } - if let _ = formatter.date(from: departureStr) { - if let (hour, minute) = departureStr.toHourMinute() { - hourTimeLabel.attributedText = AtchaFont.D2_EB_48(hour, color: .white) - minuteTimeLabel.attributedText = AtchaFont.D2_EB_48(minute, color: .white) - } + updateUIForAlarmStatus(isFired: isFired) + + let times = timeText.components(separatedBy: " ~ ").map { $0.trimmingCharacters(in: .whitespaces) } + + let targetTime: String + + if isFired { + targetTime = times.count > 1 ? times[1] : (times.first ?? "--:--") + } else { + targetTime = times.first ?? "--:--" + } + + let timeParts = targetTime.components(separatedBy: ":") + if timeParts.count == 2 { + let hour = timeParts[0] + let minute = timeParts[1] + + // 5. UI 적용 + hourTimeLabel.attributedText = AtchaFont.D2_EB_48(hour, color: .white) + minuteTimeLabel.attributedText = AtchaFont.D2_EB_48(minute, color: .white) + } else { + // 파싱 실패 시 기본값 + hourTimeLabel.attributedText = AtchaFont.D2_EB_48("--", color: .white) + minuteTimeLabel.attributedText = AtchaFont.D2_EB_48("--", color: .white) } } From 1e9b73aa017369156e172c9834092fd75b2aa226 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Fri, 20 Mar 2026 15:57:51 +0900 Subject: [PATCH 23/30] =?UTF-8?q?[FEAT]=20=EB=8F=84=EC=B0=A9=2010=EB=B6=84?= =?UTF-8?q?=20=ED=9B=84=20=EC=9E=90=EB=8F=99=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Manager/Alarm/AlarmManager.swift | 50 +++++++++++++++++++ .../Manager/Location/HomeArrivalManager.swift | 3 ++ .../Presentation/Location/MainViewModel.swift | 8 +++ 3 files changed, 61 insertions(+) diff --git a/Atcha-iOS/Core/Manager/Alarm/AlarmManager.swift b/Atcha-iOS/Core/Manager/Alarm/AlarmManager.swift index 765aab0e..991d08ce 100644 --- a/Atcha-iOS/Core/Manager/Alarm/AlarmManager.swift +++ b/Atcha-iOS/Core/Manager/Alarm/AlarmManager.swift @@ -34,6 +34,7 @@ final class AlarmManager { private var isPreviewing = false private var hapticEngine: CHHapticEngine? private var autoStopWorkItem: DispatchWorkItem? + private var arrivalTimeoutWorkItem: DispatchWorkItem? // MARK: - Init private init() { @@ -454,6 +455,7 @@ extension AlarmManager { enum AlarmNotificationID { static let autoStopInfo = "atcha.alarm.autostop" static let tenMinutesBefore = "atcha.alarm.tenMinutesBefore" + static let scheduledArrivalTimeout = "atcha.arrival.timeout" } extension AlarmManager { @@ -640,3 +642,51 @@ extension AlarmManager { DispatchQueue.main.asyncAfter(deadline: .now() + 120.0, execute: workItem) } } + +extension AlarmManager { + + // 도착 10분 후 자동 종료 예약 함수 + func scheduleArrivalTimeout(at arrivalDate: Date) { + // 기존에 예약된 게 있다면 먼저 취소 + cancelArrivalTimeout() + + let timeoutDate = arrivalDate.addingTimeInterval(10 * 60) // 도착 시간 + 10분 + let timeInterval = timeoutDate.timeIntervalSinceNow + + // 만약 이미 시간이 지났다면 예약하지 않음 + guard timeInterval > 0 else { return } + + // 1. 백그라운드용 로컬 푸시 예약 (시스템이 정확한 시간에 띄워줌) + let content = UNMutableNotificationContent() + content.title = "막차 안내 종료" + content.body = "예정된 도착 시간이 지나 알람이 자동으로 종료됐어요" + content.sound = .default + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: false) + let request = UNNotificationRequest( + identifier: AlarmNotificationID.scheduledArrivalTimeout, + content: content, + trigger: trigger + ) + UNUserNotificationCenter.current().add(request, withCompletionHandler: nil) + + // 2. 포그라운드(앱이 켜져 있을 때) 로직 처리를 위한 WorkItem + let workItem = DispatchWorkItem { + print(" 도착 10분 초과: 자동 종료 실행") + // 메인 화면 등에 신호를 보내서 팝업을 띄우고 상태를 정리함 + NotificationCenter.default.post(name: NSNotification.Name("scheduledArrivalDidTimeout"), object: nil) + } + + // 변수를 따로 저장해두어야 나중에 취소(reset)가 가능합니다. + // (클래스 상단에 private var arrivalTimeoutWorkItem: DispatchWorkItem? 를 선언해두세요) + self.arrivalTimeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + timeInterval, execute: workItem) + } + + // 예약 취소 함수 (집에 도착하거나 수동 종료했을 때 호출 필수!) + func cancelArrivalTimeout() { + arrivalTimeoutWorkItem?.cancel() + arrivalTimeoutWorkItem = nil + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [AlarmNotificationID.scheduledArrivalTimeout]) + } +} diff --git a/Atcha-iOS/Core/Manager/Location/HomeArrivalManager.swift b/Atcha-iOS/Core/Manager/Location/HomeArrivalManager.swift index eda71c91..d90fe001 100644 --- a/Atcha-iOS/Core/Manager/Location/HomeArrivalManager.swift +++ b/Atcha-iOS/Core/Manager/Location/HomeArrivalManager.swift @@ -39,6 +39,8 @@ final class HomeArrivalManager { if distance <= 50 { isArrivalSignalSent = true + AlarmManager.shared.cancelArrivalTimeout() + AlarmManager.shared.sendImmediateLocalPush( title: "막차 안내 종료", body: "목적지 부근에 도착했어요", @@ -51,5 +53,6 @@ final class HomeArrivalManager { func reset() { isArrivalSignalSent = false + AlarmManager.shared.cancelArrivalTimeout() } } diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index 9824ad9e..8ea78624 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -187,6 +187,8 @@ final class MainViewModel: BaseViewModel{ guard let arrivalDate = Calendar.current.date(byAdding: .minute, value: minutes, to: departureDate) else { return } + AlarmManager.shared.scheduleArrivalTimeout(at: arrivalDate) + print("departureDate : \(departureDate)") print("arrivalDate : \(arrivalDate)") let wrapper = UserDefaultsWrapper.shared @@ -378,6 +380,12 @@ final class MainViewModel: BaseViewModel{ // MARK: - 알림 취소 func alarmDelete() { + + stopAlarmTimer() + stopFinishAlarmTimer() + + AlarmManager.shared.cancelArrivalTimeout() + let wrapper = UserDefaultsWrapper.shared let savedLastRouteId: String? = wrapper.string( forKey: UserDefaultsWrapper.Key.lastRouteId.rawValue) From 945577b90ddd376cacbbd07d5b7e16092704dce1 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Fri, 20 Mar 2026 16:14:39 +0900 Subject: [PATCH 24/30] =?UTF-8?q?[FEAT]=20=EB=8F=84=EC=B0=A9=2010=EB=B6=84?= =?UTF-8?q?=20=ED=9B=84=20=EC=A2=85=EB=A3=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Location/MainViewController.swift | 137 ++++++++++++------ .../Presentation/Popup/AtchaPopupInfo.swift | 7 +- .../Popup/AtchaPopupViewController.swift | 4 +- 3 files changed, 96 insertions(+), 52 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 94104372..d90da7c4 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -311,6 +311,7 @@ extension MainViewController { bindPermissionAlert() bindAlarmFireStatus() observeArrival() + observeScheduledArrivalTimeout() observeAlarmTimeout() } @@ -474,7 +475,7 @@ extension MainViewController { popupVC?.dismiss(animated: false) self.viewModel.alarmDelete() - self.exitButtonTapped() + self.exitButtonTapped(showToast: true) amp_track(.alarm_force_stop) }, for: .touchUpInside) @@ -483,7 +484,7 @@ extension MainViewController { present(popupVC, animated: false) } - private func exitButtonTapped() { + private func exitButtonTapped(showToast: Bool) { // 가장 먼저 토스트 표시 상태로 변경 (이후 2.5초간 호출되는 모든 말풍선 로직 차단됨) isShowingToast = true @@ -518,14 +519,16 @@ extension MainViewController { UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) - // 토스트 띄우고 토스트 사라진 후 고정형 말풍선 띄우기 (재방문 상태) - showToastAndThen(message: "알람이 종료되었어요", delay: 2.5) { [weak self] in - guard let self = self else { return } - self.showOrUpdatePersistentBalloon( - isFirstVisit: false, - isServiceRegion: self.latestIsServiceRegion ?? false, - fareStr: self.latestFareString - ) + if showToast { + // 토스트 띄우고 토스트 사라진 후 고정형 말풍선 띄우기 (재방문 상태) + showToastAndThen(message: "알람이 종료되었어요", delay: 2.5) { [weak self] in + guard let self = self else { return } + self.showOrUpdatePersistentBalloon( + isFirstVisit: false, + isServiceRegion: self.latestIsServiceRegion ?? false, + fareStr: self.latestFareString + ) + } } } @@ -605,42 +608,42 @@ extension MainViewController { .store(in: &cancellables) } -// private func bindLegPathUpdates() { -// viewModel.$legInfo -// .receive(on: DispatchQueue.main) -// .combineLatest(viewModel.$bottomType) -// .sink { [weak self] info, bottomType in -// self?.commonAlarmSetupView() -// self?.addRouteLine(pathInfos: info?.pathInfo ?? []) -// -// switch bottomType { -// case .departure: -// self?.shouldCenterToCurrentLocationOnce = false -// self?.lastTrainDepartView.setupLegInfo(info: info) -// default: do {} -// } -// -// self?.setupBottomType(bottomType) -// } -// .store(in: &cancellables) -// -// viewModel.$bottomType -// .removeDuplicates() -// .receive(on: RunLoop.main) -// .sink { [weak self] type in -// self?.setupBottomType(type) -// } -// .store(in: &cancellables) -// -// viewModel.$departureTime -// .compactMap { $0 } -// .receive(on: RunLoop.main) -// .sink { [weak self] time in -// self?.lastTrainDepartView.refreshDepartureTime(departureStr: time) -// } -// .store(in: &cancellables) -// } - + // private func bindLegPathUpdates() { + // viewModel.$legInfo + // .receive(on: DispatchQueue.main) + // .combineLatest(viewModel.$bottomType) + // .sink { [weak self] info, bottomType in + // self?.commonAlarmSetupView() + // self?.addRouteLine(pathInfos: info?.pathInfo ?? []) + // + // switch bottomType { + // case .departure: + // self?.shouldCenterToCurrentLocationOnce = false + // self?.lastTrainDepartView.setupLegInfo(info: info) + // default: do {} + // } + // + // self?.setupBottomType(bottomType) + // } + // .store(in: &cancellables) + // + // viewModel.$bottomType + // .removeDuplicates() + // .receive(on: RunLoop.main) + // .sink { [weak self] type in + // self?.setupBottomType(type) + // } + // .store(in: &cancellables) + // + // viewModel.$departureTime + // .compactMap { $0 } + // .receive(on: RunLoop.main) + // .sink { [weak self] time in + // self?.lastTrainDepartView.refreshDepartureTime(departureStr: time) + // } + // .store(in: &cancellables) + // } + private func bindLegPathUpdates() { // 1. 경로 정보, 2. 알람 실행 여부, 3. 현재 바텀 뷰 타입을 묶어서 감시 Publishers.CombineLatest3( @@ -1103,7 +1106,7 @@ extension MainViewController { self.navigationController?.popToRootViewController(animated: true) self.viewModel.alarmDelete() - self.exitButtonTapped() + self.exitButtonTapped(showToast: false) amp_track(.alarm_arrive_stop) @@ -1139,7 +1142,7 @@ extension MainViewController { self.navigationController?.popToRootViewController(animated: true) self.viewModel.alarmDelete() - self.exitButtonTapped() + self.exitButtonTapped(showToast: false) amp_track(.alarm_timeout_stop) @@ -1156,6 +1159,44 @@ extension MainViewController { } .store(in: &cancellables) } + + + private func observeScheduledArrivalTimeout() { + NotificationCenter.default.publisher(for: NSNotification.Name("scheduledArrivalDidTimeout")) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self = self else { return } + + // 10분 지났을 때도 상세화면에서 메인으로 강제 복귀! + self.navigationController?.popToRootViewController(animated: true) + + self.viewModel.alarmDelete() + self.exitButtonTapped(showToast: false) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.showScheduledArrivalPopup() + } + } + .store(in: &cancellables) + } + + private func showScheduledArrivalPopup() { + if presentedViewController is AtchaPopupViewController { return } + + let popupVM = AtchaPopupViewModel(info: .scheduledArrive) + let popupVC = AtchaPopupViewController(viewModel: popupVM) + + popupVC.modalPresentationStyle = .overFullScreen + popupVC.modalTransitionStyle = .crossDissolve + + popupVC.confirmButton.addAction(UIAction { [weak popupVC] _ in + popupVC?.dismiss(animated: false) + HomeArrivalManager.shared.reset() + AlarmManager.shared.cancelArrivalTimeout() + }, for: .touchUpInside) + + self.present(popupVC, animated: false) + } } // MARK: - 말풍선 제어 코어 로직 diff --git a/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift b/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift index ab22fc16..14f80ec8 100644 --- a/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift +++ b/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift @@ -16,6 +16,7 @@ enum AtcahPopuInfo { case announeExit case alarmTimeout case arrive + case scheduledArrive case serverError var title: String { @@ -28,6 +29,7 @@ enum AtcahPopuInfo { case .announeExit: return "" case .alarmTimeout: return "예정된 출발 시간이 지나\n알람이 자동으로 종료됐어요" case .arrive: return "목적지 부근에 도착해\n안내를 종료합니다" + case .scheduledArrive: return "예정된 도착 시간이 지나\n알람이 자동으로 종료됐어요" case .serverError: return "잠시 후 다시 시도해주세요\n앗차팀에서 확인 및 대응 중입니다" } } @@ -42,6 +44,7 @@ enum AtcahPopuInfo { case .announeExit: return "확인" case .alarmTimeout: return "닫기" case .arrive: return "확인" + case .scheduledArrive: return "닫기" case .serverError: return "확인" } } @@ -49,14 +52,14 @@ enum AtcahPopuInfo { var confrimBackgroundColor: UIColor { switch self { case .alarm, .re_register, .course, .arrive: return .main - case .alarmTimeout, .serverError: return .gray910 + case .alarmTimeout, .serverError, .scheduledArrive: return .gray910 default: return .white } } var confrimForegroundColor: UIColor { switch self { - case .alarmTimeout, .serverError: return .white + case .alarmTimeout, .serverError, .scheduledArrive: return .white default: return .black } } diff --git a/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift b/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift index cb16df7d..ed0b20d2 100644 --- a/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift +++ b/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift @@ -80,7 +80,7 @@ final class AtchaPopupViewController: BaseViewController { confirmButton.setAttributedTitle(confirmAttr, for: .normal) confirmButton.backgroundColor = info.confrimBackgroundColor - if info != .alarmTimeout || info != .arrive || info != .serverError { + if info != .alarmTimeout || info != .arrive || info != .serverError || info != .scheduledArrive { let cancelAttr = AtchaFont.B5_SB_14(info.cancelTitle, color: info.cancelForegroundColor) cancelButton.setAttributedTitle(cancelAttr, for: .normal) cancelButton.backgroundColor = info.cancelBackgroundColor @@ -93,7 +93,7 @@ final class AtchaPopupViewController: BaseViewController { $0.removeFromSuperview() } - if info == .alarmTimeout || info == .arrive || info == .serverError { + if info == .alarmTimeout || info == .arrive || info == .serverError || info == .scheduledArrive { buttonStackView.addArrangedSubview(confirmButton) } else { buttonStackView.addArrangedSubview(cancelButton) From 491ce36156e89a4a1e3e9ea0bf08b23ba97d51ef Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Fri, 20 Mar 2026 16:32:53 +0900 Subject: [PATCH 25/30] =?UTF-8?q?[FEAT]=20=EC=95=B1=20=EC=9E=AC=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=8B=9C=20=EC=95=8C=EB=9E=8C=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EB=B3=B5=EA=B5=AC=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Location/MainViewModel.swift | 105 +++++++++++------- 1 file changed, 63 insertions(+), 42 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index 8ea78624..1982b9a0 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -13,7 +13,6 @@ import TMapSDK final class MainViewModel: BaseViewModel{ private var alarmTimerCancellable: AnyCancellable? - private var alarmFinishCancellable: AnyCancellable? private var alarmTimeoutCancellable: AnyCancellable? private var alarmObserver: NSObjectProtocol? private var refreshUpdateToken: NSObjectProtocol? @@ -79,6 +78,7 @@ final class MainViewModel: BaseViewModel{ super.init() observeGlobalRefresh() + restoreAlarmState() self.bind() } @@ -112,16 +112,16 @@ final class MainViewModel: BaseViewModel{ .store(in: &cancellables) UserDefaultsWrapper.shared.legInfoPublisher - .compactMap { $0 } - .receive(on: RunLoop.main) - .sink { [weak self] newInfo in - guard let self = self else { return } - - if self.legInfo != newInfo { - self.drawRoute(address: self.addressDesc, info: newInfo) - } + .compactMap { $0 } + .receive(on: RunLoop.main) + .sink { [weak self] newInfo in + guard let self = self else { return } + + if self.legInfo != newInfo { + self.drawRoute(address: self.addressDesc, info: newInfo) } - .store(in: &cancellables) + } + .store(in: &cancellables) } private func updateAddressOnly(for location: CLLocationCoordinate2D) async { @@ -164,9 +164,9 @@ final class MainViewModel: BaseViewModel{ addressDesc = address legInfo = info -// let wrapper = UserDefaultsWrapper.shared -// wrapper.set(address, forKey: UserDefaultsWrapper.Key.addressDesc.rawValue) -// wrapper.set(info, forKey: UserDefaultsWrapper.Key.legInfo.rawValue) + // let wrapper = UserDefaultsWrapper.shared + // wrapper.set(address, forKey: UserDefaultsWrapper.Key.addressDesc.rawValue) + // wrapper.set(info, forKey: UserDefaultsWrapper.Key.legInfo.rawValue) setupLegInfo(info: info) } @@ -349,7 +349,6 @@ final class MainViewModel: BaseViewModel{ wrapper.set(body, forKey: UserDefaultsWrapper.Key.departureTime.rawValue) fetchDetailRoute() - stopFinishAlarmTimer() startAlarmTimer() checkAlarmTime() @@ -382,7 +381,6 @@ final class MainViewModel: BaseViewModel{ func alarmDelete() { stopAlarmTimer() - stopFinishAlarmTimer() AlarmManager.shared.cancelArrivalTimeout() @@ -505,39 +503,12 @@ extension MainViewModel { .sink { [weak self] _ in self?.checkAlarmTime() } - - alarmFinishCancellable = Timer - .publish(every: 60.0, on: .main, in: .common) - .autoconnect() - .sink { [weak self] _ in - guard let self else { return } - if let arrivalTime = UserDefaultsWrapper.shared.object( - forKey: UserDefaultsWrapper.Key.arrivalTime.rawValue, - of: Date.self - ) { - let now = Date() - let thirtyMinutesLater = arrivalTime.addingTimeInterval(30 * 60) // 30분 후 - - print("departure Time : \(arrivalTime)") - print("30분 후 시각 : \(thirtyMinutesLater)") - - if now >= thirtyMinutesLater { - stopFinishAlarmTimer() - bottomType = .search - } - } - } } private func stopAlarmTimer() { alarmTimerCancellable?.cancel() alarmTimerCancellable = nil } - - func stopFinishAlarmTimer() { - alarmFinishCancellable?.cancel() - alarmFinishCancellable = nil - } } @@ -694,3 +665,53 @@ extension MainViewModel { } } } + +extension MainViewModel { + func restoreAlarmState() { + let wrapper = UserDefaultsWrapper.shared + + // 1. 알람이 등록되어 있는지 확인 + guard wrapper.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) == true else { return } + + // 2. 데이터 가져오기 + guard let departureStr = wrapper.string(forKey: UserDefaultsWrapper.Key.departureTime.rawValue), + let arrivalDate = wrapper.object(forKey: UserDefaultsWrapper.Key.arrivalTime.rawValue, of: Date.self) else { return } + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + + guard let departureDate = formatter.date(from: departureStr) else { return } + + let now = Date() + let timeoutDate = arrivalDate.addingTimeInterval(10 * 60) + + // --- 분기 처리 --- + + if now < departureDate { + // [Case 1] 아직 출발 전 + startAlarmTimer() + + } else if now >= departureDate && now < timeoutDate { + // [Case 2] 이동 중 (핵심!) + + // 중요: 이미 알람이 울린 것으로 간주하여 플래그 세팅 (경로 스냅핑 활성화) + wrapper.set(true, forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) + AlarmManager.shared.scheduleArrivalTimeout(at: arrivalDate) + // 소리 알람(AlarmManager.startAlarm)은 호출하지 않음! + self.showLockView = false // 잠금화면 보이지 않음 + self.bottomType = .departure // 하단 바를 '안내 중' 상태로 변경 + + + if let current = self.currentLocation { + HomeArrivalManager.shared.checkHomeArrival(currentCoord: current) + } + + } else if now >= timeoutDate { + // [Case 3] 이미 한참 지남 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + NotificationCenter.default.post(name: NSNotification.Name("scheduledArrivalDidTimeout"), object: nil) + } + } + } +} From f9af591a5165b7cdc61ec4fc262c9bedc2aaa47b Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Fri, 20 Mar 2026 16:45:08 +0900 Subject: [PATCH 26/30] =?UTF-8?q?[FEAT]=20=ED=8A=B9=EC=A0=95=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=EB=B0=9C=EC=83=9D=20=EC=8B=9C?= =?UTF-8?q?=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=8F=B4=EB=A7=81=20=EC=A4=91=EB=8B=A8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Atcha-iOS/Core/Network/API/APIError.swift | 2 +- Atcha-iOS/Core/Network/API/APIServiceImpl.swift | 7 ++++--- .../DetailRoute/DetailRouteViewModel.swift | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Atcha-iOS/Core/Network/API/APIError.swift b/Atcha-iOS/Core/Network/API/APIError.swift index fa964605..15edab7b 100644 --- a/Atcha-iOS/Core/Network/API/APIError.swift +++ b/Atcha-iOS/Core/Network/API/APIError.swift @@ -10,7 +10,7 @@ import Foundation enum APIError: Error { case invalidURL case decodingError - case serverError(statusCode: Int) + case serverError(statusCode: Int, responseCode: String? = nil) case unknown(error: Error) case noData } diff --git a/Atcha-iOS/Core/Network/API/APIServiceImpl.swift b/Atcha-iOS/Core/Network/API/APIServiceImpl.swift index 844d7496..0a63399f 100644 --- a/Atcha-iOS/Core/Network/API/APIServiceImpl.swift +++ b/Atcha-iOS/Core/Network/API/APIServiceImpl.swift @@ -44,7 +44,7 @@ final class APIServiceImpl: APIService, @unchecked Sendable { } else { self.handleFailure(response: response, endpoint: endpoint, continuation: continuation) } - case .failure(let error): + case .failure(_): self.handleFailure(response: response, endpoint: endpoint, continuation: continuation) } } @@ -92,7 +92,7 @@ extension APIServiceImpl { self.handleFailure(response: response, endpoint: endpoint, requestBody: body.toDictionary(), continuation: continuation) } - case .failure(let error): + case .failure(_): self.handleFailure(response: response, endpoint: endpoint, requestBody: body.toDictionary(), continuation: continuation) } } @@ -134,7 +134,8 @@ extension APIServiceImpl { requestParameters: endpoint.parameters // GET query params ) - let apiError = APIError.serverError(statusCode: statusCode) + let apiError = APIError.serverError(statusCode: statusCode, responseCode: responseCode) + NotificationCenter.default.post(name: .apiErrorOccurred, object: apiError) continuation.resume(throwing: apiError) } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index 3e970398..14a9a35f 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift @@ -481,3 +481,20 @@ extension DetailRouteViewModel { return best } } + + +extension DetailRouteViewModel { + private func checkAndStopPolling(error: Error) -> Bool { + if let apiError = error as? APIError { + if case .serverError(_, let code) = apiError { + let stopCodes = ["URT_001", "LRT_001", "LRT_003"] + + if let code = code, stopCodes.contains(code) { + self.stopPolling() + return true + } + } + } + return false + } +} From bbe14006b839d219b0c33e0965444d26d98242b5 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Fri, 20 Mar 2026 17:32:56 +0900 Subject: [PATCH 27/30] =?UTF-8?q?[BUGFIX]=20=EC=9C=84=EC=B9=98=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EA=B0=80=EA=B3=B5=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=8F=20=ED=8A=95=EA=B9=80=20?= =?UTF-8?q?=ED=98=84=EC=83=81=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/BaseViewController.swift | 2 +- .../DetailRoute/DetailRouteViewModel.swift | 9 +++- .../Location/MainViewController.swift | 1 - .../Presentation/Location/MainViewModel.swift | 53 ++++++++++--------- 4 files changed, 37 insertions(+), 28 deletions(-) diff --git a/Atcha-iOS/Presentation/Common/BaseViewController.swift b/Atcha-iOS/Presentation/Common/BaseViewController.swift index 9b1c7642..b4120853 100644 --- a/Atcha-iOS/Presentation/Common/BaseViewController.swift +++ b/Atcha-iOS/Presentation/Common/BaseViewController.swift @@ -230,7 +230,7 @@ class BaseViewController: UIViewController { guard let apiError = notification.object as? APIError else { return } // 2. 에러 케이스와 상태 코드 추출 (APIError가 statusCode를 가지고 있다고 가정) - if case .serverError(let statusCode) = apiError { + if case .serverError(let statusCode, _) = apiError { // 3. 500번대 에러인 경우에만 팝업 노출 if (500...599).contains(statusCode) && !ErrorState.isShowing500Error { diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index 14a9a35f..6f538716 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift @@ -58,6 +58,9 @@ final class DetailRouteViewModel: BaseViewModel { private var didSendInitialLocation = false private var consecutiveValidCount = 0 + + private var isCalculatingProximity = false + func forceLocationSnap() { self.didSendInitialLocation = false self.lastValidTime = nil @@ -166,6 +169,7 @@ final class DetailRouteViewModel: BaseViewModel { @MainActor func refreshAllRealTimeData() async { + guard !isRefreshing else { return } guard !busRoutes.isEmpty || !subwayRoutes.isEmpty else { return } isRefreshing = true // 애니메이션 시작 신호 @@ -401,7 +405,9 @@ extension DetailRouteViewModel { extension DetailRouteViewModel { /// 현재 좌표를 기준으로 가장 가까운 경로를 찾아 nearLegIDs를 업데이트합니다. func calculateProximity(coord: CLLocationCoordinate2D?) { - guard let coord = coord else { return } + guard let coord = coord, !isCalculatingProximity else { return } + + isCalculatingProximity = true let threshold: CLLocationDistance = 150 let polylines = self.legPolylineById @@ -442,6 +448,7 @@ extension DetailRouteViewModel { DispatchQueue.main.async { self.nearLegIDs = picked self.departedLegIDs = departed + self.isCalculatingProximity = false } } } diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index d90da7c4..c39c7e07 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -714,7 +714,6 @@ extension MainViewController { } case .search: - viewModel.stopFinishAlarmTimer() lastTrainSearchView.isHidden = false flagImageView.isHidden = false diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index 1982b9a0..72c6da44 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -59,6 +59,14 @@ final class MainViewModel: BaseViewModel{ private var lastValidTime: Date? = nil private var consecutiveValidCount = 0 + private static let isoDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + formatter.locale = Locale(identifier: "ko_KR") // 혹은 .current + return formatter + }() + private var cachedPathCoordinates: [CLLocationCoordinate2D] = [] + init(authorizationUseCase: RequestLocationAuthorizationUseCase, streamUseCase: ObserveLocationStreamUseCase, fetchTaxiFareUseCase: FetchTaxiFareUseCase, @@ -157,43 +165,42 @@ final class MainViewModel: BaseViewModel{ await MainActor.run { self.taxiFare = fare } } catch { print("택시비 조회 실패: \(error)") } } - + func drawRoute(address: String?, info: LegInfo?) { guard let address, let info else { return } addressDesc = address legInfo = info - // let wrapper = UserDefaultsWrapper.shared - // wrapper.set(address, forKey: UserDefaultsWrapper.Key.addressDesc.rawValue) - // wrapper.set(info, forKey: UserDefaultsWrapper.Key.legInfo.rawValue) + let wrapper = UserDefaultsWrapper.shared + wrapper.set(address, forKey: UserDefaultsWrapper.Key.addressDesc.rawValue) + wrapper.set(info, forKey: UserDefaultsWrapper.Key.legInfo.rawValue) setupLegInfo(info: info) } private func setupLegInfo(info: LegInfo?) { - let routeId = info?.pathInfo.first?.routeId - - guard let info, let departureStr = info.pathInfo.first?.departureDateTime, + guard let info, + let departureStr = info.pathInfo.first?.departureDateTime, let totalTime = info.trafficInfo.first?.totalTime else { return } self.departureStr = departureStr - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" - formatter.locale = .current + UserDefaultsWrapper.shared.set(info, forKey: UserDefaultsWrapper.Key.legInfo.rawValue) - guard let departureDate = formatter.date(from: departureStr) else { return } + guard let departureDate = Self.isoDateFormatter.date(from: departureStr) else { return } let minutes = parseTotalTimeToMinutes(totalTime) - guard let arrivalDate = Calendar.current.date(byAdding: .minute, value: minutes, to: departureDate) else { return } - AlarmManager.shared.scheduleArrivalTimeout(at: arrivalDate) - - print("departureDate : \(departureDate)") - print("arrivalDate : \(arrivalDate)") let wrapper = UserDefaultsWrapper.shared - wrapper.set(departureStr, forKey: UserDefaultsWrapper.Key.departureTime.rawValue) - wrapper.set(arrivalDate, forKey: UserDefaultsWrapper.Key.arrivalTime.rawValue) + let savedArrival = wrapper.object(forKey: UserDefaultsWrapper.Key.arrivalTime.rawValue, of: Date.self) + + + if savedArrival != arrivalDate { + AlarmManager.shared.scheduleArrivalTimeout(at: arrivalDate) + wrapper.set(departureStr, forKey: UserDefaultsWrapper.Key.departureTime.rawValue) + wrapper.set(arrivalDate, forKey: UserDefaultsWrapper.Key.arrivalTime.rawValue) + self.cachedPathCoordinates = info.pathInfo.flatMap { convertShapeToCoords($0.passShape ?? "") } + } } private func parseTotalTimeToMinutes(_ time: String) -> Int { @@ -286,11 +293,8 @@ final class MainViewModel: BaseViewModel{ // 알람이 울린 후(`isAlarmFired`)에만 경로 스냅 적용 let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false - if isAlarmFired, let path = self.legInfo?.pathInfo { - let allCoords = path.flatMap { convertShapeToCoords($0.passShape ?? "") } - if !allCoords.isEmpty { - finalCoord = smoother.snap(current: smoothedCoord, polyline: allCoords) - } + if isAlarmFired && !self.cachedPathCoordinates.isEmpty { + finalCoord = smoother.snap(current: smoothedCoord, polyline: self.cachedPathCoordinates) } let capturedCoord = finalCoord @@ -370,7 +374,7 @@ final class MainViewModel: BaseViewModel{ trafficInfo: trafficInfo, busInfo: busInfo) wrapper.set(legInfo, forKey: UserDefaultsWrapper.Key.legInfo.rawValue) - drawRoute(address: addressDesc, info: legInfo) + self.drawRoute(address: self.addressDesc, info: legInfo) } catch { print("routeId 조회 대실패 ㅠㅠ!!") } @@ -540,7 +544,6 @@ extension MainViewModel { routeHandler?(.myPage) case .detailRoute: - fetchDetailRoute() // 이걸 통신을 할까 말까 let wrapper = UserDefaultsWrapper.shared guard let info = wrapper.object(forKey: UserDefaultsWrapper.Key.legInfo.rawValue, of: LegInfo.self), From 2dc5b25c427c9786d7e29163605fdb11cecc1183 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Fri, 20 Mar 2026 17:38:18 +0900 Subject: [PATCH 28/30] =?UTF-8?q?[FEAT]=20=EC=95=B1=20=EC=9E=AC=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=8B=9C=20=EA=B2=BD=EB=A1=9C=20=EC=A2=8C=ED=91=9C?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=20=EB=B3=B5=EA=B5=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Atcha-iOS/Presentation/Location/MainViewModel.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index 72c6da44..4dd0f46d 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -165,7 +165,7 @@ final class MainViewModel: BaseViewModel{ await MainActor.run { self.taxiFare = fare } } catch { print("택시비 조회 실패: \(error)") } } - + func drawRoute(address: String?, info: LegInfo?) { guard let address, let info else { return } @@ -701,7 +701,14 @@ extension MainViewModel { // 중요: 이미 알람이 울린 것으로 간주하여 플래그 세팅 (경로 스냅핑 활성화) wrapper.set(true, forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) AlarmManager.shared.scheduleArrivalTimeout(at: arrivalDate) - // 소리 알람(AlarmManager.startAlarm)은 호출하지 않음! + + if let savedLegInfo = wrapper.object(forKey: UserDefaultsWrapper.Key.legInfo.rawValue, of: LegInfo.self) { + self.legInfo = savedLegInfo // Published 변수 복구 + + let coords = savedLegInfo.pathInfo.flatMap { convertShapeToCoords($0.passShape ?? "") } + self.cachedPathCoordinates = coords + } + self.showLockView = false // 잠금화면 보이지 않음 self.bottomType = .departure // 하단 바를 '안내 중' 상태로 변경 From fed4404cf1e05e548c76b4367726559a1a1d3a3a Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Fri, 20 Mar 2026 22:56:20 +0900 Subject: [PATCH 29/30] =?UTF-8?q?[BUGFIX]=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EC=8B=9C=20=EC=9D=B8=ED=84=B0=EC=85=89?= =?UTF-8?q?=ED=84=B0=20=ED=97=A4=EB=8D=94=20=EA=B0=84=EC=84=AD=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Atcha-iOS/Core/Network/Token/TokenInterceptor.swift | 3 ++- Atcha-iOS/Presentation/Lock/LockViewController.swift | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift b/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift index ce6be6d1..4bdab1b3 100644 --- a/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift +++ b/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift @@ -33,7 +33,8 @@ final class TokenInterceptor: RequestInterceptor, @unchecked Sendable { "/auth/login", "/app/version", "/locations/is-service-region", - "/api/locations/rgeo" + "/api/locations/rgeo", + "/auth/reissue" ] if publicPaths.contains(where: { path.hasSuffix($0) }) { diff --git a/Atcha-iOS/Presentation/Lock/LockViewController.swift b/Atcha-iOS/Presentation/Lock/LockViewController.swift index 81982014..d90fbce8 100644 --- a/Atcha-iOS/Presentation/Lock/LockViewController.swift +++ b/Atcha-iOS/Presentation/Lock/LockViewController.swift @@ -168,6 +168,11 @@ final class LockViewController: BaseViewController { amp_track(.later_course_click) viewModel.routerHandler?(.courseSearch(startLat: lat, startLon: lon, startAddress: address, context: .afterReigster)) + + UserDefaultsWrapper.shared.set( + true, + forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue + ) } private func observeAlarmTimeout() { From fd006b7b8ac79d4bcab3842ab66fe696326e6c6a Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Sat, 21 Mar 2026 00:02:38 +0900 Subject: [PATCH 30/30] =?UTF-8?q?[BUGFIX]=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=97=90=EB=9F=AC=20=EB=A1=9C=EA=B7=B8=EC=97=90=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EC=A0=84=EC=86=A1=EB=90=9C=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=ED=86=A0=ED=81=B0=20=EB=85=B8=EC=B6=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Discord/DiscordWebhookManager.swift | 21 +++++++++---------- .../Core/Network/API/APIServiceImpl.swift | 4 ++-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift index ce9f1120..db6aa851 100644 --- a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift +++ b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift @@ -10,9 +10,9 @@ import Foundation final class DiscordWebhookManager { static let shared = DiscordWebhookManager() private init() {} - + private let webhookURLString = "https://discord.com/api/webhooks/1483870710018474066/qyzNBI1Bwr7J5tQDrPx2-mOcej_9yLSOk5Bmlmza2D-4nSWqvWgcMd4CZDziG4vkpKrm" - + func sendErrorLog( statusCode: Int, method: String, @@ -24,13 +24,12 @@ final class DiscordWebhookManager { requestParameters: [String: Any]? = nil ) { guard let url = URL(string: webhookURLString) else { return } - + // Authorization 토큰 앞 30자만 노출 let headersText = requestHeaders.map { key, value in - let safeValue = key == "Authorization" ? String(value.prefix(30)) + "..." : value - return "\(key): \(safeValue)" + return "\(key): \(value)" }.joined(separator: "\n") - + // body JSON 변환 let bodyText: String if let body = requestBody, @@ -40,7 +39,7 @@ final class DiscordWebhookManager { } else { bodyText = "None" } - + let paramsText: String if let params = requestParameters, let data = try? JSONSerialization.data(withJSONObject: params, options: .prettyPrinted), @@ -49,7 +48,7 @@ final class DiscordWebhookManager { } else { paramsText = "None" } - + let payload: [String: Any] = [ "content": "🚨 [Atcha-iOS] API 에러 발생!", "embeds": [[ @@ -62,18 +61,18 @@ final class DiscordWebhookManager { ["name": "App Version", "value": AppInfoProvider.currentVersion, "inline": true], ["name": "Error Message", "value": message, "inline": false], ["name": "Request Headers", "value": "```\n\(headersText)\n```", "inline": false], - ["name": "Request Parameters", "value": paramsText, "inline": false], + ["name": "Request Parameters", "value": paramsText, "inline": false], ["name": "Request Body", "value": bodyText, "inline": false] ], "footer": ["text": "발생 시각: \(Date().kstString)"] ]] ] - + var request = URLRequest(url: url) request.httpMethod = "POST" request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONSerialization.data(withJSONObject: payload) - + URLSession.shared.dataTask(with: request).resume() } } diff --git a/Atcha-iOS/Core/Network/API/APIServiceImpl.swift b/Atcha-iOS/Core/Network/API/APIServiceImpl.swift index 0a63399f..44d10056 100644 --- a/Atcha-iOS/Core/Network/API/APIServiceImpl.swift +++ b/Atcha-iOS/Core/Network/API/APIServiceImpl.swift @@ -110,7 +110,7 @@ extension APIServiceImpl { let statusCode = response.response?.statusCode ?? -1 let method = endpoint.method.rawValue.uppercased() let path = endpoint.path - let requestHeaders = endpoint.headers?.dictionary ?? [:] + let actualSentHeaders = response.request?.allHTTPHeaderFields ?? [:] var responseCode = "UNKNOWN" var serverMessage = "(메시지 없음)" @@ -129,7 +129,7 @@ extension APIServiceImpl { path: serverPath, responseCode: responseCode, message: serverMessage, - requestHeaders: requestHeaders, + requestHeaders: actualSentHeaders, requestBody: requestBody, // POST/PUT body requestParameters: endpoint.parameters // GET query params )