From 096357f6db09387a819384251c1525a233b688b7 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 11 Mar 2026 08:29:46 +0900 Subject: [PATCH 01/10] =?UTF-8?q?[BUGFIX]=20=EC=95=8C=EB=9E=8C=20=EC=9A=B8?= =?UTF-8?q?=EB=A6=B0=20=ED=9B=84=20=EC=A7=80=EB=8F=84=20=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EA=B7=B8=20=EC=8B=9C=20=EC=9C=84=EC=B9=98=20=EB=94=B0=EB=9D=BC?= =?UTF-8?q?=EA=B0=80=EA=B8=B0=20=EC=A4=91=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DetailRouteViewController.swift | 16 +- .../Location/MainViewController.swift | 336 +++++++++--------- 2 files changed, 184 insertions(+), 168 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift index 7b3861d..b0d1924 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift @@ -301,12 +301,18 @@ final class DetailRouteViewController: BaseViewController, self.mapContainerView.updateUserMarker(location: coord, isRegistered: isAlarmRegistered) // 2) 지도 follow 로직 (메인) - if self.isAlarmFired { - // 알람 울린 후엔 계속 따라감 + // if self.isAlarmFired { + // // 알람 울린 후엔 계속 따라감 + // self.mapContainerView.setupZoomCenter(location: coord) + // } else if self.isFollowingUser { + // // 알람 전: following 켰을 때만 따라감 + // self.mapContainerView.setupZoomCenter(location: coord) + // } + if self.isFollowingUser { self.mapContainerView.setupZoomCenter(location: coord) - } else if self.isFollowingUser { - // 알람 전: following 켰을 때만 따라감 + } else if self.shouldCenterToCurrentLocationOnce { self.mapContainerView.setupZoomCenter(location: coord) + self.shouldCenterToCurrentLocationOnce = false } // else: fit 유지 (건드리지 않음) @@ -349,7 +355,7 @@ final class DetailRouteViewController: BaseViewController, .receive(on: RunLoop.main) .sink { [weak self] heading in guard let self else { return } - guard isFollowingUser || isAlarmFired else { return } + guard isFollowingUser else { return } mapContainerView.setHeading(heading) } .store(in: &cancellables) diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 3f8a08b..24a5cbe 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -101,11 +101,11 @@ final class MainViewController: BaseViewController, private var lastCourseUpdateAt: CFTimeInterval = 0 private let courseValidWindow: CFTimeInterval = 1.2 -// private var isGuest: Bool { -// return UserDefaultsWrapper.shared.bool( -// forKey: UserDefaultsWrapper.Key.isGuest.rawValue -// ) ?? false -// } + // private var isGuest: Bool { + // return UserDefaultsWrapper.shared.bool( + // forKey: UserDefaultsWrapper.Key.isGuest.rawValue + // ) ?? false + // } var shouldShowWelcomeToast: Bool = false @@ -164,11 +164,21 @@ final class MainViewController: BaseViewController, self.mapContainerView.setupZoomCenter(location: startCoord) } - } else if isAlarmRegistered && isAlarmFired { - // 3. 알람 울리고 나서는 계속 따라가고 회전 + }else if isAlarmRegistered && isAlarmFired { + // 3. 알람 울린 후 (현위치 추적 모드) self.mapContainerView.afterUserMarker() self.isFollowingUser = true self.viewModel.startHeading() + + // 수정된 부분: 화면 복귀 시 즉시 현위치로 카메라 이동 + if let currentCoord = self.viewModel.currentLocation { + // 즉시 중심으로 이동 (필요에 따라 setupZoomCenter를 사용해 줌 레벨까지 고정 가능) + self.mapContainerView.setupCenter(location: currentCoord) + self.shouldCenterToCurrentLocationOnce = false + } else { + // 아직 좌표가 안 잡혔다면 위치가 업데이트될 때 이동하도록 플래그 세팅 + self.shouldCenterToCurrentLocationOnce = true + } } } } @@ -521,45 +531,52 @@ extension MainViewController { // MARK: - ViewModel Bindings private func bindCurrentLocationUpdates() { - viewModel.$currentLocation - .removeDuplicates() - .compactMap { $0 } - .receive(on: RunLoop.main) - .sink { [weak self] coord in - guard let self = self else { return } - - // 1. 파란색 내 위치 마커는 무조건 실시간 업데이트 - - let isAlarmRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false - let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false - self.mapContainerView.updateUserMarker(location: coord, isRegistered: isAlarmRegistered) - // 2. 알람이 울린 상태면 무조건 강제로 센터 유지 - if isAlarmRegistered && isAlarmFired { - self.isFollowingUser = true - self.viewModel.startHeading() - self.mapContainerView.setupCenter(location: coord) - return - } - - // 3. 앱 최초 진입이거나, 내가 현위치 버튼을 눌러서 '추적 모드'일 때만 카메라 중심 이동 - if self.shouldCenterToCurrentLocationOnce || self.isFollowingUser { - self.mapContainerView.setupCenter(location: coord) - self.shouldCenterToCurrentLocationOnce = false - } - } - .store(in: &cancellables) - } - - private func bindSelectedLocationUpdates() { - viewModel.$selectedLocation - .removeDuplicates() - .compactMap { $0 } - .receive(on: RunLoop.main) - .sink { _ in - // 👉 뷰모델에서 알아서 주소를 검색하므로 뷰컨트롤러는 카메라를 건드리지 않음! + viewModel.$currentLocation + .removeDuplicates() + .compactMap { $0 } + .receive(on: RunLoop.main) + .sink { [weak self] coord in + guard let self = self else { return } + + // 1. 파란색 내 위치 마커는 무조건 실시간 업데이트 + + let isAlarmRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false + let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false + self.mapContainerView.updateUserMarker(location: coord, isRegistered: isAlarmRegistered) + // 2. 알람이 울린 상태면 무조건 강제로 센터 유지 + // if isAlarmRegistered && isAlarmFired { + // self.isFollowingUser = true + // self.viewModel.startHeading() + // self.mapContainerView.setupCenter(location: coord) + // return + // } + // + // // 3. 앱 최초 진입이거나, 내가 현위치 버튼을 눌러서 '추적 모드'일 때만 카메라 중심 이동 + // if self.shouldCenterToCurrentLocationOnce || self.isFollowingUser { + // self.mapContainerView.setupCenter(location: coord) + // self.shouldCenterToCurrentLocationOnce = false + // } + if self.isFollowingUser { + self.mapContainerView.setupCenter(location: coord) + } else if self.shouldCenterToCurrentLocationOnce { + // 앱 최초 진입 등 일회성 이동 로직 + self.mapContainerView.setupCenter(location: coord) + self.shouldCenterToCurrentLocationOnce = false } - .store(in: &cancellables) - } + } + .store(in: &cancellables) + } + + private func bindSelectedLocationUpdates() { + viewModel.$selectedLocation + .removeDuplicates() + .compactMap { $0 } + .receive(on: RunLoop.main) + .sink { _ in + // 👉 뷰모델에서 알아서 주소를 검색하므로 뷰컨트롤러는 카메라를 건드리지 않음! + } + .store(in: &cancellables) + } private func bindDeviceHeadingUpdates() { viewModel.$deviceHeading @@ -806,65 +823,65 @@ extension MainViewController { } private func bindServiceRegionUpdates() { - viewModel.$isServiceRegion - .removeDuplicates() - .receive(on: RunLoop.main) - .sink { [weak self] ok in - guard let self = self else { return } - let previous = self.latestIsServiceRegion - self.latestIsServiceRegion = ok + viewModel.$isServiceRegion + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] ok in + guard let self = self else { return } + let previous = self.latestIsServiceRegion + self.latestIsServiceRegion = ok + + switch ok { + case .some(true): + // 서비스 지역으로 들어옴! + self.lastTrainSearchView.updateSearchEnabled(true) - switch ok { - case .some(true): - // 서비스 지역으로 들어옴! - self.lastTrainSearchView.updateSearchEnabled(true) + if previous == nil { + self.showInitialPreAlarmBalloons(force: true) + } else if self.isPreAlarmBalloonActive() { - if previous == nil { - self.showInitialPreAlarmBalloons(force: true) - } else if self.isPreAlarmBalloonActive() { - - // 수정: 게스트 모드면 요금(fare)이 없어도 바로 ???로 띄워줘야 함! - if viewModel.isGuest { - let content: BalloonContent = .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 ???원") - if self.ballonView.isHidden { - self.showOrUpdatePreBalloon(content, showTopLine: self.preSessionShowTopLine ?? true) - } else { - self.updatePreBalloonContent(content, showTopLine: self.preSessionShowTopLine ?? true) - } - - } else if let fare = self.latestFareString { - // 일반 회원이고 요금이 있을 때 - let content: BalloonContent = .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(fare)원") - if self.ballonView.isHidden { - self.showOrUpdatePreBalloon(content, showTopLine: self.preSessionShowTopLine ?? true) - } else { - self.updatePreBalloonContent(content, showTopLine: self.preSessionShowTopLine ?? true) - } + // 수정: 게스트 모드면 요금(fare)이 없어도 바로 ???로 띄워줘야 함! + if viewModel.isGuest { + let content: BalloonContent = .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 ???원") + if self.ballonView.isHidden { + self.showOrUpdatePreBalloon(content, showTopLine: self.preSessionShowTopLine ?? true) + } else { + self.updatePreBalloonContent(content, showTopLine: self.preSessionShowTopLine ?? true) } - } - - case .some(false): - // 서비스 지역을 벗어남 (울산 등) - self.lastTrainSearchView.updateSearchEnabled(false) - if previous == nil { - self.showInitialPreAlarmBalloons(force: true) - } else if self.isPreAlarmBalloonActive() { - let content: BalloonContent = - .text(top: (self.preSessionShowTopLine ?? true) ? "지도를 움직여 출발지를 설정해요" : nil, - bottom: "서울, 경기, 인천 내에서만 사용할 수 있어요") + + } else if let fare = self.latestFareString { + // 일반 회원이고 요금이 있을 때 + let content: BalloonContent = .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(fare)원") if self.ballonView.isHidden { self.showOrUpdatePreBalloon(content, showTopLine: self.preSessionShowTopLine ?? true) } else { self.updatePreBalloonContent(content, showTopLine: self.preSessionShowTopLine ?? true) } } - - case .none: - self.lastTrainSearchView.updateSearchEnabled(false) } + + case .some(false): + // 서비스 지역을 벗어남 (울산 등) + self.lastTrainSearchView.updateSearchEnabled(false) + if previous == nil { + self.showInitialPreAlarmBalloons(force: true) + } else if self.isPreAlarmBalloonActive() { + let content: BalloonContent = + .text(top: (self.preSessionShowTopLine ?? true) ? "지도를 움직여 출발지를 설정해요" : nil, + bottom: "서울, 경기, 인천 내에서만 사용할 수 있어요") + if self.ballonView.isHidden { + self.showOrUpdatePreBalloon(content, showTopLine: self.preSessionShowTopLine ?? true) + } else { + self.updatePreBalloonContent(content, showTopLine: self.preSessionShowTopLine ?? true) + } + } + + case .none: + self.lastTrainSearchView.updateSearchEnabled(false) } - .store(in: &cancellables) - } + } + .store(in: &cancellables) + } // MARK: - Constraint Helper private func updateAtchaImageConstraint(relativeTo view: UIView) { @@ -996,20 +1013,20 @@ extension MainViewController { } @objc private func didTapLocationButton() { - guard ensureLocationPermissionOrShowToast() else { return } - - isFollowingUser = true - viewModel.startHeading() - - // 🚨 수정: 무조건 내 "진짜 위치(currentLocation)"로 지도를 이동시킴 - if let coord = viewModel.currentLocation { - mapContainerView.setupCenter(location: coord) - viewModel.selectedLocation = coord // 주소도 현위치로 다시 검색하게 덮어씀 - } else { - shouldCenterToCurrentLocationOnce = true - viewModel.setupLocation() - } + guard ensureLocationPermissionOrShowToast() else { return } + + isFollowingUser = true + viewModel.startHeading() + + // 수정: 무조건 내 "진짜 위치(currentLocation)"로 지도를 이동시킴 + if let coord = viewModel.currentLocation { + mapContainerView.setupCenter(location: coord) + viewModel.selectedLocation = coord // 주소도 현위치로 다시 검색하게 덮어씀 + } else { + shouldCenterToCurrentLocationOnce = true + viewModel.setupLocation() } + } private func safeStartJump() { let now = CACurrentMediaTime() @@ -1200,62 +1217,62 @@ extension MainViewController { // 초기 프리 말풍선 private func showInitialPreAlarmBalloons(force: Bool = false) { - guard let isService = latestIsServiceRegion else { return } - guard isPreAlarmBalloonActive() else { return } - - if preSessionShowTopLine == nil { - preSessionShowTopLine = !isRevisit - } - let showTopLine = preSessionShowTopLine ?? true - let d1 = balloonInitialDelayFirst - - if isService { - // 서비스 지역인데 아직 요금이 없으면 말풍선은 띄우지 않지만, - // 재방문 처리(상단 라인 억제용)는 반드시 해두고 return - guard let fare = latestFareString else { - if !isRevisit { - UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.reVisit.rawValue) - } - - // [추가 로직] 게스트일 경우 서버에서 요금을 안 주거나 늦게 줄 수 있으므로 - // 요금(fare)이 없어도 바로 ???로 띄워줍니다! - if viewModel.isGuest { - showOrUpdatePreBalloon( - .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 ???원"), - delay: d1, animated: true, showTopLine: showTopLine - ) - hasShownInitialBalloon = true - } - return + guard let isService = latestIsServiceRegion else { return } + guard isPreAlarmBalloonActive() else { return } + + if preSessionShowTopLine == nil { + preSessionShowTopLine = !isRevisit + } + let showTopLine = preSessionShowTopLine ?? true + let d1 = balloonInitialDelayFirst + + if isService { + // 서비스 지역인데 아직 요금이 없으면 말풍선은 띄우지 않지만, + // 재방문 처리(상단 라인 억제용)는 반드시 해두고 return + guard let fare = latestFareString else { + if !isRevisit { + UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.reVisit.rawValue) } - // 요금이 있고 서비스 지역일 때 - let displayFare = viewModel.isGuest ? "???원" : "\(fare)원" - showOrUpdatePreBalloon( - .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(displayFare)"), - delay: d1, animated: true, showTopLine: showTopLine - ) - - } else { - // 비서비스 지역은 기존 안내 문구 유지 - showOrUpdatePreBalloon( - .text(top: showTopLine ? "지도를 움직여 출발지를 설정해요" : nil, - bottom: "서울, 경기, 인천 내에서만 사용할 수 있어요"), - delay: d1 - ) + // [추가 로직] 게스트일 경우 서버에서 요금을 안 주거나 늦게 줄 수 있으므로 + // 요금(fare)이 없어도 바로 ???로 띄워줍니다! + if viewModel.isGuest { + showOrUpdatePreBalloon( + .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 ???원"), + delay: d1, animated: true, showTopLine: showTopLine + ) + hasShownInitialBalloon = true + } + return } - // 여기까지 도달했을 때도 초기 방문이면 reVisit 저장 - if !isRevisit { - UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.reVisit.rawValue) - } + // 요금이 있고 서비스 지역일 때 + let displayFare = viewModel.isGuest ? "???원" : "\(fare)원" + showOrUpdatePreBalloon( + .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(displayFare)"), + delay: d1, animated: true, showTopLine: showTopLine + ) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in - self?.atchaImageView.stop() - self?.atchaImageView.start() - } - hasShownInitialBalloon = true + } else { + // 비서비스 지역은 기존 안내 문구 유지 + showOrUpdatePreBalloon( + .text(top: showTopLine ? "지도를 움직여 출발지를 설정해요" : nil, + bottom: "서울, 경기, 인천 내에서만 사용할 수 있어요"), + delay: d1 + ) + } + + // 여기까지 도달했을 때도 초기 방문이면 reVisit 저장 + if !isRevisit { + UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.reVisit.rawValue) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in + self?.atchaImageView.stop() + self?.atchaImageView.start() } + hasShownInitialBalloon = true + } // 즉시 표시(터치 등): 3초 뒤 오토숨김 private func showOrUpdateImmediateBalloon(_ content: BalloonContent) { @@ -1365,13 +1382,6 @@ extension MainViewController: UIGestureRecognizerDelegate { // 조작 감지 시 공통 처리 로직 (경우의 수 1,2,3 반영) private func stopFollowingOnUserInteraction() { - let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false - - // 3. 알람이 울린 후라면 지도를 터치해도 계속 따라가도록 무시 - if isAlarmFired { - return - } - // 1, 2. 평상시엔 지도를 조작하면 추적과 회전을 중지 if isFollowingUser { isFollowingUser = false From 2ecb1ad815cfb0a0c48ac06b5e6c8225ed595ec3 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 11 Mar 2026 08:57:50 +0900 Subject: [PATCH 02/10] =?UTF-8?q?[BUGFIX]=20=EB=8C=80=EC=A4=91=EA=B5=90?= =?UTF-8?q?=ED=86=B5=20=EC=8B=9C=EA=B0=84=20=ED=91=9C=EC=8B=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=95=8C=EB=9E=8C?= =?UTF-8?q?=20=EC=A2=85=EB=A3=8C=20=EC=8B=9C=20=ED=8F=B4=EB=A7=81=20?= =?UTF-8?q?=EC=A4=91=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Cell/DetailRouteBusCell.swift | 63 ++++++++++--------- .../Cell/DetailRouteSubwayCell.swift | 3 + .../DetailRoute/DetailRouteViewModel.swift | 15 +++++ .../Location/MainViewController.swift | 2 +- 4 files changed, 52 insertions(+), 31 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift index 82bd67d..ad5e972 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift @@ -49,7 +49,7 @@ final class DetailRouteBusCell: UICollectionViewCell { private let stationListStackView = UIStackView() private let busTimerFirstLabel: UILabel = UILabel() -// private let busTimerSecondLabel: UILabel = UILabel() + // private let busTimerSecondLabel: UILabel = UILabel() private lazy var busTimerStackView: UIStackView = { let stack = UIStackView(arrangedSubviews: [busTimerFirstLabel]) stack.axis = .vertical @@ -310,6 +310,9 @@ final class DetailRouteBusCell: UICollectionViewCell { endLabel.attributedText = endCombinedLabel self.busTimerStackView.isHidden = !isAlarmFired + if isAlarmFired && !currentBusInfo.isEmpty { + updateBusTimerLabels() // 데이터가 이미 있다면 레이블을 즉시 그림 + } } @@ -358,7 +361,7 @@ final class DetailRouteBusCell: UICollectionViewCell { animationView.startAnimationIfNeeded(forceRestart: true) backgroundColor = UIColor.opacity100 } - + func stopArrivedEffectIfNeeded() { guard isArrivedEffectOn else { return } isArrivedEffectOn = false @@ -434,73 +437,73 @@ extension DetailRouteBusCell { extension DetailRouteBusCell { func setupBusRealTimeInfo(info: LegTrafficInfo?, busInfo: [RealTimeBusArrival]) { busTimerStackView.isHidden = false - + let filtered = busInfo .filter { ($0.remainingTime ?? -1) > 0 } - + currentBusInfo = filtered - + guard !busInfo.isEmpty else { stopCountdownTimer() busTimerFirstLabel.attributedText = AtchaFont.B6_R_14("", color: .gray300) return } - + guard !currentBusInfo.isEmpty else { stopCountdownTimer() busTimerFirstLabel.attributedText = AtchaFont.B6_R_14("", color: .widearea) return } - + updateBusTimerLabels() startCountdownTimerIfNeeded() } - + private func startCountdownTimerIfNeeded() { // 이미 돌고 있으면 유지 if countdownTimer != nil { return } - + countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.decrementRemainingTime() } - + // 스크롤 중에도 잘 돌게 common mode 추천 if let timer = countdownTimer { RunLoop.main.add(timer, forMode: .common) } } - + private func stopCountdownTimer() { countdownTimer?.invalidate() countdownTimer = nil } - + // 1초마다 감소 private func decrementRemainingTime() { guard !currentBusInfo.isEmpty else { stopCountdownTimer() return } - + for i in 0.. 0 } - + if currentBusInfo.isEmpty { stopCountdownTimer() busTimerFirstLabel.attributedText = AtchaFont.B6_R_14("도착 또는 출발", color: .widearea) -// busTimerSecondLabel.text = "" + // busTimerSecondLabel.text = "" return } - + updateBusTimerLabels() } - + private func updateBusTimerLabels() { - + guard isAlarmFired else { busTimerStackView.isHidden = true return @@ -512,39 +515,39 @@ extension DetailRouteBusCell { if info.busStatus == .end { return AtchaFont.B6_R_14("운행 종료", color: .gray) } - + guard let remaining = info.remainingTime else { return AtchaFont.B6_R_14("", color: .gray300) } - + if remaining <= 120 { return AtchaFont.B6_R_14("곧 도착", color: .widearea) } else { return AtchaFont.B6_R_14(formatSecondsToHMS(remaining), color: .widearea) } } - + switch currentBusInfo.count { case 2: busTimerFirstLabel.attributedText = labelText(for: currentBusInfo[0]) -// busTimerSecondLabel.attributedText = labelText(for: currentBusInfo[1]) + // busTimerSecondLabel.attributedText = labelText(for: currentBusInfo[1]) case 1: busTimerFirstLabel.attributedText = labelText(for: currentBusInfo[0]) -// busTimerSecondLabel.text = "" + // busTimerSecondLabel.text = "" default: // 3개 이상이면 우선 2개만 보여주거나, 숨기지 말고 2개만 보여주자 busTimerFirstLabel.attributedText = labelText(for: currentBusInfo[0]) -// busTimerSecondLabel.attributedText = labelText(for: currentBusInfo[1]) + // busTimerSecondLabel.attributedText = labelText(for: currentBusInfo[1]) } } - + private func formatSecondsToHMS(_ seconds: Int?) -> String { guard let seconds, seconds >= 0 else { return "" } - + let h = seconds / 3600 let m = (seconds % 3600) / 60 let s = seconds % 60 - + if h > 0 { // 시가 있으면 시/분/초 // (원하면 "1시간 0분 5초"처럼 0분도 보여줄지 결정 가능) @@ -554,12 +557,12 @@ extension DetailRouteBusCell { return "\(h)시간 \(s)초" } } - + if m > 0 { // 시가 없으면 분/초 return "\(m)분 \(s)초" } - + // 분도 없으면 초만 return "\(s)초" } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift index 58b410f..f0a7961 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift @@ -313,6 +313,9 @@ final class DetailRouteSubwayCell: UICollectionViewCell { // } self.subwayTimerLabel.isHidden = !isAlarmFired + if isAlarmFired && currentRemainingSec != nil { + updateSubwayTimerLabel() + } } private func addStationNameLabel(info: [PassStopList]) { diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index d043f99..aa65c28 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift @@ -238,6 +238,13 @@ final class DetailRouteViewModel: BaseViewModel { guard let self else { return } while !Task.isCancelled { + let isRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false + if !isRegistered { + print("알람 등록 해제 감지: 버스 폴링 중단") + self.stopBusPolling() + break + } + try? await Task.sleep(nanoseconds: 15_000_000_000) if Task.isCancelled { break } await self.refreshAllBusRealTime() @@ -276,6 +283,14 @@ final class DetailRouteViewModel: BaseViewModel { guard let self else { return } while !Task.isCancelled { + // 추가: 알람 등록 상태 확인 + let isRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false + if !isRegistered { + print("알람 등록 해제 감지: 지하철 폴링 중단") + self.stopSubwayPolling() + break + } + try? await Task.sleep(nanoseconds: 15_000_000_000) if Task.isCancelled { break } await self.refreshAllSubwayRealTime() diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 24a5cbe..05e0bbd 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -170,7 +170,7 @@ final class MainViewController: BaseViewController, self.isFollowingUser = true self.viewModel.startHeading() - // 수정된 부분: 화면 복귀 시 즉시 현위치로 카메라 이동 + //수정된 부분: 화면 복귀 시 즉시 현위치로 카메라 이동 if let currentCoord = self.viewModel.currentLocation { // 즉시 중심으로 이동 (필요에 따라 setupZoomCenter를 사용해 줌 레벨까지 고정 가능) self.mapContainerView.setupCenter(location: currentCoord) From f040df1760cf18a6adbc554ffa133d104c41c488 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 11 Mar 2026 09:43:08 +0900 Subject: [PATCH 03/10] =?UTF-8?q?[FEAT]=20=EC=9D=B4=EB=8F=99=20=ED=8F=89?= =?UTF-8?q?=EA=B7=A0=20=ED=95=84=ED=84=B0=20=EB=B0=8F=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EC=8A=A4=EB=83=85=20=EB=A1=9C=EC=A7=81=20=EB=8F=84=EC=9E=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9C=84=EC=B9=98=20=EC=A0=95=ED=99=95?= =?UTF-8?q?=EB=8F=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Atcha-iOS.xcodeproj/project.pbxproj | 4 + Atcha-iOS/Core/Manager/LocationSmoother.swift | 67 ++++++++++ .../DetailRoute/DetailRouteViewModel.swift | 126 +++++++++--------- .../Presentation/Location/MainViewModel.swift | 43 +++++- 4 files changed, 168 insertions(+), 72 deletions(-) create mode 100644 Atcha-iOS/Core/Manager/LocationSmoother.swift diff --git a/Atcha-iOS.xcodeproj/project.pbxproj b/Atcha-iOS.xcodeproj/project.pbxproj index 62ace6c..314f4a8 100644 --- a/Atcha-iOS.xcodeproj/project.pbxproj +++ b/Atcha-iOS.xcodeproj/project.pbxproj @@ -151,6 +151,7 @@ 6DC3BF5E2E07123F00831470 /* IntroCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF5D2E07123F00831470 /* IntroCell.swift */; }; 6DC3BF602E071F0900831470 /* LoginUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF5F2E071F0900831470 /* LoginUseCase.swift */; }; 6DC3BF682E0721F300831470 /* LoginDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF672E0721F300831470 /* LoginDTO.swift */; }; + 6DC617A72F60EB1A002DD641 /* LocationSmoother.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC617A62F60EB1A002DD641 /* LocationSmoother.swift */; }; 6DD632B12E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD632B02E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift */; }; 6DD632B62E52E23A00C6A66E /* ProximityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD632B52E52E23A00C6A66E /* ProximityManager.swift */; }; 6DD632B92E52E8E300C6A66E /* ProximityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD632B82E52E8E300C6A66E /* ProximityViewController.swift */; }; @@ -472,6 +473,7 @@ 6DC3BF5D2E07123F00831470 /* IntroCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroCell.swift; sourceTree = ""; }; 6DC3BF5F2E071F0900831470 /* LoginUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUseCase.swift; sourceTree = ""; }; 6DC3BF672E0721F300831470 /* LoginDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginDTO.swift; sourceTree = ""; }; + 6DC617A62F60EB1A002DD641 /* LocationSmoother.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSmoother.swift; sourceTree = ""; }; 6DD632B02E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckServiceRegionRequest.swift; sourceTree = ""; }; 6DD632B52E52E23A00C6A66E /* ProximityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityManager.swift; sourceTree = ""; }; 6DD632B82E52E8E300C6A66E /* ProximityViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityViewController.swift; sourceTree = ""; }; @@ -1181,6 +1183,7 @@ 6DADA5952EA09B9500CA9BE2 /* Amplitude */, B61C448D2E3F57B600285A4B /* AlarmManager.swift */, 6DD632B72E52E8D000C6A66E /* Proximity */, + 6DC617A62F60EB1A002DD641 /* LocationSmoother.swift */, ); path = Manager; sourceTree = ""; @@ -2281,6 +2284,7 @@ 6DADA5972EA09BA400CA9BE2 /* AmplitudeManager.swift in Sources */, 6DD632C22E544DB500C6A66E /* PushAlarmBottomView.swift in Sources */, 6D5E03D02E28853E0065AFBE /* CourseSearchResponse.swift in Sources */, + 6DC617A72F60EB1A002DD641 /* LocationSmoother.swift in Sources */, 6D91A8E62E29F5BC0081BAFC /* CourseSettingViewModel.swift in Sources */, 6D26E0002F3C17C3005097A4 /* SubwayRealTimeInfoRequest.swift in Sources */, B673C4912E0424FD00EE4AD0 /* SplashViewModel.swift in Sources */, diff --git a/Atcha-iOS/Core/Manager/LocationSmoother.swift b/Atcha-iOS/Core/Manager/LocationSmoother.swift new file mode 100644 index 0000000..859c4a1 --- /dev/null +++ b/Atcha-iOS/Core/Manager/LocationSmoother.swift @@ -0,0 +1,67 @@ +// +// LocationSmoother.swift +// Atcha-iOS +// +// Created by wodnd on 3/11/26. +// + +import Foundation +import CoreLocation +import MapKit + +final class LocationSmoother { + private var buffer: [CLLocationCoordinate2D] = [] + private let bufferLimit: Int + + init(limit: Int = 5) { + self.bufferLimit = limit + } + + func smooth(_ next: CLLocationCoordinate2D) -> CLLocationCoordinate2D { + buffer.append(next) + if buffer.count > bufferLimit { buffer.removeFirst() } + + let avgLat = buffer.map { $0.latitude }.reduce(0, +) / Double(buffer.count) + let avgLon = buffer.map { $0.longitude }.reduce(0, +) / Double(buffer.count) + + return CLLocationCoordinate2D(latitude: avgLat, longitude: avgLon) + } +} + +extension LocationSmoother { + /// 좌표를 경로선(Polyline) 위 가장 가까운 점으로 고정합니다. + func snap(current: CLLocationCoordinate2D, polyline: [CLLocationCoordinate2D], threshold: Double = 150) -> CLLocationCoordinate2D { + guard polyline.count >= 2 else { return current } + + let p = MKMapPoint(current) + var minDistance = Double.greatestFiniteMagnitude + var closestPoint = p + + for i in 0..<(polyline.count - 1) { + let a = MKMapPoint(polyline[i]) + let b = MKMapPoint(polyline[i + 1]) + + let projected = closestPointOnSegment(p, a, b) + let distance = projected.distance(to: p) + + if distance < minDistance { + minDistance = distance + closestPoint = projected + } + } + + // 임계값(150m) 보다 멀어지면 사용자가 경로를 이탈한 것으로 간주하여 원본 좌표 반환 + return minDistance < threshold ? closestPoint.coordinate : current + } + + private func closestPointOnSegment(_ p: MKMapPoint, _ a: MKMapPoint, _ b: MKMapPoint) -> MKMapPoint { + let dx = b.x - a.x + let dy = b.y - a.y + if dx == 0 && dy == 0 { return a } + + let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy) + if t < 0 { return a } + if t > 1 { return b } + return MKMapPoint(x: a.x + t * dx, y: a.y + t * dy) + } +} diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index aa65c28..52cb04e 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift @@ -48,11 +48,12 @@ final class DetailRouteViewModel: BaseViewModel { @Published var deviceHeading: CLLocationDirection? private let headingManager = HeadingManager() - -//#if DEBUG -//@Published var mockLocation: CLLocationCoordinate2D? = nil -//#endif - + private let smoother = LocationSmoother(limit: 5) + + //#if DEBUG + //@Published var mockLocation: CLLocationCoordinate2D? = nil + //#endif + init(address: String, infos: LegInfo, context: DetailRouteContext, @@ -141,74 +142,57 @@ final class DetailRouteViewModel: BaseViewModel { } func requestPermissionAndStartTracking() { - Task { - let status = await authorizationUseCase.askLocationPermission() - guard status == .authorizedAlways || status == .authorizedWhenInUse else { return } - - streamTask?.cancel() - streamTask = Task { - for await location in streamUseCase.startUpdate() { - let coord = CLLocationCoordinate2D( - latitude: location.coordinate.latitude, - longitude: location.coordinate.longitude - ) - await MainActor.run { self.currentLocation = coord } + Task { + let status = await authorizationUseCase.askLocationPermission() + guard status == .authorizedAlways || status == .authorizedWhenInUse else { return } + + streamTask?.cancel() + streamTask = Task { + for await location in streamUseCase.startUpdate() { + guard location.horizontalAccuracy < 150 else { continue } + + // 항상 Smoothing 적용 + let smoothedCoord = smoother.smooth(location.coordinate) + + var finalCoord = smoothedCoord + + // 알람이 울린 상태라면 스냅 적용 + let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false + + if isAlarmFired && !legtPathInfo.isEmpty { + let allCoords = legtPathInfo.flatMap { convertShapeToCoords($0.passShape ?? "") } + finalCoord = smoother.snap(current: smoothedCoord, polyline: allCoords) } + + await MainActor.run { self.currentLocation = finalCoord } } } } + } func startHeading() { - headingManager.onHeading = { [weak self] h in - DispatchQueue.main.async { self?.deviceHeading = h } - } - headingManager.start() - } - - func stopHeading() { - headingManager.stop() - } - - func stopTracking() { - streamTask?.cancel() - streamUseCase.stopUpdate() - headingManager.stop() - } - - deinit { - stopTracking() - stopBusPolling() - stopSubwayPolling() + headingManager.onHeading = { [weak self] h in + DispatchQueue.main.async { self?.deviceHeading = h } } + headingManager.start() + } -// func requestPermissionAndStartTracking() { -// Task { -// let status = await authorizationUseCase.askLocationPermission() -// guard status == .authorizedAlways || status == .authorizedWhenInUse else { return } -// -// streamTask = Task { [weak self] in -// guard let self else { return } -// -// for await location in streamUseCase.startUpdate() { -// let real = CLLocationCoordinate2D( -// latitude: location.coordinate.latitude, -// longitude: location.coordinate.longitude -// ) -// -// #if DEBUG -// // ✅ 디버그에선 mock 있으면 그걸로 덮어씀 -// if let mock = self.mockLocation { -// self.currentLocation = mock -// } else { -// self.currentLocation = real -// } -// #else -// self.currentLocation = real -// #endif -// } -// } -// } -// } + func stopHeading() { + headingManager.stop() + } + + func stopTracking() { + streamTask?.cancel() + streamUseCase.stopUpdate() + headingManager.stop() + } + + deinit { + stopTracking() + stopBusPolling() + stopSubwayPolling() + } + func setupLocation() { requestPermissionAndStartTracking() @@ -321,3 +305,15 @@ final class DetailRouteViewModel: BaseViewModel { } } } + +extension DetailRouteViewModel { + private func convertShapeToCoords(_ shape: String) -> [CLLocationCoordinate2D] { + shape.split(separator: " ").compactMap { pair in + let parts = pair.split(separator: ",") + guard parts.count == 2, + let lon = Double(parts[0]), + let lat = Double(parts[1]) else { return nil } + return CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + } +} diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index 1ccf84d..3cabc31 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -57,6 +57,8 @@ final class MainViewModel: BaseViewModel{ @Published var showLocationDeniedAlert: Bool = false @Published var isGuest: Bool = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.isGuest.rawValue) ?? false + private let smoother = LocationSmoother(limit: 5) + init(authorizationUseCase: RequestLocationAuthorizationUseCase, streamUseCase: ObserveLocationStreamUseCase, fetchTaxiFareUseCase: FetchTaxiFareUseCase, @@ -227,15 +229,30 @@ final class MainViewModel: BaseViewModel{ streamTask = Task { var didSendInitialLocation = false for await location in streamUseCase.startUpdate() { - let newLocation = CLLocationCoordinate2D(latitude: location.coordinate.latitude, longitude: location.coordinate.longitude) + // 정확도 필터링 (너무 튀는 값 제거) + guard location.horizontalAccuracy < 150 else { continue } + + // 이동 평균 필터링 (항상 적용하여 부드러운 움직임 확보) + let smoothedCoord = smoother.smooth(location.coordinate) + + var finalCoord = smoothedCoord - // 내 진짜 GPS 위치는 계속 업데이트 - self.currentLocation = newLocation + // 알람이 울린 후(`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) + } + } - // [수정]: 지도의 중심(selectedLocation)은 "앱 최초 진입 시" 딱 1번만 GPS 위치로 맞춰줍니다. - if !didSendInitialLocation { - self.selectedLocation = newLocation - didSendInitialLocation = true + await MainActor.run { + self.currentLocation = finalCoord + if !didSendInitialLocation { + self.selectedLocation = finalCoord + didSendInitialLocation = true + } } } } @@ -616,3 +633,15 @@ extension MainViewModel { alarmTimeoutCancellable = nil } } + +extension MainViewModel { + private func convertShapeToCoords(_ shape: String) -> [CLLocationCoordinate2D] { + shape.split(separator: " ").compactMap { pair in + let parts = pair.split(separator: ",") + guard parts.count == 2, + let lon = Double(parts[0]), + let lat = Double(parts[1]) else { return nil } + return CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + } +} From 71798391884cbf72a90a9a1494371f8881cfcb8e Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 11 Mar 2026 10:07:46 +0900 Subject: [PATCH 04/10] =?UTF-8?q?[BUGFIX]=20=EA=B2=BD=EB=A1=9C=20=EA=B7=BC?= =?UTF-8?q?=EC=A0=91=20=EA=B0=90=EC=A7=80=20=EB=A1=9C=EC=A7=81=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DetailRouteViewController.swift | 92 +++++++++---------- .../DetailRoute/DetailRouteViewModel.swift | 89 +++++++++++++++++- 2 files changed, 129 insertions(+), 52 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift index b0d1924..5a94e81 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift @@ -32,7 +32,6 @@ final class DetailRouteViewController: BaseViewController, private var isAlarmFired: Bool { UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false } - private var legPolylineById: [UUID: [CLLocationCoordinate2D]] = [:] override func viewDidLoad() { super.viewDidLoad() @@ -253,74 +252,67 @@ final class DetailRouteViewController: BaseViewController, } .store(in: &cancellables) - Publishers.CombineLatest(viewModel.$legTrafficInfo, viewModel.$legtPathInfo) - .receive(on: DispatchQueue.global(qos: .userInitiated)) - .sink { [weak self] trafficInfos, pathInfos in - guard let self else { return } - guard !trafficInfos.isEmpty, !pathInfos.isEmpty else { return } - - var dict: [UUID: [CLLocationCoordinate2D]] = [:] - - for (traffic, path) in zip(trafficInfos, pathInfos) { - guard traffic.mode == path.mode else { continue } - - if let shape = path.passShape, !shape.isEmpty { - dict[traffic.id] = self.convertShapeToCoords(shape) - continue - } - - if let steps = path.step, !steps.isEmpty { - let merged = steps - .compactMap { $0.linestring } - .filter { !$0.isEmpty } - .joined(separator: " ") - - if !merged.isEmpty { - dict[traffic.id] = self.convertShapeToCoords(merged) - } - } - } - - DispatchQueue.main.async { [weak self] in - self?.legPolylineById = dict - } - } - .store(in: &cancellables) + // Publishers.CombineLatest(viewModel.$legTrafficInfo, viewModel.$legtPathInfo) + // .receive(on: DispatchQueue.global(qos: .userInitiated)) + // .sink { [weak self] trafficInfos, pathInfos in + // guard let self else { return } + // guard !trafficInfos.isEmpty, !pathInfos.isEmpty else { return } + // + // var dict: [UUID: [CLLocationCoordinate2D]] = [:] + // + // for (traffic, path) in zip(trafficInfos, pathInfos) { + // guard traffic.mode == path.mode else { continue } + // + // if let shape = path.passShape, !shape.isEmpty { + // dict[traffic.id] = self.convertShapeToCoords(shape) + // continue + // } + // + // if let steps = path.step, !steps.isEmpty { + // let merged = steps + // .compactMap { $0.linestring } + // .filter { !$0.isEmpty } + // .joined(separator: " ") + // + // if !merged.isEmpty { + // dict[traffic.id] = self.convertShapeToCoords(merged) + // } + // } + // } + // + // DispatchQueue.main.async { [weak self] in + // self?.legPolylineById = dict + // } + // } + // .store(in: &cancellables) } private func bindFollowLogic() { // 위치 viewModel.$currentLocation - .compactMap { $0 } .receive(on: RunLoop.main) .sink { [weak self] coord in guard let self else { return } + guard let unwrappedCoord = coord else { return } + // 1) 유저 마커 업데이트 (메인) let isAlarmRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false - self.mapContainerView.updateUserMarker(location: coord, isRegistered: isAlarmRegistered) + self.mapContainerView.updateUserMarker(location: unwrappedCoord, isRegistered: isAlarmRegistered) + - // 2) 지도 follow 로직 (메인) - // if self.isAlarmFired { - // // 알람 울린 후엔 계속 따라감 - // self.mapContainerView.setupZoomCenter(location: coord) - // } else if self.isFollowingUser { - // // 알람 전: following 켰을 때만 따라감 - // self.mapContainerView.setupZoomCenter(location: coord) - // } if self.isFollowingUser { - self.mapContainerView.setupZoomCenter(location: coord) + self.mapContainerView.setupZoomCenter(location: unwrappedCoord) } else if self.shouldCenterToCurrentLocationOnce { - self.mapContainerView.setupZoomCenter(location: coord) + self.mapContainerView.setupZoomCenter(location: unwrappedCoord) self.shouldCenterToCurrentLocationOnce = false } // else: fit 유지 (건드리지 않음) // 3) 근처(150m) 지나가면 반짝임 계산 (백그라운드) let threshold: CLLocationDistance = 150 - - let polylines = self.legPolylineById - let orderedLegs = self.viewModel.legTrafficInfo // 화면 표시 순서(위→아래) + let polylines = self.viewModel.legPolylineById + let orderedLegs = self.viewModel.legTrafficInfo DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self else { return } @@ -329,7 +321,7 @@ final class DetailRouteViewController: BaseViewController, var nearCandidates = Set() for (id, polyline) in polylines { - let d = self.distanceToPolylineMeters(point: coord, polyline: polyline) + let d = self.distanceToPolylineMeters(point: unwrappedCoord, polyline: polyline) if d <= threshold { nearCandidates.insert(id) } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index 52cb04e..ba4d706 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift @@ -8,6 +8,7 @@ import Foundation import UIKit import CoreLocation +import MapKit enum DetailRouteContext { case beforeRegister @@ -49,6 +50,7 @@ final class DetailRouteViewModel: BaseViewModel { private let headingManager = HeadingManager() private let smoother = LocationSmoother(limit: 5) + @Published var legPolylineById: [UUID: [CLLocationCoordinate2D]] = [:] //#if DEBUG //@Published var mockLocation: CLLocationCoordinate2D? = nil @@ -79,7 +81,21 @@ final class DetailRouteViewModel: BaseViewModel { func fetchInfo() { legtPathInfo = infos.pathInfo legTrafficInfo = infos.trafficInfo - print("위치: \(legtPathInfo)") + + var dict: [UUID: [CLLocationCoordinate2D]] = [:] + for (traffic, path) in zip(legTrafficInfo, legtPathInfo) { + if let shape = path.passShape, !shape.isEmpty { + dict[traffic.id] = self.convertShapeToCoords(shape) + continue + } + if let steps = path.step, !steps.isEmpty { + let merged = steps.compactMap { $0.linestring }.filter { !$0.isEmpty }.joined(separator: " ") + if !merged.isEmpty { + dict[traffic.id] = self.convertShapeToCoords(merged) + } + } + } + self.legPolylineById = dict guard context == .afterReigster else { return } // 버스 @@ -165,6 +181,8 @@ final class DetailRouteViewModel: BaseViewModel { } await MainActor.run { self.currentLocation = finalCoord } + + self.calculateProximity(coord: finalCoord) } } } @@ -192,7 +210,7 @@ final class DetailRouteViewModel: BaseViewModel { stopBusPolling() stopSubwayPolling() } - + func setupLocation() { requestPermissionAndStartTracking() @@ -317,3 +335,70 @@ extension DetailRouteViewModel { } } } + +extension DetailRouteViewModel { + /// 현재 좌표를 기준으로 가장 가까운 경로를 찾아 nearLegIDs를 업데이트합니다. + func calculateProximity(coord: CLLocationCoordinate2D?) { + guard let coord = coord else { return } + + let threshold: CLLocationDistance = 150 + let polylines = self.legPolylineById + let orderedLegs = self.legTrafficInfo + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { return } + var nearCandidates = Set() + + for (id, polyline) in polylines { + let d = self.distanceToPolylineMeters(point: coord, polyline: polyline) + if d <= threshold { + nearCandidates.insert(id) + } + } + + var picked: Set = [] + if let first = orderedLegs.first(where: { nearCandidates.contains($0.id) })?.id { + picked = [first] + } + + DispatchQueue.main.async { + self.nearLegIDs = picked + } + } + } + + // 뷰컨트롤러에서 가져온 거리 계산 함수 + private func distanceToPolylineMeters( + point: CLLocationCoordinate2D, + polyline: [CLLocationCoordinate2D] + ) -> CLLocationDistance { + guard polyline.count >= 2 else { return .greatestFiniteMagnitude } + + let p = MKMapPoint(point) + var best = CLLocationDistance.greatestFiniteMagnitude + + for i in 0..<(polyline.count - 1) { + let a = MKMapPoint(polyline[i]) + let b = MKMapPoint(polyline[i + 1]) + + let abx = b.x - a.x + let aby = b.y - a.y + let apx = p.x - a.x + let apy = p.y - a.y + + let ab2 = abx*abx + aby*aby + if ab2 == 0 { + best = min(best, p.distance(to: a)) + continue + } + + var t = (apx*abx + apy*aby) / ab2 + t = max(0, min(1, t)) + + let closest = MKMapPoint(x: a.x + t*abx, y: a.y + t*aby) + best = min(best, p.distance(to: closest)) + } + + return best + } +} From e3440b78e8ee60c61ce9703f79df660c401968b6 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 11 Mar 2026 10:17:20 +0900 Subject: [PATCH 05/10] =?UTF-8?q?[FEAT]=20=EC=95=8C=EB=9E=8C=20=EC=9A=B8?= =?UTF-8?q?=EB=A6=AC=EB=A9=B4=20=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=ED=8C=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Location/MainViewController.swift | 2 ++ .../Location/View/LastTrainDepartBottomView.swift | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 05e0bbd..aa1dbaa 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -361,6 +361,8 @@ extension MainViewController { .sink { [weak self] isFired in guard let self = self else { return } + self.lastTrainDepartView.updateUIForAlarmStatus(isFired: isFired) + if isFired { // 1. 추적 플래그 ON self.isFollowingUser = true diff --git a/Atcha-iOS/Presentation/Location/View/LastTrainDepartBottomView.swift b/Atcha-iOS/Presentation/Location/View/LastTrainDepartBottomView.swift index eb9be1c..3a9bf77 100644 --- a/Atcha-iOS/Presentation/Location/View/LastTrainDepartBottomView.swift +++ b/Atcha-iOS/Presentation/Location/View/LastTrainDepartBottomView.swift @@ -206,6 +206,19 @@ extension LastTrainDepartBottomView { } } } + + + func updateUIForAlarmStatus(isFired: Bool) { + if isFired { + // 알람 울린 후: 도착 예정 시간 모드 + trainTimeLabel.attributedText = AtchaFont.B4_R_15("우리집 도착 예정시간", color: .white) + trainRigtImageView.isHidden = true + } else { + // 알람 울리기 전: 출발 시간 모드 (기본값) + trainTimeLabel.attributedText = AtchaFont.B4_R_15("출발시간", color: .white) + trainRigtImageView.isHidden = false + } + } } extension LastTrainDepartBottomView { From 5f925977db52c801cbc1e54f2d99c5526633e245 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 11 Mar 2026 10:23:54 +0900 Subject: [PATCH 06/10] =?UTF-8?q?[BUGFIX]=20=ED=8C=9D=EC=97=85=EC=B0=BD=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Course/CourseSearch/CourseSearchViewController.swift | 4 ++-- Atcha-iOS/Presentation/Location/MainViewController.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift b/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift index 5565128..1f59d7f 100644 --- a/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift +++ b/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift @@ -362,7 +362,7 @@ extension CourseSearchViewController { let popupVC = AtchaPopupViewController(viewModel: popupVM) popupVC.confirmButton.addAction(UIAction { [weak popupVC] _ in - popupVC?.dismiss(animated: true) + popupVC?.dismiss(animated: false) self.viewModel.alarmRegister(alarmRequest) self.viewModel.getAlarmTapped?(alarmTapped.0, alarmTapped.1) @@ -392,7 +392,7 @@ extension CourseSearchViewController { let popupVC = AtchaPopupViewController(viewModel: popupVM) popupVC.confirmButton.addAction(UIAction { [weak popupVC] _ in - popupVC?.dismiss(animated: true) + popupVC?.dismiss(animated: false) self.viewModel.alarmRegister(alarmRequest) self.viewModel.getAlarmTapped?(alarmTapped.0, alarmTapped.1) diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index aa1dbaa..41ca0a7 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -475,7 +475,7 @@ extension MainViewController { let popupVC = AtchaPopupViewController(viewModel: popupVM) popupVC.cancelButton.addAction(UIAction { [weak popupVC] _ in - popupVC?.dismiss(animated: true) + popupVC?.dismiss(animated: false) }, for: .touchUpInside) popupVC.confirmButton.addAction(UIAction { [weak self, weak popupVC] _ in From c2264585852340e22494fac9e9fa4a2d19529384 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 11 Mar 2026 12:26:32 +0900 Subject: [PATCH 07/10] =?UTF-8?q?[FEAT]=20=EC=95=8C=EB=9E=8C=202=EB=B6=84?= =?UTF-8?q?=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20=EB=B0=8F=20=EC=A7=91?= =?UTF-8?q?=20=EB=8F=84=EC=B0=A9(50m)=20=EC=9E=90=EB=8F=99=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Atcha-iOS.xcodeproj/project.pbxproj | 4 ++ Atcha-iOS/Core/Manager/AlarmManager.swift | 46 ++++++++++++- .../Core/Manager/HomeArrivalManager.swift | 59 ++++++++++++++++ .../DetailRoute/DetailRouteViewModel.swift | 7 +- .../Location/MainViewController.swift | 69 +++++++++++++++++++ .../Presentation/Location/MainViewModel.swift | 54 ++++++++------- .../Presentation/Popup/AtchaPopupInfo.swift | 5 +- .../Popup/AtchaPopupViewController.swift | 2 +- 8 files changed, 215 insertions(+), 31 deletions(-) create mode 100644 Atcha-iOS/Core/Manager/HomeArrivalManager.swift diff --git a/Atcha-iOS.xcodeproj/project.pbxproj b/Atcha-iOS.xcodeproj/project.pbxproj index 314f4a8..2e92916 100644 --- a/Atcha-iOS.xcodeproj/project.pbxproj +++ b/Atcha-iOS.xcodeproj/project.pbxproj @@ -152,6 +152,7 @@ 6DC3BF602E071F0900831470 /* LoginUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF5F2E071F0900831470 /* LoginUseCase.swift */; }; 6DC3BF682E0721F300831470 /* LoginDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF672E0721F300831470 /* LoginDTO.swift */; }; 6DC617A72F60EB1A002DD641 /* LocationSmoother.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC617A62F60EB1A002DD641 /* LocationSmoother.swift */; }; + 6DC617A92F610238002DD641 /* HomeArrivalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC617A82F610238002DD641 /* HomeArrivalManager.swift */; }; 6DD632B12E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD632B02E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift */; }; 6DD632B62E52E23A00C6A66E /* ProximityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD632B52E52E23A00C6A66E /* ProximityManager.swift */; }; 6DD632B92E52E8E300C6A66E /* ProximityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD632B82E52E8E300C6A66E /* ProximityViewController.swift */; }; @@ -474,6 +475,7 @@ 6DC3BF5F2E071F0900831470 /* LoginUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUseCase.swift; sourceTree = ""; }; 6DC3BF672E0721F300831470 /* LoginDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginDTO.swift; sourceTree = ""; }; 6DC617A62F60EB1A002DD641 /* LocationSmoother.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSmoother.swift; sourceTree = ""; }; + 6DC617A82F610238002DD641 /* HomeArrivalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeArrivalManager.swift; sourceTree = ""; }; 6DD632B02E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckServiceRegionRequest.swift; sourceTree = ""; }; 6DD632B52E52E23A00C6A66E /* ProximityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityManager.swift; sourceTree = ""; }; 6DD632B82E52E8E300C6A66E /* ProximityViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityViewController.swift; sourceTree = ""; }; @@ -1184,6 +1186,7 @@ B61C448D2E3F57B600285A4B /* AlarmManager.swift */, 6DD632B72E52E8D000C6A66E /* Proximity */, 6DC617A62F60EB1A002DD641 /* LocationSmoother.swift */, + 6DC617A82F610238002DD641 /* HomeArrivalManager.swift */, ); path = Manager; sourceTree = ""; @@ -2202,6 +2205,7 @@ B664018B2E2277A900A397AE /* PushAlarmOption.swift in Sources */, 6D26E0072F3C197F005097A4 /* SubwayInfoRepository.swift in Sources */, B65C13082E057C590016D2F0 /* APIService.swift in Sources */, + 6DC617A92F610238002DD641 /* HomeArrivalManager.swift in Sources */, 6D6879D02E4211B800E59C55 /* HomePatchRequest.swift in Sources */, 6D9283742E3AFF6A0090889B /* BusRouteCell.swift in Sources */, 6D73EB5F2E16120200F8DF8B /* CourseSearchViewModel.swift in Sources */, diff --git a/Atcha-iOS/Core/Manager/AlarmManager.swift b/Atcha-iOS/Core/Manager/AlarmManager.swift index 7747603..2d0cd2d 100644 --- a/Atcha-iOS/Core/Manager/AlarmManager.swift +++ b/Atcha-iOS/Core/Manager/AlarmManager.swift @@ -33,6 +33,7 @@ final class AlarmManager { private var shouldKeepBackgroundAudio = false private var isPreviewing = false private var hapticEngine: CHHapticEngine? + private var autoStopWorkItem: DispatchWorkItem? // MARK: - Init private init() { @@ -71,6 +72,8 @@ final class AlarmManager { /// 서버에서 받은 출발 시각 기준으로 1분 전에 반복 푸시/사운드/진동을 시작 func startAlarm(title: String, body: String) { // 기존 알람 상태만 정리 (silent는 유지 or 다시 켜기) + autoStopWorkItem?.cancel() + autoStopWorkItem = nil stopAlarm(keepSilent: true) ensureBackgroundSilentRunning() applySavedVolumeForAlarmStart() @@ -121,6 +124,7 @@ final class AlarmManager { } print("알람 종료 (keepSilent = \(keepSilent))") + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [AlarmNotificationID.autoStopInfo]) } func alarmInit() { @@ -220,13 +224,19 @@ extension AlarmManager { self.sendImmediateLocalPush(title: title, body: body) } + + scheduleAutoStop() } - func sendImmediateLocalPush(title: String, body: String) { + func sendImmediateLocalPush(title: String, body: String, playSound: Bool = false) { let content = UNMutableNotificationContent() content.title = title content.body = body - content.sound = nil // 사운드는 직접 재생 중 + if playSound { + content.sound = .default + } else { + content.sound = nil + } let request = UNNotificationRequest( identifier: UUID().uuidString, @@ -593,3 +603,35 @@ extension AlarmManager { print("미리듣기 종료 (keep=\(shouldKeepBackgroundAudio))") } } + +extension AlarmManager { + private func scheduleAutoStop() { + autoStopWorkItem?.cancel() + + let content = UNMutableNotificationContent() + content.title = "출발 알람이 자동 종료되었어요" + content.body = "클릭해서 경로 재탐색하기" + content.sound = .default + + let request = UNNotificationRequest( + identifier: AlarmNotificationID.autoStopInfo, + content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 120.0, repeats: false) + ) + UNUserNotificationCenter.current().add(request, withCompletionHandler: nil) + + // 2. 앱 내부의 정지 로직 및 팝업 신호 (포그라운드일 때 즉시, 백그라운드면 켜질 때 실행됨) + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + + // 음악/진동 정지 + self.stopAlarm(keepSilent: false) // 아예 무음까지 끄기 + + // 메인 뷰에 타임아웃 팝업 띄우라고 신호 + NotificationCenter.default.post(name: NSNotification.Name("alarmDidTimeout"), object: nil) + } + + autoStopWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 120.0, execute: workItem) + } +} diff --git a/Atcha-iOS/Core/Manager/HomeArrivalManager.swift b/Atcha-iOS/Core/Manager/HomeArrivalManager.swift new file mode 100644 index 0000000..ab46966 --- /dev/null +++ b/Atcha-iOS/Core/Manager/HomeArrivalManager.swift @@ -0,0 +1,59 @@ +// +// HomeArrivalManager.swift +// Atcha-iOS +// +// Created by wodnd on 3/11/26. +// + +import Foundation +import CoreLocation +import UserNotifications +import UIKit + +final class HomeArrivalManager { + static let shared = HomeArrivalManager() + private init() {} + + private var isArrivalSignalSent = false + + func checkHomeArrival(currentCoord: CLLocationCoordinate2D) { + let wrapper = UserDefaultsWrapper.shared + + guard wrapper.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) == true else { + isArrivalSignalSent = false + return + } + + guard !isArrivalSignalSent else { return } + + let homeLat = wrapper.double(forKey: UserDefaultsWrapper.Key.homeLat.rawValue) ?? 0.0 + let homeLon = wrapper.double(forKey: UserDefaultsWrapper.Key.homeLon.rawValue) ?? 0.0 + + guard homeLat != 0 && homeLon != 0 else { return } + + let homeLoc = CLLocation(latitude: homeLat, longitude: homeLon) + let currentLoc = CLLocation(latitude: currentCoord.latitude, longitude: currentCoord.longitude) + + let distance = currentLoc.distance(from: homeLoc) + + if distance <= 50 { + isArrivalSignalSent = true + + let isForeground = UIApplication.shared.applicationState == .active + + if !isForeground { + AlarmManager.shared.sendImmediateLocalPush( + title: "막차 안내 종료", + body: "목적지 부근에 도착했어요", + playSound: true + ) + } + + NotificationCenter.default.post(name: NSNotification.Name("userArrivedHome"), object: nil) + } + } + + func reset() { + isArrivalSignalSent = false + } +} diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index ba4d706..6bfae4b 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift @@ -180,7 +180,12 @@ final class DetailRouteViewModel: BaseViewModel { finalCoord = smoother.snap(current: smoothedCoord, polyline: allCoords) } - await MainActor.run { self.currentLocation = finalCoord } + let capturedCoord = finalCoord + + await MainActor.run { + self.currentLocation = capturedCoord + HomeArrivalManager.shared.checkHomeArrival(currentCoord: capturedCoord) + } self.calculateProximity(coord: finalCoord) } diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 41ca0a7..ddfa17d 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -316,6 +316,8 @@ extension MainViewController { bindDeviceHeadingUpdates() bindPermissionAlert() bindAlarmFireStatus() + observeArrival() + observeAlarmTimeout() } private func bindPermissionAlert() { @@ -682,6 +684,12 @@ extension MainViewController { self.exitButtonTapped() guard self.presentedViewController == nil else { return } + + AlarmManager.shared.sendImmediateLocalPush( + title: "출발 알람이 자동 종료되었어요", + body: "클릭해서 경로 재탐색하기" + ) + self.showAlarmTimeoutPopup() self.viewModel.showAlarmStopPopUpView = false } @@ -1418,3 +1426,64 @@ extension MainViewController { } } +// MARK: - 도착 자동 종료 처리 +extension MainViewController { + private func observeArrival() { + NotificationCenter.default.publisher(for: NSNotification.Name("userArrivedHome")) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self = self else { return } + + // 1. 사용자가 다른 화면(상세 경로 등)에 있다면 무조건 메인으로 강제 이동 + self.navigationController?.popToRootViewController(animated: true) + + // 2. 백그라운드에서 즉시 알람 종료 통신 및 지도/UI 초기화 실행 + self.viewModel.alarmDelete() + self.exitButtonTapped() + + // 3. 안내용 팝업 띄우기 (화면 이동이 끝난 0.3초 뒤에 띄워서 자연스럽게) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.showArrivalPopup() + } + } + .store(in: &cancellables) + } + + private func showArrivalPopup() { + // 이미 팝업이 떠 있다면 무시 (중복 방지) + if presentedViewController is AtchaPopupViewController { return } + + let popupVM = AtchaPopupViewModel(info: .arrive) + 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() + }, for: .touchUpInside) + + self.present(popupVC, animated: false) + } + + private func observeAlarmTimeout() { + NotificationCenter.default.publisher(for: NSNotification.Name("alarmDidTimeout")) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self = self else { return } + + // 1. 무조건 메인으로 강제 이동 + self.navigationController?.popToRootViewController(animated: true) + + // 2. 백그라운드 취소 로직 + self.viewModel.alarmDelete() + self.exitButtonTapped() + + // 3. 타임아웃 팝업 띄우기 + self.showAlarmTimeoutPopup() + } + .store(in: &cancellables) + } +} diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index 3cabc31..ff1bc29 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -56,7 +56,7 @@ final class MainViewModel: BaseViewModel{ @Published var showLocationDeniedAlert: Bool = false @Published var isGuest: Bool = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.isGuest.rawValue) ?? false - + private var didSendInitialLocation = false private let smoother = LocationSmoother(limit: 5) init(authorizationUseCase: RequestLocationAuthorizationUseCase, @@ -227,7 +227,6 @@ final class MainViewModel: BaseViewModel{ self.startHeading() streamTask = Task { - var didSendInitialLocation = false for await location in streamUseCase.startUpdate() { // 정확도 필터링 (너무 튀는 값 제거) guard location.horizontalAccuracy < 150 else { continue } @@ -247,12 +246,15 @@ final class MainViewModel: BaseViewModel{ } } + let capturedCoord = finalCoord + await MainActor.run { - self.currentLocation = finalCoord + self.currentLocation = capturedCoord if !didSendInitialLocation { - self.selectedLocation = finalCoord + self.selectedLocation = capturedCoord didSendInitialLocation = true } + HomeArrivalManager.shared.checkHomeArrival(currentCoord: capturedCoord) } } } @@ -397,7 +399,7 @@ extension MainViewModel { showLockView = true AlarmManager.shared.startAlarm(title: "눌러서 출발 알람 끄기", body: "자리에서 일어나야 할 시간이에요!") - startAlarmTimeoutTimer() +// startAlarmTimeoutTimer() stopAlarmTimer() } else { print("미래") @@ -606,27 +608,27 @@ extension MainViewModel { // MARK: - 2분 타임아웃 extension MainViewModel { - private func startAlarmTimeoutTimer() { - alarmTimeoutCancellable?.cancel() - - let task = Task { [weak self] in - guard let self else { return } - - do { - try await Task.sleep(nanoseconds: 120 * 1_000_000_000) - } catch { - return - } - - guard !Task.isCancelled else { return } - - await MainActor.run { - self.routeHandler?(.dismissLockScreen) - } - } - - alarmTimeoutCancellable = AnyCancellable { task.cancel() } - } +// private func startAlarmTimeoutTimer() { +// alarmTimeoutCancellable?.cancel() +// +// let task = Task { [weak self] in +// guard let self else { return } +// +// do { +// try await Task.sleep(nanoseconds: 120 * 1_000_000_000) +// } catch { +// return +// } +// +// guard !Task.isCancelled else { return } +// +// await MainActor.run { +// self.routeHandler?(.dismissLockScreen) +// } +// } +// +// alarmTimeoutCancellable = AnyCancellable { task.cancel() } +// } func stopAlarmTimeoutTimer() { alarmTimeoutCancellable?.cancel() diff --git a/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift b/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift index 5cb5cf9..44747d5 100644 --- a/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift +++ b/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift @@ -15,6 +15,7 @@ enum AtcahPopuInfo { case course case announeExit case alarmTimeout + case arrive var title: String { switch self { @@ -25,6 +26,7 @@ enum AtcahPopuInfo { case .course : return "배차 간격이 긴 버스가 포함되어\n환승 대기 시간이 길어질 수 있어요.\n막차 알람을 등록할까요?" case .announeExit: return "" case .alarmTimeout: return "예정된 출발 시간이 지나\n알람이 자동으로 종료됐어요" + case .arrive: return "목적지 부근에 도착해\n안내를 종료합니다" } } @@ -37,12 +39,13 @@ enum AtcahPopuInfo { case .course: return "알람 받기" case .announeExit: return "확인" case .alarmTimeout: return "닫기" + case .arrive: return "확인" } } var confrimBackgroundColor: UIColor { switch self { - case .alarm, .re_register, .course: return .main + case .alarm, .re_register, .course, .arrive: return .main case .alarmTimeout: return .gray910 default: return .white } diff --git a/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift b/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift index 7e057b3..cff25f9 100644 --- a/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift +++ b/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift @@ -81,7 +81,7 @@ final class AtchaPopupViewController: BaseViewController { confirmButton.backgroundColor = info.confrimBackgroundColor // alarmTimeout에서는 cancel이 없으니, cancel 세팅은 조건부로 - if info != .alarmTimeout { + if info != .alarmTimeout || info != .arrive { let cancelAttr = AtchaFont.B5_SB_14(info.cancelTitle, color: info.cancelForegroundColor) cancelButton.setAttributedTitle(cancelAttr, for: .normal) cancelButton.backgroundColor = info.cancelBackgroundColor From e7fded31db19375ddb5d752081059957c6716b4d Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 11 Mar 2026 13:15:39 +0900 Subject: [PATCH 08/10] =?UTF-8?q?[BUGFIX]=20=EC=95=8C=EB=9E=8C=20=ED=83=80?= =?UTF-8?q?=EC=9E=84=EC=95=84=EC=9B=83=20=EC=A2=85=EB=A3=8C=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EB=B3=B5=EA=B7=80=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Atcha-iOS/Core/Manager/AlarmManager.swift | 2 ++ .../Location/MainViewController.swift | 33 ++++++------------- .../Presentation/Location/MainViewModel.swift | 1 - .../Lock/LockViewController.swift | 21 +++++++++--- .../Presentation/Main/MainCoordinator.swift | 13 +++----- 5 files changed, 32 insertions(+), 38 deletions(-) diff --git a/Atcha-iOS/Core/Manager/AlarmManager.swift b/Atcha-iOS/Core/Manager/AlarmManager.swift index 2d0cd2d..2a2fa3b 100644 --- a/Atcha-iOS/Core/Manager/AlarmManager.swift +++ b/Atcha-iOS/Core/Manager/AlarmManager.swift @@ -627,6 +627,8 @@ extension AlarmManager { // 음악/진동 정지 self.stopAlarm(keepSilent: false) // 아예 무음까지 끄기 + UserDefaults.standard.set(true, forKey: "isAlarmTimedOut") + // 메인 뷰에 타임아웃 팝업 띄우라고 신호 NotificationCenter.default.post(name: NSNotification.Name("alarmDidTimeout"), object: nil) } diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index ddfa17d..81f5265 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -312,7 +312,6 @@ extension MainViewController { bindTaxiFareUpdates() bindServiceRegionUpdates() bindLockView() - bindAlarmTimeoutView() bindDeviceHeadingUpdates() bindPermissionAlert() bindAlarmFireStatus() @@ -465,7 +464,6 @@ extension MainViewController { guard let self else { return } popupVC?.dismiss(animated: false) AlarmManager.shared.alarmInit() - self.viewModel.showAlarmStopPopUpView = false }, for: .touchUpInside) popupVC.modalPresentationStyle = .overFullScreen @@ -675,26 +673,6 @@ extension MainViewController { .store(in: &cancellables) } - private func bindAlarmTimeoutView() { - viewModel.$showAlarmStopPopUpView - .receive(on: RunLoop.main) - .sink { [weak self] show in - guard let self, show else { return } - self.viewModel.alarmDelete() - self.exitButtonTapped() - - guard self.presentedViewController == nil else { return } - - AlarmManager.shared.sendImmediateLocalPush( - title: "출발 알람이 자동 종료되었어요", - body: "클릭해서 경로 재탐색하기" - ) - - self.showAlarmTimeoutPopup() - self.viewModel.showAlarmStopPopUpView = false - } - .store(in: &cancellables) - } private func commonAlarmSetupView() { updateAtchaImageConstraint(relativeTo: lastTrainDepartView) @@ -1481,8 +1459,17 @@ extension MainViewController { self.viewModel.alarmDelete() self.exitButtonTapped() + if let coord = self.viewModel.currentLocation { + self.mapContainerView.setupCenter(location: coord) + self.viewModel.selectedLocation = coord // 주소도 다시 검색 + } else { + self.viewModel.setupLocation() + } + // 3. 타임아웃 팝업 띄우기 - self.showAlarmTimeoutPopup() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.showAlarmTimeoutPopup() + } } .store(in: &cancellables) } diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index ff1bc29..15880a2 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -32,7 +32,6 @@ final class MainViewModel: BaseViewModel{ @Published var bottomType: MapBottomType? @Published var showLockView: Bool = false - @Published var showAlarmStopPopUpView: Bool = false @Published var departureStr: String? // @Published var currentCourse: CLLocationDirection? diff --git a/Atcha-iOS/Presentation/Lock/LockViewController.swift b/Atcha-iOS/Presentation/Lock/LockViewController.swift index 952fff8..14bcac8 100644 --- a/Atcha-iOS/Presentation/Lock/LockViewController.swift +++ b/Atcha-iOS/Presentation/Lock/LockViewController.swift @@ -27,6 +27,7 @@ final class LockViewController: BaseViewController { bind() setupUI() setupAutoLayout() + observeAlarmTimeout() } override func viewWillAppear(_ animated: Bool) { @@ -56,13 +57,13 @@ final class LockViewController: BaseViewController { .receive(on: DispatchQueue.main) .sink { [weak self] fare in guard let self = self else { return } - + if fare == 0 { self.taxiFareLabel.attributedText = - AtchaFont.D1_EB_56("계산 중...", color: AtchaColor.Bus.widearea) + AtchaFont.D1_EB_56("계산 중...", color: AtchaColor.Bus.widearea) } else { self.taxiFareLabel.attributedText = - AtchaFont.D1_EB_56("-\(fare.formattedWithComma)", color: AtchaColor.Bus.widearea) + AtchaFont.D1_EB_56("-\(fare.formattedWithComma)", color: AtchaColor.Bus.widearea) } } .store(in: &cancellables) @@ -151,12 +152,12 @@ final class LockViewController: BaseViewController { true, forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue ) - + AmplitudeManager.shared.track(.start_click) } @objc private func detailRouteTapped() { - + AlarmManager.shared.stopAlarm() let wrapper = UserDefaultsWrapper.shared @@ -167,4 +168,14 @@ final class LockViewController: BaseViewController { AmplitudeManager.shared.track(.later_course_click) viewModel.routerHandler?(.courseSearch(startLat: lat, startLon: lon, startAddress: address)) } + + private func observeAlarmTimeout() { + NotificationCenter.default.publisher(for: NSNotification.Name("alarmDidTimeout")) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + // 수정됨: 화면을 닫고 메인으로 돌아가는 올바른 라우터 명령 전달 + self?.viewModel.routerHandler?(.dismissLockScreen) + } + .store(in: &cancellables) + } } diff --git a/Atcha-iOS/Presentation/Main/MainCoordinator.swift b/Atcha-iOS/Presentation/Main/MainCoordinator.swift index 970f95b..d623a22 100644 --- a/Atcha-iOS/Presentation/Main/MainCoordinator.swift +++ b/Atcha-iOS/Presentation/Main/MainCoordinator.swift @@ -285,6 +285,8 @@ final class MainCoordinator { startLon: startLon, startAddress: startAddress)) } + case .dismissLockScreen: + self?.navigationController.dismiss(animated: true) default: do {} } } @@ -303,13 +305,6 @@ final class MainCoordinator { dismissPresentedIfNeeded { DispatchQueue.global(qos: .utility).async { self.mainViewModel?.showLockView = false - self.mainViewModel?.showAlarmStopPopUpView = true - - AlarmManager.shared.sendBackgroundPush( - title: "출발 알람이 자동 종료되었어요", - body: "클릭해서 경로 재탐색하기" - ) - } } case .loginSheet: @@ -329,7 +324,7 @@ final class MainCoordinator { self.mainViewModel?.isGuest = newGuestStatus if isExist { -// self.mainViewModel?.setupLocation() + // self.mainViewModel?.setupLocation() self.mainViewModel?.refreshCurrentMapCenterData() } else { self.routeToOnboarding?() @@ -372,7 +367,7 @@ extension UINavigationController { UIView.performWithoutAnimation { if let target = viewControllers.first(where: { $0 is MainViewController }) { popToViewController(target, animated: true) - } else { + } else { popToRootViewController(animated: true) } } From 933cb2780b6aae496c2144ddf01118e28c838df8 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 11 Mar 2026 13:28:15 +0900 Subject: [PATCH 09/10] =?UTF-8?q?[BUGFIX]=20=EB=8F=84=EC=B0=A9=20=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=20=EB=88=84=EB=9D=BD=20=EC=9D=B4=EC=8A=88=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/Core/Manager/HomeArrivalManager.swift | 14 +++++--------- .../Popup/AtchaPopupViewController.swift | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/Atcha-iOS/Core/Manager/HomeArrivalManager.swift b/Atcha-iOS/Core/Manager/HomeArrivalManager.swift index ab46966..eda71c9 100644 --- a/Atcha-iOS/Core/Manager/HomeArrivalManager.swift +++ b/Atcha-iOS/Core/Manager/HomeArrivalManager.swift @@ -39,15 +39,11 @@ final class HomeArrivalManager { if distance <= 50 { isArrivalSignalSent = true - let isForeground = UIApplication.shared.applicationState == .active - - if !isForeground { - AlarmManager.shared.sendImmediateLocalPush( - title: "막차 안내 종료", - body: "목적지 부근에 도착했어요", - playSound: true - ) - } + AlarmManager.shared.sendImmediateLocalPush( + title: "막차 안내 종료", + body: "목적지 부근에 도착했어요", + playSound: true + ) NotificationCenter.default.post(name: NSNotification.Name("userArrivedHome"), object: nil) } diff --git a/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift b/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift index cff25f9..33fef77 100644 --- a/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift +++ b/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift @@ -94,7 +94,7 @@ final class AtchaPopupViewController: BaseViewController { $0.removeFromSuperview() } - if info == .alarmTimeout { + if info == .alarmTimeout || info == .arrive{ buttonStackView.addArrangedSubview(confirmButton) } else { buttonStackView.addArrangedSubview(cancelButton) From 043992e0a1b174a8e30fb3ff9ecca7d77cde3989 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Wed, 11 Mar 2026 13:55:32 +0900 Subject: [PATCH 10/10] =?UTF-8?q?[BUGFIX]=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=8F=B4=EB=A7=81=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=EB=B0=8F=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DetailRouteViewController.swift | 15 +- .../DetailRoute/DetailRouteViewModel.swift | 160 ++++++++---------- 2 files changed, 83 insertions(+), 92 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift index 5a94e81..03d7e0d 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift @@ -252,6 +252,17 @@ final class DetailRouteViewController: BaseViewController, } .store(in: &cancellables) + viewModel.$isRefreshing + .receive(on: RunLoop.main) + .removeDuplicates() // 상태가 실제로 바뀔 때만 실행 + .sink { [weak self] refreshing in + // 데이터 로딩이 시작될 때(true) 버튼 애니메이션 실행 + if refreshing { + self?.refreshButton.start() + } + } + .store(in: &cancellables) + // Publishers.CombineLatest(viewModel.$legTrafficInfo, viewModel.$legtPathInfo) // .receive(on: DispatchQueue.global(qos: .userInitiated)) // .sink { [weak self] trafficInfos, pathInfos in @@ -538,14 +549,14 @@ final class DetailRouteViewController: BaseViewController, viewModel.startHeading() lastRouteFitApplied = true // 알람 시에는 Fit 방지 - // ✅ 1. 지도의 크기(제약 조건)를 여기서 먼저 결정 + // 1. 지도의 크기(제약 조건)를 여기서 먼저 결정 mapContainerView.snp.remakeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalToSuperview() make.bottom.equalToSuperview().inset(200) } - // ✅ 2. 좌표가 있다면 '애니메이션 없이' 즉시 현위치로 이동 + // 2. 좌표가 있다면 '애니메이션 없이' 즉시 현위치로 이동 if let currentCoord = viewModel.currentLocation { mapContainerView.setupCenter(location: currentCoord) // setupZoomCenter 대신 setupCenter(이동만) mapContainerView.setupZoomCenter(location: currentCoord) // 필요 시 줌까지 diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index 6bfae4b..df220fe 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift @@ -37,11 +37,9 @@ final class DetailRouteViewModel: BaseViewModel { @Published var busRealTimeInfos: [[RealTimeBusArrival]] = [] private var busRoutes: [String] = [] private var busRealTimeMap: [String: [RealTimeBusArrival]] = [:] - private var busPollingTask: Task? @Published var subwayRealTimeInfos: [SubwayRealTimeInfo] = [] private var subwayRoutes: [String] = [] - private var subwayPollingTask: Task? @Published private(set) var context: DetailRouteContext @Published var nearLegIDs: Set = [] @@ -52,6 +50,9 @@ final class DetailRouteViewModel: BaseViewModel { private let smoother = LocationSmoother(limit: 5) @Published var legPolylineById: [UUID: [CLLocationCoordinate2D]] = [:] + private var pollingTask: Task? + @Published var isRefreshing: Bool = false + //#if DEBUG //@Published var mockLocation: CLLocationCoordinate2D? = nil //#endif @@ -98,41 +99,25 @@ final class DetailRouteViewModel: BaseViewModel { self.legPolylineById = dict guard context == .afterReigster else { return } - // 버스 - let routes = infos.busInfo - .compactMap { $0.routeName } - .filter { !$0.isEmpty && $0.contains(":") } - - busRoutes = Array(Set(routes)) // 중복 제거 - busRealTimeMap.removeAll() - busRealTimeInfos = [] - - // 최초 1회 로드 - Task { [weak self] in - await self?.refreshAllBusRealTime() - } - - // 15초 폴링 시작 - startBusPolling() - - let subwayRoutes = Array(Set( - infos.trafficInfo - .filter { $0.mode == .subway } - .compactMap { $0.route } - .filter { !$0.isEmpty } - )) - - self.subwayRoutes = subwayRoutes - // 여기서 removeAll 하면 첫 표시가 비었다가 생길 수 있음. - // "최초 진입 때만 비우고", 폴링에서는 기존 유지가 더 안정적. - self.subwayRealTimeInfos = [] - Task { [weak self] in - await self?.refreshAllSubwayRealTime() - } - startSubwayPolling() + setupRoutes() + startPolling() } + private func setupRoutes() { + let routes = infos.busInfo + .compactMap { $0.routeName } + .filter { !$0.isEmpty && $0.contains(":") } + busRoutes = Array(Set(routes)) + + subwayRoutes = Array(Set( + infos.trafficInfo + .filter { $0.mode == .subway } + .compactMap { $0.route } + .filter { !$0.isEmpty } + )) + } + @MainActor func getBusRealTimeInfo(request: String) { Task { @@ -146,6 +131,55 @@ final class DetailRouteViewModel: BaseViewModel { } } + @MainActor + func refreshAllRealTimeData() async { + guard !busRoutes.isEmpty || !subwayRoutes.isEmpty else { return } + + isRefreshing = true // 애니메이션 시작 신호 + + // async let을 사용하여 버스와 지하철 정보를 동시에(병렬) 요청함 (속도 최적화) + async let refreshBus: () = refreshAllBusRealTime() + async let refreshSubway: () = refreshAllSubwayRealTime() + + _ = await [refreshBus, refreshSubway] + + // 애니메이션이 시각적으로 잘 보이도록 최소 0.5초 대기 후 종료 + try? await Task.sleep(nanoseconds: 500_000_000) + isRefreshing = false + } + + private func startPolling() { + stopPolling() + + pollingTask = Task { [weak self] in + guard let self = self else { return } + + // 1. 진입 시 최초 1회 즉시 실행 + await self.refreshAllRealTimeData() + + while !Task.isCancelled { + // 2. 15초 대기 + try? await Task.sleep(nanoseconds: 15_000_000_000) + + // 3. 루프 중간에 취소 여부 및 알람 등록 상태 재확인 + let isRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false + if !isRegistered || Task.isCancelled { + self.stopPolling() + break + } + + // 4. 통합 데이터 새로고침 실행 + await self.refreshAllRealTimeData() + } + } + } + + func stopPolling() { + pollingTask?.cancel() + pollingTask = nil + } + + @MainActor func getSubwayRealTimeInfo(routeName: String) async { do { @@ -212,8 +246,7 @@ final class DetailRouteViewModel: BaseViewModel { deinit { stopTracking() - stopBusPolling() - stopSubwayPolling() + stopPolling() } @@ -238,32 +271,7 @@ final class DetailRouteViewModel: BaseViewModel { } } - private func startBusPolling() { - stopBusPolling() - - busPollingTask = Task { [weak self] in - guard let self else { return } - - while !Task.isCancelled { - let isRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false - if !isRegistered { - print("알람 등록 해제 감지: 버스 폴링 중단") - self.stopBusPolling() - break - } - - try? await Task.sleep(nanoseconds: 15_000_000_000) - if Task.isCancelled { break } - await self.refreshAllBusRealTime() - } - } - } - - private func stopBusPolling() { - busPollingTask?.cancel() - busPollingTask = nil - } - + @MainActor private func refreshAllBusRealTime() async { guard !busRoutes.isEmpty else { return } @@ -282,34 +290,6 @@ final class DetailRouteViewModel: BaseViewModel { } // MARK: - Subway Polling - - private func startSubwayPolling() { - stopSubwayPolling() - - subwayPollingTask = Task { [weak self] in - guard let self else { return } - - while !Task.isCancelled { - // 추가: 알람 등록 상태 확인 - let isRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false - if !isRegistered { - print("알람 등록 해제 감지: 지하철 폴링 중단") - self.stopSubwayPolling() - break - } - - try? await Task.sleep(nanoseconds: 15_000_000_000) - if Task.isCancelled { break } - await self.refreshAllSubwayRealTime() - } - } - } - - private func stopSubwayPolling() { - subwayPollingTask?.cancel() - subwayPollingTask = nil - } - @MainActor private func refreshAllSubwayRealTime() async { guard !subwayRoutes.isEmpty else { return }