diff --git a/Atcha-iOS.xcodeproj/project.pbxproj b/Atcha-iOS.xcodeproj/project.pbxproj index d83766fe..b2665eb7 100644 --- a/Atcha-iOS.xcodeproj/project.pbxproj +++ b/Atcha-iOS.xcodeproj/project.pbxproj @@ -153,6 +153,7 @@ 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 */; }; + 6DC617AD2F62EB59002DD641 /* HomeRegistrationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC617AC2F62EB59002DD641 /* HomeRegistrationCoordinator.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 */; }; @@ -476,6 +477,7 @@ 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 = ""; }; + 6DC617AC2F62EB59002DD641 /* HomeRegistrationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeRegistrationCoordinator.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 = ""; }; @@ -691,6 +693,7 @@ 6D1EE2D62E08E4AB00F7BBF1 /* HomeRegister */ = { isa = PBXGroup; children = ( + 6DC617AB2F62EB4B002DD641 /* Coordinator */, 6D1EE2D92E08E4E000F7BBF1 /* HomeRegisterViewController.swift */, 6D1EE2DB2E08E4EA00F7BBF1 /* HomeRegisterViewModel.swift */, ); @@ -1113,6 +1116,21 @@ path = Login; sourceTree = ""; }; + 6DC617AA2F62E931002DD641 /* ChangeHome */ = { + isa = PBXGroup; + children = ( + ); + path = ChangeHome; + sourceTree = ""; + }; + 6DC617AB2F62EB4B002DD641 /* Coordinator */ = { + isa = PBXGroup; + children = ( + 6DC617AC2F62EB59002DD641 /* HomeRegistrationCoordinator.swift */, + ); + path = Coordinator; + sourceTree = ""; + }; 6DD632AE2E4F8A6600C6A66E /* SearchLocation */ = { isa = PBXGroup; children = ( @@ -1311,6 +1329,7 @@ B65C12D82E042A320016D2F0 /* DIContainer */ = { isa = PBXGroup; children = ( + 6DC617AA2F62E931002DD641 /* ChangeHome */, 6D61ABE62F57174500111C9B /* Intro */, B61C44962E40340C00285A4B /* LockScreen */, 6D2B8CB62E39C87F00608104 /* BusInfo */, @@ -2150,6 +2169,7 @@ 6DB7636D2E45C69400D06A49 /* AlarmRepository.swift in Sources */, 6DB7636F2E45C6D100D06A49 /* AlarmRepositoryImpl.swift in Sources */, B65C12E22E042D400016D2F0 /* FetchUserUseCase.swift in Sources */, + 6DC617AD2F62EB59002DD641 /* HomeRegistrationCoordinator.swift in Sources */, B6EDD73C2E0D87F2006170DF /* FileStorageImpl.swift in Sources */, B6793D4B2E3493D6001BE9F5 /* AtcahaInsetLabel.swift in Sources */, 6D2B8CB52E39AC7200608104 /* BusPositionInfoResponse.swift in Sources */, diff --git a/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift b/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift index 866c9924..636b6a63 100644 --- a/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift @@ -48,6 +48,10 @@ final class MainDIContainer { LoginDIContainer(apiService: apiService) }() + private lazy var homeRegisterDI: HomeRegisterDIContainer = { + HomeRegisterDIContainer(apiService: apiService, locationStateHolder: locationStateHolder) + }() + init(apiService: APIService, locationStateHolder: LocationStateHolder) { self.apiService = apiService self.locationStateHolder = locationStateHolder @@ -128,3 +132,10 @@ extension MainDIContainer{ } } + +// MARK: - HomeRegister +extension MainDIContainer{ + func makeHomeRegisterDIContainer() -> HomeRegisterDIContainer { + return homeRegisterDI + } +} diff --git a/Atcha-iOS/Core/Manager/LocationSmoother.swift b/Atcha-iOS/Core/Manager/LocationSmoother.swift index 859c4a17..e63fa2da 100644 --- a/Atcha-iOS/Core/Manager/LocationSmoother.swift +++ b/Atcha-iOS/Core/Manager/LocationSmoother.swift @@ -12,18 +12,23 @@ import MapKit final class LocationSmoother { private var buffer: [CLLocationCoordinate2D] = [] private let bufferLimit: Int - + init(limit: Int = 5) { self.bufferLimit = limit } - + + func reset(_ coordinate: CLLocationCoordinate2D) { + buffer.removeAll() + buffer.append(coordinate) + } + 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) } } diff --git a/Atcha-iOS/DesignSource/AtchaColor/AtchaColor.swift b/Atcha-iOS/DesignSource/AtchaColor/AtchaColor.swift index 96a5e006..a10425d2 100644 --- a/Atcha-iOS/DesignSource/AtchaColor/AtchaColor.swift +++ b/Atcha-iOS/DesignSource/AtchaColor/AtchaColor.swift @@ -82,6 +82,8 @@ enum AtchaColor{ static let remainTime = UIColor(named: "remainTime")! static let paddingLabel = UIColor(named: "paddingLabel")! } + + static let neutral = UIColor(named: "neutral") // MARK: - 사용 예시 // // titleLabel.textColor = AtchaColor.gray900 diff --git a/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Line/Contents.json b/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Line/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Line/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Line/neutral.colorset/Contents.json b/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Line/neutral.colorset/Contents.json new file mode 100644 index 00000000..2ac6711a --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Line/neutral.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x42", + "green" : "0x3C", + "red" : "0x39" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/Presentation/Location/Coordinator/MainRoute.swift b/Atcha-iOS/Presentation/Location/Coordinator/MainRoute.swift index d8619350..59384ca2 100644 --- a/Atcha-iOS/Presentation/Location/Coordinator/MainRoute.swift +++ b/Atcha-iOS/Presentation/Location/Coordinator/MainRoute.swift @@ -16,4 +16,5 @@ enum MainRoute { case proximity // 가까운 거리 알림 모달 case dismissLockScreen case loginSheet + case changeHome } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift index ad5e9724..df69983c 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift @@ -83,6 +83,10 @@ final class DetailRouteBusCell: UICollectionViewCell { private var isArrivedEffectOn = false private var isAlarmFired: Bool = false + private var hasMetZero: Bool = false + private var isBoarded: Bool = false + private var hasDepartedStartStation: Bool = false + override init(frame: CGRect) { super.init(frame: frame) setupUI() @@ -114,6 +118,10 @@ final class DetailRouteBusCell: UICollectionViewCell { isAlarmFired = false busTimerStackView.isHidden = true + + hasMetZero = false + isBoarded = false + hasDepartedStartStation = false } override func preferredLayoutAttributesFitting( @@ -354,12 +362,24 @@ final class DetailRouteBusCell: UICollectionViewCell { } } - func isNowUserLocationArrived() { - if isArrivedEffectOn { return } + func isNowUserLocationArrived(hasDeparted: Bool = false) { + // 정류장에 도착해서 0초(도착)를 본 적이 있을 때만 '출발'을 인정합니다. + if hasDeparted && hasMetZero { + self.hasDepartedStartStation = true + } + + if isArrivedEffectOn { + // 출발 상태가 들어왔다면 라벨 갱신 (탑승 완료 띄우기) + if self.hasDepartedStartStation { updateBusTimerLabels() } + return + } + isArrivedEffectOn = true animationView.isHidden = false animationView.startAnimationIfNeeded(forceRestart: true) backgroundColor = UIColor.opacity100 + + updateBusTimerLabels() } func stopArrivedEffectIfNeeded() { @@ -479,28 +499,30 @@ extension DetailRouteBusCell { } // 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 = "" - return + 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) + return + } + + updateBusTimerLabels() } - - updateBusTimerLabels() - } private func updateBusTimerLabels() { @@ -512,15 +534,42 @@ extension DetailRouteBusCell { busTimerStackView.isHidden = false func labelText(for info: RealTimeBusArrival) -> NSAttributedString { + if self.isBoarded { + return AtchaFont.B6_R_14("탑승 완료", color: .gray300) + } + if info.busStatus == .end { return AtchaFont.B6_R_14("운행 종료", color: .gray) } guard let remaining = info.remainingTime else { - return AtchaFont.B6_R_14("", color: .gray300) + // 예외처리: API 갱신으로 정보가 날아갔는데 0초를 본 적이 있다면 탑승으로 간주 + if self.hasMetZero { + self.isBoarded = true + return AtchaFont.B6_R_14("탑승 완료", color: .gray300) + } else { + return AtchaFont.B6_R_14("", color: .gray300) + } + } + + if remaining <= 0 { + self.hasMetZero = true + } + + if self.hasMetZero { + // (1) 도착 후 60초가 지났거나 + // (2) 0초를 봤는데 API가 갱신되었거나 + // (3) 정류장에서 150m 멀어짐을 감지했을 때 + if remaining <= -60 || (remaining > 0 && self.isArrivedEffectOn) || self.hasDepartedStartStation { + self.isBoarded = true + self.stopCountdownTimer() // 버스 타이머 멈춤 + return AtchaFont.B6_R_14("탑승 완료", color: .gray300) + } } - if remaining <= 120 { + if remaining <= 0 { + return AtchaFont.B6_R_14("도착 또는 출발", color: .widearea) + } else if remaining <= 120 { return AtchaFont.B6_R_14("곧 도착", color: .widearea) } else { return AtchaFont.B6_R_14(formatSecondsToHMS(remaining), color: .widearea) diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift index f0a79610..68bcb1de 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift @@ -73,6 +73,10 @@ final class DetailRouteSubwayCell: UICollectionViewCell { var currentLegTrafficInfo: LegTrafficInfo? = nil private var isArrivedEffectOn = false private var isAlarmFired: Bool = false + private var hasMetZero: Bool = false + private var isBoarded: Bool = false + private var hasDepartedStartStation: Bool = false + override init(frame: CGRect) { super.init(frame: frame) @@ -108,6 +112,10 @@ final class DetailRouteSubwayCell: UICollectionViewCell { currentRemainingSec = nil isAlarmFired = false subwayTimerLabel.isHidden = true + + hasMetZero = false + isBoarded = false + hasDepartedStartStation = false } override func preferredLayoutAttributesFitting( @@ -328,12 +336,23 @@ final class DetailRouteSubwayCell: UICollectionViewCell { } } - func isNowUserLocationArrived() { - if isArrivedEffectOn { return } + func isNowUserLocationArrived(hasDeparted: Bool = false) { + if hasDeparted && hasMetZero { + self.hasDepartedStartStation = true + } + + if isArrivedEffectOn { + // 출발 상태가 들어왔다면 라벨 갱신 (탑승 완료 띄우기) + if self.hasDepartedStartStation { updateSubwayTimerLabel() } + return + } + isArrivedEffectOn = true animationView.isHidden = false animationView.startAnimationIfNeeded(forceRestart: true) backgroundColor = UIColor.opacity100 + + updateSubwayTimerLabel() } func stopArrivedEffectIfNeeded() { @@ -481,17 +500,14 @@ extension DetailRouteSubwayCell { return } + // 무조건 1초씩 뺌 (음수 허용) let next = sec - 1 currentRemainingSec = next - if next <= 0 { - currentRemainingSec = 0 - updateSubwayTimerLabel() - stopSubwayCountdownTimer() - return - } + updateSubwayTimerLabel() // 라벨 업데이트 (판정은 여기서 알아서 함) - updateSubwayTimerLabel() + // 탑승 완료 자물쇠가 잠기면 타이머 정지 + if isBoarded { stopSubwayCountdownTimer() } } private func updateSubwayTimerLabel() { @@ -503,12 +519,38 @@ extension DetailRouteSubwayCell { subwayTimerLabel.isHidden = false + if isBoarded { + subwayTimerLabel.attributedText = AtchaFont.B6_R_14("탑승 완료", color: .gray300) + return + } + guard let sec = currentRemainingSec else { - subwayTimerLabel.attributedText = AtchaFont.B6_R_14("", color: .widearea) + if hasMetZero { + isBoarded = true + subwayTimerLabel.attributedText = AtchaFont.B6_R_14("탑승 완료", color: .gray300) + } else { + subwayTimerLabel.attributedText = AtchaFont.B6_R_14("", color: .widearea) + } return } - if sec == 0 { + if sec <= 0 { + hasMetZero = true + } + + if hasMetZero { + // (1) 역에서 150m 이상 멀어짐 (ViewModel에서 알려줌) + // (2) 0초 도달 후 1분(-60초)이 경과함 + // (3) 아직 애니메이션 켜져 있는데 API가 다음 배차(sec > 0)로 갱신됨 + if self.hasDepartedStartStation || sec <= -60 || (sec > 0 && isArrivedEffectOn) { + isBoarded = true // 자물쇠 딸깍 + subwayTimerLabel.attributedText = AtchaFont.B6_R_14("탑승 완료", color: .gray300) + stopSubwayCountdownTimer() // 타이머 종료 + return + } + } + + if sec <= 0 { subwayTimerLabel.attributedText = AtchaFont.B6_R_14("도착 또는 출발", color: .widearea) return } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift index 05f586e7..4fca61b2 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift @@ -377,15 +377,19 @@ extension DetailRouteInfoBottomView { } } - func updateProximityHighlight(nearLegIDs: Set) { + func updateProximityHighlight(nearLegIDs: Set, departedLegIDs: Set = []) { let actualNearIDs = isAlarmFired ? nearLegIDs : [] + let actualDepartedIDs = isAlarmFired ? departedLegIDs : [] currentNearLegIDs = actualNearIDs for cell in collectionView.visibleCells { if let busCell = cell as? DetailRouteBusCell, let leg = busCell.currentLegTrafficInfo { + + let hasDeparted = actualDepartedIDs.contains(leg.id) + if actualNearIDs.contains(leg.id) { - busCell.isNowUserLocationArrived() + busCell.isNowUserLocationArrived(hasDeparted: hasDeparted) } else { busCell.stopArrivedEffectIfNeeded() } @@ -393,8 +397,10 @@ extension DetailRouteInfoBottomView { if let subwayCell = cell as? DetailRouteSubwayCell, let leg = subwayCell.currentLegTrafficInfo { + let hasDeparted = actualDepartedIDs.contains(leg.id) + if actualNearIDs.contains(leg.id) { - subwayCell.isNowUserLocationArrived() + subwayCell.isNowUserLocationArrived(hasDeparted: hasDeparted) } else { subwayCell.stopArrivedEffectIfNeeded() } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift index 03d7e0d2..601da4e4 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift @@ -227,12 +227,22 @@ final class DetailRouteViewController: BaseViewController, .sink { [weak self] address in self?.bottomSheet.setupStartAddress(address) } .store(in: &cancellables) - viewModel.$nearLegIDs + // viewModel.$nearLegIDs + // .receive(on: RunLoop.main) + // .sink { [weak self] near in + // guard let self = self else { return } + // let idsToHighlight = self.isAlarmFired ? near : [] + // self.bottomSheet.updateProximityHighlight(nearLegIDs: idsToHighlight) + // } + // .store(in: &cancellables) + Publishers.CombineLatest(viewModel.$nearLegIDs, viewModel.$departedLegIDs) .receive(on: RunLoop.main) - .sink { [weak self] near in + .sink { [weak self] near, departed in guard let self = self else { return } - let idsToHighlight = self.isAlarmFired ? near : [] - self.bottomSheet.updateProximityHighlight(nearLegIDs: idsToHighlight) + let actualNear = self.isAlarmFired ? near : [] + let actualDeparted = self.isAlarmFired ? departed : [] + + self.bottomSheet.updateProximityHighlight(nearLegIDs: actualNear, departedLegIDs: actualDeparted) } .store(in: &cancellables) diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index df220fed..7b484c3e 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift @@ -43,6 +43,7 @@ final class DetailRouteViewModel: BaseViewModel { @Published private(set) var context: DetailRouteContext @Published var nearLegIDs: Set = [] + @Published var departedLegIDs: Set = [] @Published var deviceHeading: CLLocationDirection? private let headingManager = HeadingManager() @@ -53,6 +54,9 @@ final class DetailRouteViewModel: BaseViewModel { private var pollingTask: Task? @Published var isRefreshing: Bool = false + private var lastValidTime: Date? = nil + private var consecutiveValidCount = 0 + //#if DEBUG //@Published var mockLocation: CLLocationCoordinate2D? = nil //#endif @@ -105,18 +109,18 @@ final class DetailRouteViewModel: BaseViewModel { } 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 } - )) - } + 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) { @@ -132,52 +136,52 @@ 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 - } + 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() + stopPolling() + + pollingTask = Task { [weak self] in + guard let self = self else { return } - pollingTask = Task { [weak self] in - guard let self = self else { return } - - // 1. 진입 시 최초 1회 즉시 실행 - await self.refreshAllRealTimeData() + // 1. 진입 시 최초 1회 즉시 실행 + await self.refreshAllRealTimeData() + + while !Task.isCancelled { + // 2. 15초 대기 + try? await Task.sleep(nanoseconds: 15_000_000_000) - 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() + // 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 - } + } + + func stopPolling() { + pollingTask?.cancel() + pollingTask = nil + } @MainActor @@ -199,7 +203,37 @@ final class DetailRouteViewModel: BaseViewModel { streamTask?.cancel() streamTask = Task { for await location in streamUseCase.startUpdate() { - guard location.horizontalAccuracy < 150 else { continue } + let now = Date() + let timeGap = self.lastValidTime != nil ? now.timeIntervalSince(self.lastValidTime!) : 999.0 + let isRecovering = timeGap > 60.0 + + let accuracyThreshold = isRecovering ? 300.0 : 150.0 + + guard location.horizontalAccuracy < accuracyThreshold else { + self.consecutiveValidCount = 0 + continue + } + + if isRecovering { + // 지상 탈출이 의심될 때: 바로 안 믿고 카운터를 올립니다. + self.consecutiveValidCount += 1 + + if self.consecutiveValidCount >= 3 { + // 3번 연속(약 3초) 정상 신호가 들어왔다? 이건 100% 진짜 지상이다! + smoother.reset(location.coordinate) + + // 리셋했으니 이제 평상시 상태로 복구 + self.lastValidTime = now + self.consecutiveValidCount = 0 + } else { + // 아직 1~2번만 들어왔으면 마커를 움직이지 않고 무시 (가짜일 수 있으므로 대기) + continue + } + } else { + // 평상시 (지상에서 잘 걸어 다니고 있을 때) + self.lastValidTime = now + self.consecutiveValidCount = 0 // 평소엔 카운터 필요 없음 + } // 항상 Smoothing 적용 let smoothedCoord = smoother.smooth(location.coordinate) @@ -271,7 +305,7 @@ final class DetailRouteViewModel: BaseViewModel { } } - + @MainActor private func refreshAllBusRealTime() async { guard !busRoutes.isEmpty else { return } @@ -346,8 +380,25 @@ extension DetailRouteViewModel { picked = [first] } + var departed: Set = [] + if let activeID = picked.first, + let leg = orderedLegs.first(where: { $0.id == activeID }), + let firstStop = leg.passStopList?.first, + let latStr = firstStop.lat, let lat = Double(latStr), + let lonStr = firstStop.lon, let lon = Double(lonStr) { + + let startLoc = CLLocation(latitude: lat, longitude: lon) + let currentLoc = CLLocation(latitude: coord.latitude, longitude: coord.longitude) + + // 첫 정류장에서 300m 이상 벗어났다면 '출발(탑승)'한 것으로 간주 + if currentLoc.distance(from: startLoc) > 300 { + departed.insert(activeID) + } + } + DispatchQueue.main.async { self.nearLegIDs = picked + self.departedLegIDs = departed } } } diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 53563a60..0d911848 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -201,6 +201,8 @@ final class MainViewController: BaseViewController, } } + self.viewModel.refreshCurrentMapCenterData() + AmplitudeManager.shared.trackScreen(.main) } @@ -388,6 +390,12 @@ extension MainViewController { private func handleSearchViewAction(_ action: LastTrainSearchBottomView.Action) { switch action { + case .homeChangeTapped: + if viewModel.isGuest { + presentLoginAlert() + } else { + viewModel.handleRoute(route: .changeHome) + } case .currentTapped: if viewModel.isGuest { presentLoginAlert() @@ -608,11 +616,9 @@ extension MainViewController { guard let self = self else { return } self.updateAddress(addr) - self.latestIsServiceRegion = nil - self.latestFareString = nil - if self.hasShownInitialBalloon { if self.latestIsServiceRegion == false { + self.latestFareString = nil self.showOrUpdatePreBalloon( .text( top: (self.preSessionShowTopLine ?? true) ? "지도를 움직여 출발지를 설정해요" : nil, @@ -788,7 +794,13 @@ extension MainViewController { Publishers.CombineLatest(viewModel.$taxiFare, viewModel.$isGuest) .receive(on: RunLoop.main) .sink { [weak self] fare, isGuest in - guard let self = self, let fare = fare else { return } + guard let self = self else { return } + + guard let fare = fare else { + self.latestFareString = nil + self.viewModel.taxiFare = nil + return + } let fareInt = Int(fare) let fareStr = self.decimalFormatter.string(from: NSNumber(value: fareInt)) ?? "\(fareInt)" @@ -859,6 +871,7 @@ extension MainViewController { case .some(false): // 서비스 지역을 벗어남 (울산 등) self.latestFareString = nil + self.viewModel.taxiFare = nil self.lastTrainSearchView.updateSearchEnabled(false) if previous == nil { self.showInitialPreAlarmBalloons(force: true) diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index abf1317e..1c4926c8 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 isGuest: Bool = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.isGuest.rawValue) ?? false private var didSendInitialLocation = false private let smoother = LocationSmoother(limit: 5) + private var lastValidTime: Date? = nil + private var consecutiveValidCount = 0 init(authorizationUseCase: RequestLocationAuthorizationUseCase, streamUseCase: ObserveLocationStreamUseCase, @@ -95,7 +97,10 @@ final class MainViewModel: BaseViewModel{ .debounce(for: .seconds(0.3), scheduler: RunLoop.main) .sink { [weak self] loc in guard let self = self else { return } - Task { await self.updateAddressOnly(for: loc) } + Task { + await self.updateAddressOnly(for: loc) + await self.refreshRegionAndFareForCurrentAddress() + } } .store(in: &cancellables) $isGuest @@ -106,13 +111,13 @@ final class MainViewModel: BaseViewModel{ } .store(in: &cancellables) - $address - .compactMap { $0 } - .removeDuplicates() - .sink { [weak self] _ in - Task { await self?.refreshRegionAndFareForCurrentAddress() } - } - .store(in: &cancellables) + // $address + // .compactMap { $0 } + // .removeDuplicates() + // .sink { [weak self] _ in + // Task { await self?.refreshRegionAndFareForCurrentAddress() } + // } + // .store(in: &cancellables) } private func updateAddressOnly(for location: CLLocationCoordinate2D) async { @@ -123,9 +128,8 @@ final class MainViewModel: BaseViewModel{ } catch { print("❌ 역지오코딩 실패: \(error)") } } - private func refreshRegionAndFareForCurrentAddress() async { + func refreshRegionAndFareForCurrentAddress() async { self.taxiFare = nil - guard let lat = lastReverseGeocode?.lat, let lon = lastReverseGeocode?.lon else { return } @@ -136,7 +140,7 @@ final class MainViewModel: BaseViewModel{ await MainActor.run { self.isServiceRegion = ok } } catch { print("서비스 지역 확인 실패: \(error)") } - + guard self.isServiceRegion == true, !isGuest else { return } let req = FetchTaxiFareRequest( originLat: lastReverseGeocode?.lat, @@ -229,9 +233,34 @@ final class MainViewModel: BaseViewModel{ streamTask = Task { for await location in streamUseCase.startUpdate() { - // 정확도 필터링 (너무 튀는 값 제거) - guard location.horizontalAccuracy < 150 else { continue } + let now = Date() + // 앱 최초 실행 시 빠른 위치 탐색을 위해 nil이면 999.0(강제 탈출 모드) 세팅 + let timeGap = self.lastValidTime != nil ? now.timeIntervalSince(self.lastValidTime!) : 999.0 + let isRecovering = timeGap > 60.0 + let accuracyThreshold = isRecovering ? 300.0 : 150.0 + + // 정확도 필터링 + guard location.horizontalAccuracy < accuracyThreshold else { + self.consecutiveValidCount = 0 + continue + } + + if isRecovering { + self.consecutiveValidCount += 1 + + if self.consecutiveValidCount >= 3 { + smoother.reset(location.coordinate) + + self.lastValidTime = now + self.consecutiveValidCount = 0 + } else { + continue + } + } else { + self.lastValidTime = now + self.consecutiveValidCount = 0 + } // 이동 평균 필터링 (항상 적용하여 부드러운 움직임 확보) let smoothedCoord = smoother.smooth(location.coordinate) @@ -400,7 +429,7 @@ extension MainViewModel { showLockView = true AlarmManager.shared.startAlarm(title: "눌러서 출발 알람 끄기", body: "자리에서 일어나야 할 시간이에요!") -// startAlarmTimeoutTimer() + // startAlarmTimeoutTimer() stopAlarmTimer() } else { print("미래") @@ -478,6 +507,8 @@ extension MainViewModel { extension MainViewModel { func handleRoute(route: MainRoute) { switch route { + case .changeHome: + routeHandler?(.changeHome) case .changeCourse: routeHandler?(.changeCourse(location: Location( name: lastReverseGeocode?.name, diff --git a/Atcha-iOS/Presentation/Location/SearchLocation/SearchLocationViewController.swift b/Atcha-iOS/Presentation/Location/SearchLocation/SearchLocationViewController.swift index 65ccaf7d..815bf814 100644 --- a/Atcha-iOS/Presentation/Location/SearchLocation/SearchLocationViewController.swift +++ b/Atcha-iOS/Presentation/Location/SearchLocation/SearchLocationViewController.swift @@ -34,6 +34,11 @@ final class SearchLocationViewController: BaseViewController() @@ -33,15 +34,17 @@ final class LastTrainSearchBottomView: UIView { return stack }() - private lazy var arrivalLocationView: UIStackView = { - let stack = UIStackView(arrangedSubviews: [arrivalDotView, arrivalLocationLabel]) - stack.axis = .horizontal - stack.alignment = .center - stack.spacing = 8 - stack.backgroundColor = .clear - stack.isLayoutMarginsRelativeArrangement = true - stack.layoutMargins = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) - return stack + private let arrivalLocationView: UIView = { + let view = UIView() + view.backgroundColor = .clear + return view + }() + + private let arrivalImageView: UIImageView = { + let iv = UIImageView(image: .chevronRight) + iv.tintColor = AtchaColor.neutral + iv.contentMode = .scaleAspectFit + return iv }() private let searchButton: AtchaButton = AtchaButton(text: "막차 검색하기", @@ -80,6 +83,8 @@ final class LastTrainSearchBottomView: UIView { currentLocationLabel.attributedText = AtchaFont.B1_R_17(lineHeight: 0, "현위치: 조회 중..", color: .main) arrivalLocationLabel.attributedText = AtchaFont.B1_R_17(lineHeight: 0, "도착지: 우리집", color: .gray200) + arrivalLocationView.addSubViews(arrivalDotView, arrivalLocationLabel, arrivalImageView) + addSubViews(currentLocationView, arrivalLocationView, searchButton) let tap = UITapGestureRecognizer(target: self, action: #selector(handleCurrentTap)) @@ -110,10 +115,27 @@ final class LastTrainSearchBottomView: UIView { arrivalLocationView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(16) - make.top.equalTo(currentLocationView.snp.bottom).inset(-8) + make.top.equalTo(currentLocationView.snp.bottom).offset(8) make.height.equalTo(48) } + arrivalDotView.snp.makeConstraints { make in + make.width.height.equalTo(4) + make.leading.equalToSuperview().offset(12) + make.centerY.equalToSuperview() + } + + arrivalLocationLabel.snp.makeConstraints { make in + make.leading.equalTo(arrivalDotView.snp.trailing).offset(8) + make.centerY.equalToSuperview() + } + + arrivalImageView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(8) + make.centerY.equalToSuperview() + make.height.width.equalTo(20) + } + searchButton.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(18) make.top.equalTo(arrivalLocationView.snp.bottom).inset(-24) @@ -143,5 +165,6 @@ extension LastTrainSearchBottomView { } @objc private func handleDestinationTap() { + actionPublisher.send(.homeChangeTapped) } } diff --git a/Atcha-iOS/Presentation/Main/MainCoordinator.swift b/Atcha-iOS/Presentation/Main/MainCoordinator.swift index 40c8429c..0ae9a1e8 100644 --- a/Atcha-iOS/Presentation/Main/MainCoordinator.swift +++ b/Atcha-iOS/Presentation/Main/MainCoordinator.swift @@ -9,12 +9,13 @@ import UIKit import TMapSDK import Foundation -final class MainCoordinator { +final class MainCoordinator: NSObject { private let navigationController: UINavigationController private let diContainer: MainDIContainer private var myPageCoordinator: MyPageCoordinator? private var busDetailCoordinator: BusDetailCoordinator? private var loginCoordinator: LoginCoordinator? + private var homeRegisterCoordinator: HomeRegistrationCoordinator? private var mainViewModel: MainViewModel? @@ -27,6 +28,8 @@ final class MainCoordinator { diContainer: MainDIContainer) { self.navigationController = navigationController self.diContainer = diContainer + super.init() + self.navigationController.delegate = self } func start(info: LegInfo? = nil, @@ -53,6 +56,13 @@ final class MainCoordinator { private func handle(route: MainRoute) { switch route { + case .changeHome: + let homeDI = diContainer.makeHomeRegisterDIContainer() + let coordinator = HomeRegistrationCoordinator(navigationController: navigationController, diContainer: homeDI) + + self.homeRegisterCoordinator = coordinator + coordinator.start() + case .myPage: let myPageDI = diContainer.makeMyPageDIContainer() let myPageCoordinator = MyPageCoordinator( @@ -74,6 +84,8 @@ final class MainCoordinator { myPageCoordinator.withdrawFinish = { [weak self] in DispatchQueue.main.async { self?.withdrawFinish?() + + self?.myPageCoordinator = nil } } @@ -377,3 +389,22 @@ extension UINavigationController { } } } + +extension MainCoordinator: UINavigationControllerDelegate { + func navigationController(_ navigationController: UINavigationController, + didShow viewController: UIViewController, + animated: Bool) { + + guard viewController is MainViewController else { return } + + clearChildCoordinators() + mainViewModel?.refreshCurrentMapCenterData() + } + + private func clearChildCoordinators() { + self.myPageCoordinator = nil + self.busDetailCoordinator = nil + self.loginCoordinator = nil + self.homeRegisterCoordinator = nil + } +} diff --git a/Atcha-iOS/Presentation/Onboarding/HomeRegister/Coordinator/HomeRegistrationCoordinator.swift b/Atcha-iOS/Presentation/Onboarding/HomeRegister/Coordinator/HomeRegistrationCoordinator.swift new file mode 100644 index 00000000..354b8260 --- /dev/null +++ b/Atcha-iOS/Presentation/Onboarding/HomeRegister/Coordinator/HomeRegistrationCoordinator.swift @@ -0,0 +1,67 @@ +// +// HomeRegistrationCoordinator.swift +// Atcha-iOS +// +// Created by wodnd on 3/12/26. +// + +import Foundation +import UIKit +import Combine + +final class HomeRegistrationCoordinator { + private let navigationController: UINavigationController + private let diContainer: HomeRegisterDIContainer // 전용 컨테이너로 교체 + private var cancellables = Set() + + + init(navigationController: UINavigationController, + diContainer: HomeRegisterDIContainer) { + self.navigationController = navigationController + self.diContainer = diContainer + } + + func start() { + // 메인에서 진입했으므로 context를 .home(또는 해당되는 케이스)으로 주입 + let viewModel = diContainer.makeHomeRegisterViewModel(context: .home) + + viewModel.routeHandler = { [weak self] route in + self?.handleHomeRouter(route) + } + + let viewController = diContainer.makeHomeRegisterViewController(viewModel: viewModel) + + navigationController.pushViewController(viewController, animated: true) + } + + private func handleHomeRouter(_ route: HomeRouter) { + switch route { + case .searchAdress: + showSearchAddress() + case let .homeRegister(useDeviceLocation): + showHomeFind(useDeviceLocation: useDeviceLocation) + } + } + + private func showSearchAddress() { + let viewModel = diContainer.makeHomeSearchViewModel() + viewModel.routeHandler = { [weak self] route in + self?.handleHomeRouter(route) + } + + let viewController = diContainer.makeHomeSearchViewController(viewModel: viewModel) + navigationController.pushViewController(viewController, animated: true) + } + + private func showHomeFind(useDeviceLocation: Bool) { + let viewModel = diContainer.makeHomeFindViewModel(context: .home) + viewModel.forceDeviceLocation = useDeviceLocation + + viewModel.routeHandler = { [weak self] route in + self?.handleHomeRouter(route) + } + + let viewController = diContainer.makeHomeFindViewController(viewModel: viewModel) + navigationController.pushViewController(viewController, animated: true) + } +} diff --git a/Atcha-iOS/Presentation/Onboarding/HomeRegister/HomeRegisterViewController.swift b/Atcha-iOS/Presentation/Onboarding/HomeRegister/HomeRegisterViewController.swift index 22114e34..d43c7fac 100644 --- a/Atcha-iOS/Presentation/Onboarding/HomeRegister/HomeRegisterViewController.swift +++ b/Atcha-iOS/Presentation/Onboarding/HomeRegister/HomeRegisterViewController.swift @@ -12,7 +12,7 @@ import CoreLocation final class HomeRegisterViewController: BaseViewController { private lazy var navigationBar: TitleNavigationBar = AtchaNavigationBar.title("우리집 설정", shouldShowCloseButton: false, onBack: { [weak self] in guard let self else { return } - navigationController?.popViewController(animated: true) + self.navigationController?.popViewController(animated: true) }) private let titleLabel = UILabel() @@ -23,7 +23,7 @@ final class HomeRegisterViewController: BaseViewController, ) if ok { self.viewModel.handleRegister() - if viewModel.context == .myPage { + if viewModel.context == .myPage || viewModel.context == .home { self.navigationController?.popToViewController(ofType: HomeRegisterViewController.self) } } else { diff --git a/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift b/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift index 065ed7fc..eb5fdda9 100644 --- a/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift +++ b/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift @@ -79,7 +79,7 @@ final class HomeFindViewModel: BaseViewModel { case .onboarding: saveCurrentLoaction() signUp() - case .myPage: + case .myPage, .home: guard let currentLocation, let address else { print("집주소 변경 불가: 값 없음")