diff --git a/Atcha-iOS/App/DIContainer/Course/CourseDIContainer.swift b/Atcha-iOS/App/DIContainer/Course/CourseDIContainer.swift index 688bddc..9ef1a20 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/App/SceneDelegate.swift b/Atcha-iOS/App/SceneDelegate.swift index 10804dd..6c61134 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/Core/Manager/Alarm/AlarmManager.swift b/Atcha-iOS/Core/Manager/Alarm/AlarmManager.swift index 765aab0..991d08c 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/Discord/DiscordWebhookManager.swift b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift index ce9f112..db6aa85 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/Manager/Location/HomeArrivalManager.swift b/Atcha-iOS/Core/Manager/Location/HomeArrivalManager.swift index eda71c9..d90fe00 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/Core/Network/API/APIError.swift b/Atcha-iOS/Core/Network/API/APIError.swift index fa96460..15edab7 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 844d749..44d1005 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) } } @@ -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,12 +129,13 @@ extension APIServiceImpl { path: serverPath, responseCode: responseCode, message: serverMessage, - requestHeaders: requestHeaders, + requestHeaders: actualSentHeaders, requestBody: requestBody, // POST/PUT body 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/Core/Network/Token/TokenInterceptor.swift b/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift index ce6be6d..4bdab1b 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/Common/BaseViewController.swift b/Atcha-iOS/Presentation/Common/BaseViewController.swift index 7352c9d..b412085 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? @@ -220,21 +224,37 @@ 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) && !ErrorState.isShowing500Error { + guard self.presentedViewController == nil else { return } + + DispatchQueue.main.async { [weak self] in + self?.showAtchaErrorPopup() + } + } else { + // 400번대 등 기타 에러는 팝업을 띄우지 않고 로그만 남기거나 별도 처리 + print("UI 팝업 제외 대상 에러: \(statusCode)") + } } } 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) diff --git a/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift b/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift index 8c908ec..ea88086 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 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 + } +} diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 027df1e..c39c7e0 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() } @@ -417,7 +418,7 @@ extension MainViewController { } viewModel.handleRoute(route: .courseSearch( - startLat: String(startCoord.latitude), startLon: String(startCoord.longitude), startAddress: "" + startLat: String(startCoord.latitude), startLon: String(startCoord.longitude), startAddress: "", context: .beforeRegister )) } } @@ -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,33 +608,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) @@ -675,7 +714,6 @@ extension MainViewController { } case .search: - viewModel.stopFinishAlarmTimer() lastTrainSearchView.isHidden = false flagImageView.isHidden = false @@ -1067,7 +1105,7 @@ extension MainViewController { self.navigationController?.popToRootViewController(animated: true) self.viewModel.alarmDelete() - self.exitButtonTapped() + self.exitButtonTapped(showToast: false) amp_track(.alarm_arrive_stop) @@ -1103,7 +1141,7 @@ extension MainViewController { self.navigationController?.popToRootViewController(animated: true) self.viewModel.alarmDelete() - self.exitButtonTapped() + self.exitButtonTapped(showToast: false) amp_track(.alarm_timeout_stop) @@ -1120,6 +1158,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/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index cc4c1e3..4dd0f46 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? @@ -60,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, @@ -79,6 +86,7 @@ final class MainViewModel: BaseViewModel{ super.init() observeGlobalRefresh() + restoreAlarmState() self.bind() } @@ -112,16 +120,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 { @@ -171,27 +179,28 @@ final class MainViewModel: BaseViewModel{ } 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 } - 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 { @@ -284,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 @@ -347,7 +353,6 @@ final class MainViewModel: BaseViewModel{ wrapper.set(body, forKey: UserDefaultsWrapper.Key.departureTime.rawValue) fetchDetailRoute() - stopFinishAlarmTimer() startAlarmTimer() checkAlarmTime() @@ -369,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 조회 대실패 ㅠㅠ!!") } @@ -378,6 +383,11 @@ final class MainViewModel: BaseViewModel{ // MARK: - 알림 취소 func alarmDelete() { + + stopAlarmTimer() + + AlarmManager.shared.cancelArrivalTimeout() + let wrapper = UserDefaultsWrapper.shared let savedLastRouteId: String? = wrapper.string( forKey: UserDefaultsWrapper.Key.lastRouteId.rawValue) @@ -497,39 +507,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 - } } @@ -548,19 +531,19 @@ extension MainViewModel { address: lastReverseGeocode?.address, radius: lastReverseGeocode?.radius))) - case .courseSearch(let startLat, let startLon, _): + case .courseSearch(let startLat, let startLon, _, _): routeHandler?(.courseSearch( startLat: (lastReverseGeocode?.lat).map { String($0) } ?? startLat, startLon: (lastReverseGeocode?.lon).map { String($0) } ?? startLon, - startAddress: address ?? lastReverseGeocode?.address ?? "" + startAddress: address ?? lastReverseGeocode?.address ?? "", + context: .beforeRegister )) case .myPage: routeHandler?(.myPage) case .detailRoute: - fetchDetailRoute() // 이걸 통신을 할까 말까 let wrapper = UserDefaultsWrapper.shared guard let info = wrapper.object(forKey: UserDefaultsWrapper.Key.legInfo.rawValue, of: LegInfo.self), @@ -685,3 +668,60 @@ 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) + + 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 // 하단 바를 '안내 중' 상태로 변경 + + + 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) + } + } + } +} diff --git a/Atcha-iOS/Presentation/Location/View/LastTrainDepartBottomView.swift b/Atcha-iOS/Presentation/Location/View/LastTrainDepartBottomView.swift index 3a9bf77..79cd9a0 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) } } diff --git a/Atcha-iOS/Presentation/Lock/LockViewController.swift b/Atcha-iOS/Presentation/Lock/LockViewController.swift index 46f7991..d90fbce 100644 --- a/Atcha-iOS/Presentation/Lock/LockViewController.swift +++ b/Atcha-iOS/Presentation/Lock/LockViewController.swift @@ -167,7 +167,12 @@ final class LockViewController: 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)) + + UserDefaultsWrapper.shared.set( + true, + forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue + ) } private func observeAlarmTimeout() { diff --git a/Atcha-iOS/Presentation/Main/MainCoordinator.swift b/Atcha-iOS/Presentation/Main/MainCoordinator.swift index 736777a..56e99ca 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) diff --git a/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift b/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift index 50d350f..14f80ec 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 { @@ -23,11 +24,12 @@ 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알람이 자동으로 종료됐어요" 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 cb16df7..ed0b20d 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)