From ffedff73193482aaaeaba8f86aaa7f2491f1db5b Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Sat, 14 Mar 2026 04:54:07 +0900 Subject: [PATCH 01/13] =?UTF-8?q?[BUGFIX]=20=EC=83=88=EB=B2=BD=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8C=80=20=EA=B2=80=EC=83=89=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Course/CourseSearch/CourseSearchViewModel.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewModel.swift b/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewModel.swift index 04176176..27060bf7 100644 --- a/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewModel.swift +++ b/Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewModel.swift @@ -130,12 +130,12 @@ final class CourseSearchViewModel: BaseViewModel { // MARK: - 코스 검색 스트리밍용 func startCourseStream() { -// if isBlackoutNow() { -// setLoading(false) -// isServerError = true -// courses = [] -// return -// } + if isBlackoutNow() { + setLoading(false) + isServerError = true + courses = [] + return + } courseStreamTask?.cancel() setLoading(true) From 5c8b1106506e19dad5141460f63409d56fd4134c Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Sat, 14 Mar 2026 23:44:20 +0900 Subject: [PATCH 02/13] =?UTF-8?q?[BUGFIX]=20=EB=A9=94=EC=9D=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=A7=90=ED=92=8D=EC=84=A0=20=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AtchaBallon/AtchaBallon.swift | 39 +- .../Location/MainViewController.swift | 797 ++++++------------ 2 files changed, 279 insertions(+), 557 deletions(-) diff --git a/Atcha-iOS/DesignSource/AtchaBallon/AtchaBallon.swift b/Atcha-iOS/DesignSource/AtchaBallon/AtchaBallon.swift index b5e31a3f..9da11238 100644 --- a/Atcha-iOS/DesignSource/AtchaBallon/AtchaBallon.swift +++ b/Atcha-iOS/DesignSource/AtchaBallon/AtchaBallon.swift @@ -9,8 +9,8 @@ import UIKit import SnapKit final class AtchaBallon: UIView { - private let topLabel: AtcahaInsetLabel = AtcahaInsetLabel() - private let bottomLabel: AtcahaInsetLabel = AtcahaInsetLabel() + var topLabel: AtcahaInsetLabel = AtcahaInsetLabel() + var bottomLabel: AtcahaInsetLabel = AtcahaInsetLabel() private let triangeImageView: UIImageView = UIImageView() private lazy var containerStackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: [topLabel, bottomLabel]) @@ -184,3 +184,38 @@ final class AtchaBallon: UIView { } } +extension AtchaBallon { + // 내부 뷰를 초기화 (모두 투명하게) + func resetAndHideAll() { + self.layer.removeAllAnimations() + self.topLabel.alpha = 0 + self.bottomLabel.alpha = 0 + self.triangeImageView.alpha = 0 + self.isHidden = false + } + + // Top 라벨 서서히 표시/숨김 + func setTopVisible(_ isVisible: Bool, duration: TimeInterval = 0.3) { + self.topLabel.isHidden = false + UIView.animate(withDuration: duration) { + self.topLabel.alpha = isVisible ? 1 : 0 + } + } + + // Bottom 라벨(과 삼각형) 서서히 표시/숨김 + func setBottomVisible(_ isVisible: Bool, duration: TimeInterval = 0.3) { + self.bottomLabel.isHidden = false + UIView.animate(withDuration: duration) { + self.bottomLabel.alpha = isVisible ? 1 : 0 + self.triangeImageView.alpha = isVisible ? 1 : 0 + } + } + + // 택시비 AttributedString 생성 헬퍼 + static func makeFareAttributedString(fareStr: String) -> NSAttributedString { + let gray = NSMutableAttributedString(string: "여기서 막차 놓치면 택시비 ", attributes: [.foregroundColor: UIColor.gray100]) + let white = NSMutableAttributedString(string: "약 \(fareStr)", attributes: [.foregroundColor: UIColor.white]) + gray.append(white) + return gray + } +} diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index e0dc262f..a72c345a 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -37,52 +37,18 @@ final class MainViewController: BaseViewController, private var firstAddress: String? - // MARK: - 말풍선 기본 설정 - private var pinnedPreBalloon: BalloonContent? - private let balloonInitialDelayFirst: TimeInterval = 0.7 // 첫 노출 700ms - private let balloonInitialDelaySecond: TimeInterval = 1.5 // (2개일 때) 두 번째 1500ms - private let balloonHold: TimeInterval = 2.5 // 유지 2500ms - private let balloonFade: TimeInterval = 0.25 // 페이드 250ms - - // MARK: - 말풍선 타입 - private enum BalloonContent: Equatable { - case text(top: String?, bottom: String) - case separation(gray: String, white: String) // (택시비: 회색+흰색 분리용) - } - - private enum BalloonScope { - case pre // 알람 등록 전 - case next // 알람 등록 후 - } - - // MARK: - 말풍선 큐 & 상태 - private var balloonQueue: [(content: BalloonContent, delay: TimeInterval, scope: BalloonScope)] = [] - private var isBalloonShowing = false - private var hasShownInitialBalloon = false - private var lastShownBalloon: BalloonContent? - private var lastShownScope: BalloonScope? - // MARK: - 최신 값 캐시(비동기 병합용) private var latestIsServiceRegion: Bool? private var latestFareString: String? - // MARK: - 알람 등록 후 메시지(순환) - private var postAlarmMessages: [BalloonContent] = [] - private var postAlarmIndex = 0 + // MARK: - 알람 등록 후 메시지 순환 인덱스 + private var postAlarmTapIndex = 0 // MARK: - 방문 플래그 & 표시 규칙 - private var isRevisit: Bool { - UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.reVisit.rawValue) ?? false - } - private var showTopLineForPre: Bool { !isRevisit } // 신규 = true, 재방문 = false - private var preSessionShowTopLine: Bool? - private var deferPreBalloonOnce = false private var wasAlarmRegisteredOnLaunch = false // MARK: - 화면 하단 타입 / 설정 상태 private var lastAppliedBottomType: MapBottomType? - private var setupGen = 0 - private var firstBalloonWork: DispatchWorkItem? // MARK: - 네트워크/조회 상태 private var lastFareRefreshTime: CFTimeInterval = 0 @@ -101,20 +67,32 @@ 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 - // } - var shouldShowWelcomeToast: Bool = false private var hasShownAlarmRegisteredToast = false + private lazy var shouldShowTopLineInSearch: Bool = { + let isRevisit = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.reVisit.rawValue) ?? false + if !isRevisit { + // 처음 방문 시 기기에는 '방문함'으로 저장해두되, + // 현재 앱이 켜져있는 이 세션 동안은 계속 true(첫 방문 취급)를 반환하도록 함 + UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.reVisit.rawValue) + return true + } + return false + }() + + private var isFirstVisit: Bool = false + private var isShowingToast = false // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() + if !UserDefaults.standard.bool(forKey: "IsAppFirstLaunchedEver") { + self.isFirstVisit = true + UserDefaults.standard.set(true, forKey: "IsAppFirstLaunchedEver") + } + wasAlarmRegisteredOnLaunch = UserDefaultsWrapper.shared.bool( forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue ) ?? false @@ -127,7 +105,6 @@ final class MainViewController: BaseViewController, setupUI() setupAutoLayout() - // installMapUserGestureDetector() mapContainerView.onUserInteraction = { [weak self] in self?.stopFollowingOnUserInteraction() } @@ -151,9 +128,9 @@ final class MainViewController: BaseViewController, if let currentCoord = self.viewModel.selectedLocation ?? self.viewModel.currentLocation { self.mapContainerView.setupCenter(location: currentCoord) - self.shouldCenterToCurrentLocationOnce = false // 이미 이동했으니 대기 안 함 + self.shouldCenterToCurrentLocationOnce = false } else { - self.shouldCenterToCurrentLocationOnce = true // 값이 없다면 위치를 찾을 때까지 대기 + self.shouldCenterToCurrentLocationOnce = true } } else if isAlarmRegistered && !isAlarmFired { @@ -165,19 +142,16 @@ final class MainViewController: BaseViewController, self.mapContainerView.setupZoomCenter(location: startCoord) } - }else if isAlarmRegistered && isAlarmFired { + } 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 } } @@ -188,16 +162,25 @@ final class MainViewController: BaseViewController, super.viewDidAppear(animated) if shouldShowWelcomeToast { - shouldShowWelcomeToast = false // 한 번 띄우고 바로 꺼줌 - - // 첫 번째 토스트: 집 주소 등록 완료 - AtchaToast(message: "집 주소가 등록되었어요").show(in: self.view) + shouldShowWelcomeToast = false - // 두 번째 토스트: 위치 권한 체크 - let status = CLLocationManager.authorizationStatus() - if status != .authorizedAlways && status != .authorizedWhenInUse { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in - self?.ensureLocationPermissionOrShowToast() + // 토스트를 띄우고, 사라진 다음에 말풍선을 띄우도록 통일 + showToastAndThen(message: "집 주소가 등록되었어요", delay: 2.5) { [weak self] in + guard let self = self else { return } + let status = CLLocationManager.authorizationStatus() + if status != .authorizedAlways && status != .authorizedWhenInUse { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self.ensureLocationPermissionOrShowToast() + } + } + + // 토스트 끝나면 고정 말풍선 호출 + if self.viewModel.bottomType == .search || self.viewModel.bottomType == nil { + self.showOrUpdatePersistentBalloon( + isFirstVisit: self.shouldShowTopLineInSearch, + isServiceRegion: self.latestIsServiceRegion ?? false, + fareStr: self.latestFareString + ) } } } @@ -213,7 +196,6 @@ final class MainViewController: BaseViewController, } else { amp_track(.main_view, properties: props(AmplitudeProperty.userStatus(.member))) } - } override func viewWillDisappear(_ animated: Bool) { @@ -337,7 +319,7 @@ extension MainViewController { .receive(on: RunLoop.main) .sink { [weak self] _ in self?.presentLocationDeniedAlert() - self?.viewModel.showLocationDeniedAlert = false // 띄운 뒤 신호 초기화 + self?.viewModel.showLocationDeniedAlert = false } .store(in: &cancellables) } @@ -367,9 +349,8 @@ extension MainViewController { } private func bindAlarmFireStatus() { - // UserDefaults의 변화를 실시간으로 구독합니다. UserDefaults.standard.publisher(for: \.departureAlarmDidFire) - .removeDuplicates() // 같은 값이 연속으로 들어오는 것 방지 + .removeDuplicates() .receive(on: RunLoop.main) .sink { [weak self] isFired in guard let self = self else { return } @@ -377,16 +358,10 @@ extension MainViewController { self.lastTrainDepartView.updateUIForAlarmStatus(isFired: isFired) if isFired { - // 1. 추적 플래그 ON self.isFollowingUser = true - - // 2. 헤딩(회전) 시작 self.viewModel.startHeading() - - // 3. 유저 마커 스타일 변경 (알람 후 전용 마커가 있다면) self.mapContainerView.afterUserMarker() - // 4. 즉시 현재 위치로 지도 중심 이동 if let currentCoord = self.viewModel.currentLocation { self.mapContainerView.setupCenter(location: currentCoord) } @@ -459,19 +434,14 @@ extension MainViewController { ) case .locationTapped: - self.showOrUpdateImmediateBalloon( - .text(top: nil, bottom: "위치를 변경하려면 알람을 종료해야 해요") - ) - + showTransientBalloon(isFare: false, text: "위치를 변경하려면 알람을 종료해야 해요") amp_track(.course_click) case .reloadTapped: viewModel.refreshDepatrueTime() - case .timeTapped: - self.showOrUpdateImmediateBalloon( - .text(top: "이때 자리에서 출발하면 돼요", bottom: "교통 상황에 따라 시간이 달라질 수 있어요") - ) + case .timeTapped: + showSequentialBalloons() amp_track(.departure_time_click) } } @@ -513,48 +483,48 @@ extension MainViewController { } private func exitButtonTapped() { + // 가장 먼저 토스트 표시 상태로 변경 (이후 2.5초간 호출되는 모든 말풍선 로직 차단됨) + isShowingToast = true + AlarmManager.shared.stopAlarm() viewModel.requestPermissionAndStartTracking() viewModel.removeLegInfoAndAddress() viewModel.stopHeading() - // 4. 알람 해제 시 1번(초기 상태)으로 돌아감 isFollowingUser = false shouldCenterToCurrentLocationOnce = true hasShownAlarmRegisteredToast = false - // 이번 한 번은 프리 말풍선 자동 표시를 건너뛰도록 플래그 세팅 - deferPreBalloonOnce = true viewModel.bottomType = .search routeStartCoordinate = nil - cancelBalloonQueueAndHide() + + ballonView.layer.removeAllAnimations() + ballonView.isHidden = true + ballonView.alpha = 0 + atchaImageView.stop() mapContainerView.clearMapView() mapContainerView.beforeUserMarker() if let currentCoord = viewModel.currentLocation { - // 현재 좌표가 있다면 즉시 이동 mapContainerView.setupCenter(location: currentCoord) - viewModel.selectedLocation = currentCoord // 주소 검색 결과도 현위치로 갱신 + viewModel.selectedLocation = currentCoord shouldCenterToCurrentLocationOnce = false } else { - // 아직 좌표가 없다면, 다음 위치 업데이트 시점에 이동하도록 예약 shouldCenterToCurrentLocationOnce = true viewModel.setupLocation() } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - guard let self else { return } - - self.view.showToast(message: "알람이 종료되었어요") - - // 2초 뒤 수동으로 말풍선 표시 (이때 플래그 해제) - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.deferPreBalloonOnce = false - self.showInitialPreAlarmBalloons(force: true) - } - - UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) + UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) + + // 토스트 띄우고 토스트 사라진 후 고정형 말풍선 띄우기 (재방문 상태) + showToastAndThen(message: "알람이 종료되었어요", delay: 2.5) { [weak self] in + guard let self = self else { return } + self.showOrUpdatePersistentBalloon( + isFirstVisit: false, + isServiceRegion: self.latestIsServiceRegion ?? false, + fareStr: self.latestFareString + ) } } @@ -568,28 +538,12 @@ extension MainViewController { .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 } @@ -602,9 +556,7 @@ extension MainViewController { .removeDuplicates() .compactMap { $0 } .receive(on: RunLoop.main) - .sink { _ in - // 뷰모델에서 알아서 주소를 검색하므로 뷰컨트롤러는 카메라를 건드리지 않음! - } + .sink { _ in } .store(in: &cancellables) } @@ -624,26 +576,12 @@ extension MainViewController { private func bindAddressUpdates() { viewModel.$address - .compactMap { $0 } // Optional -> String - .removeDuplicates() // String 기준 중복 제거 + .compactMap { $0 } + .removeDuplicates() .receive(on: RunLoop.main) - .sink { [weak self] (addr: String) in // 타입 명시로 추론 고정 + .sink { [weak self] (addr: String) in guard let self = self else { return } self.updateAddress(addr) - - if self.hasShownInitialBalloon { - if self.latestIsServiceRegion == false { - self.latestFareString = nil - self.showOrUpdatePreBalloon( - .text( - top: (self.preSessionShowTopLine ?? true) ? "지도를 움직여 출발지를 설정해요" : nil, - bottom: "서울, 경기, 인천 내에서만 사용할 수 있어요" - ) - ) - } - } else { - self.showInitialPreAlarmBalloons(force: false) - } } .store(in: &cancellables) } @@ -702,71 +640,62 @@ extension MainViewController { .store(in: &cancellables) } - private func commonAlarmSetupView() { updateAtchaImageConstraint(relativeTo: lastTrainDepartView) } private func setupBottomType(_ type: MapBottomType?) { guard let type = type else { return } - let isSame = (lastAppliedBottomType == type) - // 공통 초기 숨김 flagImageView.isHidden = true lastTrainSearchView.isHidden = true lastTrainDepartView.isHidden = true switch type { case .departure: - if !isSame { cancelBalloonQueueAndHide() } + self.shouldShowTopLineInSearch = false lastTrainDepartView.isHidden = false viewModel.startAlarmTimer() mapContainerView.afterUserMarker() - setupGen &+= 1 - let gen = setupGen - cancelBalloonQueueAndHide() + ballonView.layer.removeAllAnimations() + ballonView.isHidden = true + ballonView.alpha = 0 atchaImageView.stop() - self.setupPostAlarmMessages() - - // 두 번 연속 호출일 때만 0.7초, 아니면 0.2초 (기존 로직 유지) - let delayBeforeScheduling: TimeInterval = isSame ? 0.7 : 0.2 - - let popRegister = UserDefaultsWrapper.shared.bool( - forKey: UserDefaultsWrapper.Key.popRegister.rawValue - ) ?? false - let popToastDelay: Double = popRegister ? 0.3 : 0.0 - let popBallonDelay: TimeInterval = popRegister ? 2.7 : 2.4 - - // 앱을 켤 때부터 알람이 이미 등록되어 있었다면, post-delay(기존 2.0초)를 0으로 - let postRevealDelay: TimeInterval = wasAlarmRegisteredOnLaunch ? 0.0 : popBallonDelay - - self.scheduleFirstBalloon(gen: gen, - delay: delayBeforeScheduling, - postRevealDelay: postRevealDelay, - popToastDelay: popToastDelay) - - preSessionShowTopLine = nil + // 알람 등록 직후 토스트 + 순차 말풍선 + if !wasAlarmRegisteredOnLaunch && !hasShownAlarmRegisteredToast { + hasShownAlarmRegisteredToast = true + showToastAndThen(message: "알람이 등록되었습니다.", delay: 2.5) { [weak self] in + self?.showSequentialBalloons() + } + } else if wasAlarmRegisteredOnLaunch { + showSequentialBalloons() + } case .search: - if !isSame { cancelBalloonQueueAndHide() } viewModel.stopFinishAlarmTimer() lastTrainSearchView.isHidden = false flagImageView.isHidden = false - cancelBalloonQueueAndHide() + + ballonView.layer.removeAllAnimations() + ballonView.isHidden = true + ballonView.alpha = 0 atchaImageView.stop() mapContainerView.clearMapView() mapContainerView.beforeUserMarker() updateAtchaImageConstraint(relativeTo: lastTrainSearchView) - hasShownInitialBalloon = false - preSessionShowTopLine = nil + let isService = self.latestIsServiceRegion ?? false - // exit 흐름에서 지연 표시 예정이면 여기서는 자동 호출 안 함 - if !deferPreBalloonOnce { - showInitialPreAlarmBalloons(force: true) + // 여기도 동일하게 로딩중 무시 조건 적용 + if !isService || self.viewModel.isGuest || self.latestFareString != nil { + showOrUpdatePersistentBalloon( + isFirstVisit: self.isFirstVisit, + isServiceRegion: isService, + fareStr: self.latestFareString + ) } case .detail: @@ -778,70 +707,32 @@ extension MainViewController { lastAppliedBottomType = type } - private func scheduleFirstBalloon(gen: Int, - delay: TimeInterval, - postRevealDelay: TimeInterval, - popToastDelay: TimeInterval) { - firstBalloonWork?.cancel() - - let work = DispatchWorkItem { [weak self] in - guard let self else { return } - guard gen == self.setupGen, self.viewModel.bottomType == .departure else { return } - guard let first = self.postAlarmMessages.first else { return } - - if !wasAlarmRegisteredOnLaunch && !hasShownAlarmRegisteredToast { - DispatchQueue.main.asyncAfter(deadline: .now() + popToastDelay) { - self.view.showToast(message: "알람이 등록되었습니다.") - self.hasShownAlarmRegisteredToast = true // 띄웠다고 표시! - UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.popRegister.rawValue) - } - } - - DispatchQueue.main.asyncAfter(deadline: .now() + postRevealDelay) { - self.showOrUpdateImmediateBalloon(first) - } - } - firstBalloonWork = work - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) - } - private func bindTaxiFareUpdates() { - // taxiFare와 isGuest 중 하나라도 바뀌면 이 블록이 실행됩니다. Publishers.CombineLatest(viewModel.$taxiFare, viewModel.$isGuest) .receive(on: RunLoop.main) .sink { [weak self] fare, isGuest in guard let self = self else { return } - guard let fare = fare else { + if let fare = fare { + let fareInt = Int(fare) + self.latestFareString = self.decimalFormatter.string(from: NSNumber(value: fareInt)) ?? "\(fareInt)" + } else { self.latestFareString = nil - self.viewModel.taxiFare = nil - return } - let fareInt = Int(fare) - let fareStr = self.decimalFormatter.string(from: NSNumber(value: fareInt)) ?? "\(fareInt)" - self.latestFareString = fareStr - - if self.isPreAlarmBalloonActive(), self.latestIsServiceRegion == true { - // 이제 파라미터로 들어오는 최신 isGuest 상태에 따라 ??? 혹은 금액이 결정됩니다. - let displayFare = isGuest ? "???원" : "\(fareStr)원" - let content: BalloonContent = .separation( - gray: "여기서 막차 놓치면 택시비 ", white: "약 \(displayFare)" - ) + // 검색 모드일 때는 즉시 말풍선 글자 업데이트 + if self.viewModel.bottomType == .search { + let isService = self.latestIsServiceRegion ?? false - if self.ballonView.isHidden { - self.showOrUpdatePreBalloon(content, showTopLine: self.preSessionShowTopLine ?? true) - } else { - self.updatePreBalloonContent(content, showTopLine: self.preSessionShowTopLine ?? true) + // 핵심: 회원이면서 서비스 지역인데 아직 택시비가 없으면(로딩중) 업데이트 생략! + if !isService || isGuest || self.latestFareString != nil { + self.showOrUpdatePersistentBalloon( + isFirstVisit: self.isFirstVisit, + isServiceRegion: isService, + fareStr: self.latestFareString + ) } } - - // 알람 등록 후 말풍선 큐 갱신 - if !self.postAlarmMessages.isEmpty { - let displayFare = isGuest ? "???원" : "\(fareStr)원" - self.postAlarmMessages[self.postAlarmMessages.count - 1] = - .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(displayFare)") - } } .store(in: &cancellables) } @@ -852,59 +743,32 @@ extension MainViewController { .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) - - 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) - } - } - } - case .some(false): - // 서비스 지역을 벗어남 (울산 등) self.latestFareString = nil self.viewModel.taxiFare = nil 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) } + + // 검색 모드일 때는 즉시 말풍선 글자 업데이트 + if ok != nil && self.viewModel.bottomType == .search { + let isService = ok ?? false + + // 핵심: 회원이면서 서비스 지역인데 아직 택시비가 없으면(로딩중) 업데이트 생략! + if !isService || self.viewModel.isGuest || self.latestFareString != nil { + self.showOrUpdatePersistentBalloon( + isFirstVisit: self.isFirstVisit, + isServiceRegion: isService, + fareStr: self.latestFareString + ) + } + } } .store(in: &cancellables) } @@ -1010,8 +874,6 @@ extension MainViewController { func didFinishLoadingMap(_ mapView: TMapWrapper) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { self.hideLoading() - - // 지도가 완전히 로드된 이 시점에 setupLocation()을 호출해야 합니다! self.viewModel.setupLocation() let wrapper = UserDefaultsWrapper.shared @@ -1033,7 +895,6 @@ extension MainViewController { @objc private func didTapMyPageButton() { if viewModel.isGuest { presentLoginAlert() - amp_track(.login_view, properties: props(AmplitudeProperty.entryPoint(.mypage))) } else { viewModel.handleRoute(route: .myPage) @@ -1047,10 +908,9 @@ extension MainViewController { isFollowingUser = true viewModel.startHeading() - // 수정: 무조건 내 "진짜 위치(currentLocation)"로 지도를 이동시킴 if let coord = viewModel.currentLocation { mapContainerView.setupCenter(location: coord) - viewModel.selectedLocation = coord // 주소도 현위치로 다시 검색하게 덮어씀 + viewModel.selectedLocation = coord } else { shouldCenterToCurrentLocationOnce = true viewModel.setupLocation() @@ -1068,312 +928,63 @@ extension MainViewController { } @objc private func handleBallonTap() { - atchaImageView.stop() - atchaImageView.start() + // 알람 등록 후(departure 상태)일 때만 반응 + guard viewModel.bottomType == .departure else { return } + + amp_track(.character_click) - let scope = lastShownScope + let cycle = postAlarmTapIndex % 3 - switch scope { - case .pre: - print("") - case .next: - amp_track(.character_click) + if cycle == 0 { + // 요금 정보 표시 (동적 로딩) let now = CACurrentMediaTime() - let shouldRefreshFare = (now - lastFareRefreshTime) > fareRefreshInterval - - if shouldRefreshFare && !isFetchingFare { + if (now - lastFareRefreshTime) > fareRefreshInterval && !isFetchingFare { isFetchingFare = true - let vm = viewModel - Task(priority: .userInitiated) { + Task { defer { Task { @MainActor in self.isFetchingFare = false } } do { - let fare = try await vm.fetchFareForRegisteredStart() + let fare = try await viewModel.fetchFareForRegisteredStart() let fareInt = Int(fare) let fareStr = self.decimalFormatter.string(from: NSNumber(value: fareInt)) ?? "\(fareInt)" await MainActor.run { self.latestFareString = fareStr - self.setupPostAlarmMessages() - // 이번 탭에선 요금 먼저 한 번 보여주고 - self.showOrUpdateImmediateBalloon( - .separation(gray: "막차 놓치면 택시비 ", white: "약 \(fareStr)원") - ) - // 다음 탭부터는 순환 문구가 나오도록 시작 인덱스 조정 - self.postAlarmIndex = 1 self.lastFareRefreshTime = CACurrentMediaTime() + let displayFare = self.viewModel.isGuest ? "???원" : "\(fareStr)원" + self.showTransientBalloon(isFare: true, text: displayFare) + self.postAlarmTapIndex += 1 } } catch { await MainActor.run { - self.showOrUpdateImmediateBalloon(.text(top: nil, bottom: "택시비 조회에 실패했어요")) - // 실패 시에도 다음 탭은 순환 시작 - self.postAlarmIndex = max(1, self.postAlarmIndex) + self.showTransientBalloon(isFare: false, text: "택시비 조회에 실패했어요") + self.postAlarmTapIndex += 1 } } } - return // 이번 탭은 요금만 보여주고 종료 - } - - // ===== 재조회 주기가 아닐 땐 순환 메시지 ===== - guard !postAlarmMessages.isEmpty else { return } - if postAlarmIndex < 1 { postAlarmIndex = 1 } // 1..N-1 범위에서 순환 - let content = postAlarmMessages[postAlarmIndex] - showOrUpdateImmediateBalloon(content) - - let cycleCount = postAlarmMessages.count - 1 - postAlarmIndex = 1 + ((postAlarmIndex - 1 + 1) % cycleCount) - - case .none: - break - } - } - - // MARK: - Balloon (helpers + queue) - - private func applyBalloon(_ content: BalloonContent, showTopLine: Bool) { - switch content { - case .text(let top, let bottom): - if let top = top { - ballonView.setupTitle(topMessage: top, bottomMessage: bottom) } else { - ballonView.setupTitle(bottomMessage: bottom) - } - case .separation(let gray, let white): - ballonView.separationTitle( - grayMessage: gray, - whiteMessage: white, - showTopLine: showTopLine - ) - } - } - - private func revealBalloon(animated: Bool) { - ballonView.layer.removeAllAnimations() - view.bringSubviewToFront(ballonView) - - if ballonView.isHidden { - ballonView.alpha = 0 - ballonView.isHidden = false - UIView.animate(withDuration: animated ? balloonFade : 0) { - self.ballonView.alpha = 1 + let fareStr = latestFareString ?? "???" + let displayFare = viewModel.isGuest ? "???원" : "\(fareStr)원" + showTransientBalloon(isFare: true, text: displayFare) + postAlarmTapIndex += 1 } - } else { - ballonView.revealImmediately() - } - ballonView.animateStaggered(secondaryDelay: 0.8, fade: balloonFade) - } - - private func autoHideBalloon(after delay: TimeInterval, completion: (() -> Void)? = nil) { - guard !isPreAlarmBalloonActive() else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self else { return } - self.ballonView.animateHideStaggered( - secondaryDelay: 0.6, // 필요하면 조절 (등장과 비슷하게 0.6~0.8 추천) - fade: self.balloonFade, // 기존 0.25 유지 - completion: { [weak self] in - guard let self else { return } - // 기존 상태 정리 로직 유지 - self.ballonView.isHidden = true - completion?() - } - ) - } - } - - // 내부 enqueue: 큐잉 + 드레인 - private func enqueueInternal(_ content: BalloonContent, - delay: TimeInterval = 0, - hold: TimeInterval? = nil, - scope: BalloonScope) { - if scope == .pre && !isPreAlarmBalloonActive() { return } - balloonQueue.append((content, delay, scope)) - drainBalloonQueue(hold: hold ?? balloonHold) - } - - // 등록 후 안내(큐잉) - private func enqueueNextBalloon(_ content: BalloonContent, - delay: TimeInterval = 0, - hold: TimeInterval? = nil) { - enqueueInternal(content, delay: delay, hold: hold, scope: .next) - } - - // 큐 드레인 - private func drainBalloonQueue(hold: TimeInterval) { - guard !isBalloonShowing, let next = balloonQueue.first else { return } - isBalloonShowing = true - balloonQueue.removeFirst() - - DispatchQueue.main.asyncAfter(deadline: .now() + next.delay) { [weak self] in - guard let self else { return } - self.applyBalloon(next.content, showTopLine: false) - self.revealBalloon(animated: true) - - self.lastShownBalloon = next.content - self.lastShownScope = next.scope - - self.autoHideBalloon(after: hold) { - self.isBalloonShowing = false - self.drainBalloonQueue(hold: hold) - } - } - } - - // 알람 등록 전: 즉시 고정(큐/오토숨김 없음) - private func showOrUpdatePreBalloon(_ content: BalloonContent, - delay: TimeInterval = 0, - animated: Bool = true, - showTopLine: Bool = true) { - guard isPreAlarmBalloonActive() else { return } - - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self else { return } - - // 같은 내용이면 스킵 - if self.pinnedPreBalloon == content, self.lastShownScope == .pre { return } - - // 이미 떠 있으면 내용만 교체하고 종료 (새로 생성/페이드인/스태거 X) - if !self.ballonView.isHidden { - self.updatePreBalloonContent(content, showTopLine: showTopLine) - return - } - - // 처음 띄울 때만 페이드인 + 스태거 - self.applyBalloon(content, showTopLine: showTopLine) - self.revealBalloon(animated: animated) - - self.lastShownBalloon = content - self.lastShownScope = .pre - self.pinnedPreBalloon = content - } - } - - // 초기 프리 말풍선 - 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 - } - - // 요금이 있고 서비스 지역일 때 - let displayFare = viewModel.isGuest ? "???원" : "\(fare)원" - showOrUpdatePreBalloon( - .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(displayFare)"), - delay: d1, animated: true, showTopLine: showTopLine - ) + } else if cycle == 1 { + showTransientBalloon(isFare: false, text: "시간에 맞춰 알림을 드릴게요") + postAlarmTapIndex += 1 } else { - // 비서비스 지역은 기존 안내 문구 유지 - showOrUpdatePreBalloon( - .text(top: showTopLine ? "지도를 움직여 출발지를 설정해요" : nil, - bottom: "서울, 경기, 인천 내에서만 사용할 수 있어요"), - delay: d1 - ) - } - - // 여기까지 도달했을 때도 초기 방문이면 reVisit 저장 - if !isRevisit { - UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.reVisit.rawValue) + showTransientBalloon(isFare: false, text: "교통 상황에 따라 시간이 달라질 수 있어요") + postAlarmTapIndex += 1 } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in - self?.atchaImageView.stop() - self?.atchaImageView.start() - } - hasShownInitialBalloon = true - } - - // 즉시 표시(터치 등): 3초 뒤 오토숨김 - private func showOrUpdateImmediateBalloon(_ content: BalloonContent) { - balloonQueue.removeAll() - ballonView.layer.removeAllAnimations() - self.safeStartJump() - NSObject.cancelPreviousPerformRequests(withTarget: self, - selector: #selector(hideImmediateBalloon), - object: nil) - - applyBalloon(content, showTopLine: false) - revealBalloon(animated: true) - - perform(#selector(hideImmediateBalloon), with: nil, afterDelay: 3.0) - - lastShownBalloon = content - lastShownScope = .next - pinnedPreBalloon = content - - wasAlarmRegisteredOnLaunch = false - } - - @objc private func hideImmediateBalloon() { - autoHideBalloon(after: 0) - } - - // 프리/포스트 여부 - private func isPreAlarmBalloonActive() -> Bool { - return viewModel.bottomType == .search - } - - private func updatePreBalloonContent(_ content: BalloonContent, showTopLine: Bool) { - applyBalloon(content, showTopLine: showTopLine) - ballonView.revealImmediately() // 라벨만 보이게 - lastShownBalloon = content - lastShownScope = .pre - pinnedPreBalloon = content - } - - private func cancelBalloonQueueAndHide() { - balloonQueue.removeAll() - ballonView.layer.removeAllAnimations() - ballonView.isHidden = true - ballonView.alpha = 0 - isBalloonShowing = false - pinnedPreBalloon = nil - if lastShownScope == .pre { lastShownBalloon = nil } - } - - private func setupPostAlarmMessages() { - let fareStr = latestFareString ?? "12,000" - postAlarmMessages = [ - .text(top: "이때 자리에서 출발하면 돼요", bottom: "교통 상황에 따라 시간이 달라질 수 있어요"), - .text(top: nil, bottom: "시간에 맞춰 알림을 드릴게요"), - .text(top: nil, bottom: "교통 상황에 따라 시간이 달라질 수 있어요"), - .separation(gray: "막차 놓치면 택시비 ", white: "약 \(fareStr)원") - ] - postAlarmIndex = 1 } } // MARK: - Map Delegate & Gesture extension MainViewController { func mapView(_ mapView: TMapWrapper, didUpdateLocation coordinate: CLLocationCoordinate2D) { - // 위치가 업데이트 될 때마다 호출됨 (조작 방해를 막기 위해 비워둠) viewModel.selectedLocation = coordinate } func mapView(_ mapView: TMapWrapper, didSelectLocation coordinate: CLLocationCoordinate2D) { - // 지도 단순 터치(탭) 시 추적 해제 stopFollowingOnUserInteraction() viewModel.selectedLocation = coordinate } @@ -1405,15 +1016,12 @@ extension MainViewController: UIGestureRecognizerDelegate { } @objc private func userDidManipulateMap(_ g: UIGestureRecognizer) { - // 드래그, 줌 등의 제스처 발생 시 추적 해제 if g.state == .began || g.state == .changed { stopFollowingOnUserInteraction() } } - // 조작 감지 시 공통 처리 로직 (경우의 수 1,2,3 반영) private func stopFollowingOnUserInteraction() { - // 1, 2. 평상시엔 지도를 조작하면 추적과 회전을 중지 if isFollowingUser { isFollowingUser = false shouldCenterToCurrentLocationOnce = false @@ -1435,14 +1043,11 @@ extension MainViewController { message: "위치 권한을 허용하지 않으면\n현위치의 막차를 확인할 수 없어요.", preferredStyle: .alert ) - alert.addAction(UIAlertAction(title: "닫기", style: .cancel, handler: nil)) - alert.addAction(UIAlertAction(title: "설정하기", style: .default) { _ in guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) }) - present(alert, animated: true) } } @@ -1455,16 +1060,12 @@ extension MainViewController { .sink { [weak self] _ in guard let self = self else { return } - // 1. 사용자가 다른 화면(상세 경로 등)에 있다면 무조건 메인으로 강제 이동 self.navigationController?.popToRootViewController(animated: true) - - // 2. 백그라운드에서 즉시 알람 종료 통신 및 지도/UI 초기화 실행 self.viewModel.alarmDelete() self.exitButtonTapped() amp_track(.alarm_arrive_stop) - // 3. 안내용 팝업 띄우기 (화면 이동이 끝난 0.3초 뒤에 띄워서 자연스럽게) DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self.showArrivalPopup() } @@ -1473,18 +1074,16 @@ extension MainViewController { } 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.modalTransitionStyle = .crossDissolve popupVC.confirmButton.addAction(UIAction { [weak popupVC] _ in popupVC?.dismiss(animated: false) - // 팝업을 닫을 때 다음 알람을 위해 매니저 초기화 HomeArrivalManager.shared.reset() }, for: .touchUpInside) @@ -1497,10 +1096,7 @@ extension MainViewController { .sink { [weak self] _ in guard let self = self else { return } - // 1. 무조건 메인으로 강제 이동 self.navigationController?.popToRootViewController(animated: true) - - // 2. 백그라운드 취소 로직 self.viewModel.alarmDelete() self.exitButtonTapped() @@ -1508,12 +1104,11 @@ extension MainViewController { if let coord = self.viewModel.currentLocation { self.mapContainerView.setupCenter(location: coord) - self.viewModel.selectedLocation = coord // 주소도 다시 검색 + self.viewModel.selectedLocation = coord } else { self.viewModel.setupLocation() } - // 3. 타임아웃 팝업 띄우기 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.showAlarmTimeoutPopup() } @@ -1521,3 +1116,95 @@ extension MainViewController { .store(in: &cancellables) } } + +// MARK: - 말풍선 제어 코어 로직 +extension MainViewController { + + // 토스트를 띄우고 정해진 시간 뒤에 클로저를 실행하는 헬퍼 함수 + private func showToastAndThen(message: String, delay: TimeInterval = 2.5, completion: @escaping () -> Void) { + isShowingToast = true // 켜기 + ballonView.layer.removeAllAnimations() + ballonView.isHidden = true + ballonView.alpha = 0 + + self.view.showToast(message: message) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.isShowingToast = false // 끄기 + completion() + } + } + + // 1 & 2. 알람 등록 전 (고정형) - 위치 이동시 글자만 바뀜 + private func showOrUpdatePersistentBalloon(isFirstVisit: Bool, isServiceRegion: Bool, fareStr: String?) { + guard !isShowingToast else { return } // 토스트 떠있으면 무조건 무시! + + let displayFare = viewModel.isGuest ? "???원" : "\(fareStr ?? "???")원" + let topText = "지도를 움직여 출발지를 설정해요" + + // 말풍선 내용 세팅 + if isServiceRegion { + ballonView.separationTitle(grayMessage: "여기서 막차 놓치면 택시비 ", whiteMessage: "약 \(displayFare)", showTopLine: isFirstVisit) + } else { + ballonView.setupTitle(topMessage: isFirstVisit ? topText : nil, bottomMessage: "서울, 경기, 인천 내에서만 사용할 수 있어요") + } + + // 이미 떠 있으면 텍스트만 업데이트, 아니면 새로 등장 + if ballonView.isHidden || ballonView.alpha == 0 { + safeStartJump() // 무조건 점프! + ballonView.isHidden = false + ballonView.alpha = 1 + + // 처음 뜰 때는 서서히 애니메이션 적용 (두 줄이면 0.8초 딜레이 뒤 아래쪽 등장) + let delay: TimeInterval = isFirstVisit ? 0.8 : 0.0 + ballonView.animateStaggered(secondaryDelay: delay, fade: 0.3) + } else { + // 이미 떠있는 상태면 위치 이동으로 인한 글자 업데이트이므로 즉시 바꿈 + ballonView.revealImmediately() + } + } + + // 3. 순차형 (알람 등록 직후 & timeTapped) + private func showSequentialBalloons() { + guard !isShowingToast else { return } // 토스트 떠있으면 무조건 무시! + + safeStartJump() + ballonView.layer.removeAllAnimations() + ballonView.isHidden = false + ballonView.alpha = 1 + + ballonView.setupTitle(topMessage: "이때 자리에서 출발하면 돼요", bottomMessage: "교통 상황에 따라 시간이 달라질 수 있어요") + + // 1. 위가 먼저 나타나고 '1.0초' 뒤 아래가 나타남 + ballonView.animateStaggered(secondaryDelay: 1.0, fade: 0.3) + + // 2. 첫 번째(위)가 뜬 지 '2.0초' 뒤에 사라지기 시작함 + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + // 3. 위쪽이 사라지고 '1.0초' 뒤에 아래쪽도 사라짐 + self?.ballonView.animateHideStaggered(secondaryDelay: 1.0, fade: 0.3) + } + } + + // 4. 휘발형 (캐릭터 탭, locationTapped) + private func showTransientBalloon(isFare: Bool, text: String) { + guard !isShowingToast else { return } // 토스트 떠있으면 무조건 무시! + + safeStartJump() + ballonView.layer.removeAllAnimations() + ballonView.isHidden = false + ballonView.alpha = 1 + + if isFare { + ballonView.separationTitle(grayMessage: "막차 놓치면 택시비 ", whiteMessage: "약 \(text)", showTopLine: false) + } else { + ballonView.setupTitle(topMessage: nil, bottomMessage: text) + } + + // 한 줄만 즉시/스태거로 띄움 + ballonView.animateStaggered(secondaryDelay: 0, fade: 0.25) + + // 2초 뒤 자동 숨김으로 수정 + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.ballonView.animateHideStaggered(secondaryDelay: 0, fade: 0.25) + } + } +} From 99ffa40be016703960375b026e975883383a1dc5 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Sun, 15 Mar 2026 00:17:42 +0900 Subject: [PATCH 03/13] =?UTF-8?q?[BUGFIX]=20=EB=A7=90=ED=92=8D=EC=84=A0=20?= =?UTF-8?q?=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Location/MainViewController.swift | 2 +- .../Presentation/Location/MainViewModel.swift | 15 ++++++++++++++- Atcha-iOS/Presentation/Main/MainCoordinator.swift | 3 +++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index a72c345a..5b5375df 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -968,7 +968,7 @@ extension MainViewController { } } else if cycle == 1 { - showTransientBalloon(isFare: false, text: "시간에 맞춰 알림을 드릴게요") + showTransientBalloon(isFare: false, text: "시간에 맞춰 알람을 드릴게요") postAlarmTapIndex += 1 } else { diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index 6dea51d0..7beb6603 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -230,11 +230,14 @@ final class MainViewModel: BaseViewModel{ } self.startHeading() + streamTask?.cancel() streamTask = Task { for await location in streamUseCase.startUpdate() { let now = Date() // 앱 최초 실행 시 빠른 위치 탐색을 위해 nil이면 999.0(강제 탈출 모드) 세팅 + let isInitialTracking = !self.didSendInitialLocation + let timeGap = self.lastValidTime != nil ? now.timeIntervalSince(self.lastValidTime!) : 999.0 let isRecovering = timeGap > 60.0 @@ -248,8 +251,9 @@ final class MainViewModel: BaseViewModel{ if isRecovering { self.consecutiveValidCount += 1 + let requiredCount = isInitialTracking ? 1 : 3 - if self.consecutiveValidCount >= 3 { + if self.consecutiveValidCount >= requiredCount { smoother.reset(location.coordinate) self.lastValidTime = now @@ -410,6 +414,15 @@ final class MainViewModel: BaseViewModel{ if let alarmObserver { NotificationCenter.default.removeObserver(alarmObserver) } if let refreshUpdateToken { NotificationCenter.default.removeObserver(refreshUpdateToken) } } + + func resetLocationState() { + self.lastValidTime = nil + self.didSendInitialLocation = false + self.consecutiveValidCount = 0 + self.currentLocation = nil + self.selectedLocation = nil + self.streamTask?.cancel() + } } // MARK: - Alarm diff --git a/Atcha-iOS/Presentation/Main/MainCoordinator.swift b/Atcha-iOS/Presentation/Main/MainCoordinator.swift index 0ae9a1e8..c8c0009a 100644 --- a/Atcha-iOS/Presentation/Main/MainCoordinator.swift +++ b/Atcha-iOS/Presentation/Main/MainCoordinator.swift @@ -76,6 +76,8 @@ final class MainCoordinator: NSObject { self.mainViewModel?.isGuest = true self.mainViewModel?.bottomType = .search + self.mainViewModel?.resetLocationState() + self.navigationController.popToRootViewController(animated: true) self.myPageCoordinator = nil @@ -83,6 +85,7 @@ final class MainCoordinator: NSObject { } myPageCoordinator.withdrawFinish = { [weak self] in DispatchQueue.main.async { + self?.mainViewModel?.resetLocationState() self?.withdrawFinish?() self?.myPageCoordinator = nil From f741a5e6f5173df8c5d8ce951199c4c110b6626e Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Sun, 15 Mar 2026 00:45:42 +0900 Subject: [PATCH 04/13] =?UTF-8?q?[BUGFIX]=20=EC=B2=AB=20=EB=B0=A9=EB=AC=B8?= =?UTF-8?q?=20=EB=A7=90=ED=92=8D=EC=84=A0=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Location/MainViewController.swift | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 5b5375df..c9aa5040 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -89,9 +89,9 @@ final class MainViewController: BaseViewController, super.viewDidLoad() if !UserDefaults.standard.bool(forKey: "IsAppFirstLaunchedEver") { - self.isFirstVisit = true - UserDefaults.standard.set(true, forKey: "IsAppFirstLaunchedEver") - } + self.isFirstVisit = true + UserDefaults.standard.set(true, forKey: "IsAppFirstLaunchedEver") + } wasAlarmRegisteredOnLaunch = UserDefaultsWrapper.shared.bool( forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue @@ -164,25 +164,25 @@ final class MainViewController: BaseViewController, if shouldShowWelcomeToast { shouldShowWelcomeToast = false - // 토스트를 띄우고, 사라진 다음에 말풍선을 띄우도록 통일 - showToastAndThen(message: "집 주소가 등록되었어요", delay: 2.5) { [weak self] in - guard let self = self else { return } - let status = CLLocationManager.authorizationStatus() - if status != .authorizedAlways && status != .authorizedWhenInUse { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - self.ensureLocationPermissionOrShowToast() - } - } - - // 토스트 끝나면 고정 말풍선 호출 - if self.viewModel.bottomType == .search || self.viewModel.bottomType == nil { - self.showOrUpdatePersistentBalloon( - isFirstVisit: self.shouldShowTopLineInSearch, - isServiceRegion: self.latestIsServiceRegion ?? false, - fareStr: self.latestFareString - ) + + self.view.showToast(message: "집 주소가 등록되었어요") + + + let status = CLLocationManager.authorizationStatus() + if status != .authorizedAlways && status != .authorizedWhenInUse { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.ensureLocationPermissionOrShowToast() } } + + // 즉시 말풍선 업데이트 (1줄짜리로 자연스럽게 나타남) + if self.viewModel.bottomType == .search || self.viewModel.bottomType == nil { + self.showOrUpdatePersistentBalloon( + isFirstVisit: self.isFirstVisit, + isServiceRegion: self.latestIsServiceRegion ?? false, + fareStr: self.latestFareString + ) + } } self.viewModel.refreshCurrentMapCenterData() From c575bf6d999fd6d6954980ea5306328495191065 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Sun, 15 Mar 2026 00:52:50 +0900 Subject: [PATCH 05/13] =?UTF-8?q?[BUGFIX]=20=EB=A7=90=ED=92=8D=EC=84=A0=20?= =?UTF-8?q?=EC=82=AC=EB=9D=BC=EC=A7=90=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=A4=91=EC=B2=A9=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Location/MainViewController.swift | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index c9aa5040..c42f0df2 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -82,6 +82,7 @@ final class MainViewController: BaseViewController, private var isFirstVisit: Bool = false private var isShowingToast = false + private var balloonHideWorkItem: DispatchWorkItem? // MARK: - Life Cycle @@ -1167,6 +1168,8 @@ extension MainViewController { private func showSequentialBalloons() { guard !isShowingToast else { return } // 토스트 떠있으면 무조건 무시! + balloonHideWorkItem?.cancel() + safeStartJump() ballonView.layer.removeAllAnimations() ballonView.isHidden = false @@ -1177,16 +1180,18 @@ extension MainViewController { // 1. 위가 먼저 나타나고 '1.0초' 뒤 아래가 나타남 ballonView.animateStaggered(secondaryDelay: 1.0, fade: 0.3) - // 2. 첫 번째(위)가 뜬 지 '2.0초' 뒤에 사라지기 시작함 - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - // 3. 위쪽이 사라지고 '1.0초' 뒤에 아래쪽도 사라짐 + let workItem = DispatchWorkItem { [weak self] in self?.ballonView.animateHideStaggered(secondaryDelay: 1.0, fade: 0.3) } + + balloonHideWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem) } // 4. 휘발형 (캐릭터 탭, locationTapped) private func showTransientBalloon(isFare: Bool, text: String) { guard !isShowingToast else { return } // 토스트 떠있으면 무조건 무시! + balloonHideWorkItem?.cancel() safeStartJump() ballonView.layer.removeAllAnimations() @@ -1202,9 +1207,11 @@ extension MainViewController { // 한 줄만 즉시/스태거로 띄움 ballonView.animateStaggered(secondaryDelay: 0, fade: 0.25) - // 2초 뒤 자동 숨김으로 수정 - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + let workItem = DispatchWorkItem { [weak self] in self?.ballonView.animateHideStaggered(secondaryDelay: 0, fade: 0.25) } + + balloonHideWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem) } } From e883902165b231d7b831ccb41bb2129a497cab78 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Sun, 15 Mar 2026 04:45:14 +0900 Subject: [PATCH 06/13] =?UTF-8?q?[BUGFIX]=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=B0=8F=20=ED=9A=8C=EC=9B=90=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EC=8B=9C=20=EB=A7=90=ED=92=8D=EC=84=A0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Location/MainViewModel.swift | 14 +++++++------- .../User/MyAccount/MyAccountViewModel.swift | 2 ++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index 7beb6603..3148eff6 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -416,13 +416,13 @@ final class MainViewModel: BaseViewModel{ } func resetLocationState() { - self.lastValidTime = nil - self.didSendInitialLocation = false - self.consecutiveValidCount = 0 - self.currentLocation = nil - self.selectedLocation = nil - self.streamTask?.cancel() - } + self.lastValidTime = nil + self.didSendInitialLocation = false + self.consecutiveValidCount = 0 + self.currentLocation = nil + self.selectedLocation = nil + self.streamTask?.cancel() + } } // MARK: - Alarm diff --git a/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift b/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift index 669e8daf..0f5ee9e0 100644 --- a/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift +++ b/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift @@ -28,6 +28,8 @@ final class MyAccountViewModel: BaseViewModel { AppDIContainer.shared.tokenStorage.clearRefreshToken() UserDefaultsWrapper.shared.removeAll() AppDIContainer.shared.locationStateHolder.clear() + + UserDefaults.standard.set(true, forKey: "IsAppFirstLaunchedEver") UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.hasSeenIntro.rawValue) await MainActor.run { From 26056e285ec6cc21196f17ce46b20b88493b14b3 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Sun, 15 Mar 2026 05:04:14 +0900 Subject: [PATCH 07/13] =?UTF-8?q?[BUGFIX]=20=EC=9C=84=EC=B9=98=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=20=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DetailRouteViewController.swift | 2 ++ .../DetailRoute/DetailRouteViewModel.swift | 18 ++++++++++++++++-- .../Location/MainViewController.swift | 2 ++ .../Presentation/Location/MainViewModel.swift | 6 ++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift index 94d7633a..eeaaaf79 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift @@ -604,6 +604,8 @@ extension DetailRouteViewController { @objc private func didTapLocationButton() { ensureLocationPermissionOrShowToast() + viewModel.forceLocationSnap() + isFollowingUser = true shouldCenterToCurrentLocationOnce = true viewModel.startHeading() diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index 7b484c3e..e7c575e6 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift @@ -55,8 +55,14 @@ final class DetailRouteViewModel: BaseViewModel { @Published var isRefreshing: Bool = false private var lastValidTime: Date? = nil - private var consecutiveValidCount = 0 + private var didSendInitialLocation = false + private var consecutiveValidCount = 0 + func forceLocationSnap() { + self.didSendInitialLocation = false + self.lastValidTime = nil + self.consecutiveValidCount = 0 + } //#if DEBUG //@Published var mockLocation: CLLocationCoordinate2D? = nil //#endif @@ -204,6 +210,9 @@ final class DetailRouteViewModel: BaseViewModel { streamTask = Task { for await location in streamUseCase.startUpdate() { let now = Date() + + let isInitialTracking = !self.didSendInitialLocation + let timeGap = self.lastValidTime != nil ? now.timeIntervalSince(self.lastValidTime!) : 999.0 let isRecovering = timeGap > 60.0 @@ -218,7 +227,9 @@ final class DetailRouteViewModel: BaseViewModel { // 지상 탈출이 의심될 때: 바로 안 믿고 카운터를 올립니다. self.consecutiveValidCount += 1 - if self.consecutiveValidCount >= 3 { + let requiredCount = isInitialTracking ? 1 : 3 + + if self.consecutiveValidCount >= requiredCount { // 3번 연속(약 3초) 정상 신호가 들어왔다? 이건 100% 진짜 지상이다! smoother.reset(location.coordinate) @@ -252,6 +263,9 @@ final class DetailRouteViewModel: BaseViewModel { await MainActor.run { self.currentLocation = capturedCoord + if !self.didSendInitialLocation { + self.didSendInitialLocation = true + } HomeArrivalManager.shared.checkHomeArrival(currentCoord: capturedCoord) } diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index c42f0df2..f5396cdc 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -906,6 +906,8 @@ extension MainViewController { @objc private func didTapLocationButton() { guard ensureLocationPermissionOrShowToast() else { return } + viewModel.forceLocationSnap() + isFollowingUser = true viewModel.startHeading() diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index 3148eff6..96b6e811 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -423,6 +423,12 @@ final class MainViewModel: BaseViewModel{ self.selectedLocation = nil self.streamTask?.cancel() } + + func forceLocationSnap() { + self.didSendInitialLocation = false + self.lastValidTime = nil + self.consecutiveValidCount = 0 + } } // MARK: - Alarm From e483e3b8929f5f2361aeaef59f2c971ccc461ac3 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Sun, 15 Mar 2026 05:20:30 +0900 Subject: [PATCH 08/13] =?UTF-8?q?[BUGFIX]=20=EC=83=81=EC=84=B8=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=ED=99=94=EB=A9=B4=EC=9D=98=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Location/DetailRoute/DetailRouteViewController.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift index eeaaaf79..ce84370a 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift @@ -567,7 +567,6 @@ final class DetailRouteViewController: BaseViewController, // 2. 좌표가 있다면 '애니메이션 없이' 즉시 현위치로 이동 if let currentCoord = viewModel.currentLocation { - mapContainerView.setupCenter(location: currentCoord) // setupZoomCenter 대신 setupCenter(이동만) mapContainerView.setupZoomCenter(location: currentCoord) // 필요 시 줌까지 } } else { @@ -647,7 +646,6 @@ extension DetailRouteViewController { if isAlarmFired { if let currentCoord = viewModel.currentLocation { // 애니메이션 없이 즉시 이동하여 '깜빡임' 방지 - mapContainerView.setupCenter(location: currentCoord) mapContainerView.setupZoomCenter(location: currentCoord) } } From 9d767242e8239437c1fa6d63c514489a50649f05 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Sun, 15 Mar 2026 14:29:14 +0900 Subject: [PATCH 09/13] =?UTF-8?q?[BUGFIX]=20=EC=95=8C=EB=9E=8C=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=20=EC=97=AC=EB=B6=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EB=A7=90=ED=92=8D=EC=84=A0=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Location/MainViewController.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index f5396cdc..18def083 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -938,6 +938,9 @@ extension MainViewController { let cycle = postAlarmTapIndex % 3 + // 추가: 현재 알람이 울린 상태인지 확인 + let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false + if cycle == 0 { // 요금 정보 표시 (동적 로딩) let now = CACurrentMediaTime() @@ -967,12 +970,19 @@ extension MainViewController { let fareStr = latestFareString ?? "???" let displayFare = viewModel.isGuest ? "???원" : "\(fareStr)원" showTransientBalloon(isFare: true, text: displayFare) - postAlarmTapIndex += 1 + self.postAlarmTapIndex += 1 } } else if cycle == 1 { - showTransientBalloon(isFare: false, text: "시간에 맞춰 알람을 드릴게요") - postAlarmTapIndex += 1 + // 수정: 알람이 울렸다면 이 메시지를 건너뛰고 다음 메시지를 띄움 + if isAlarmFired { + showTransientBalloon(isFare: false, text: "교통 상황에 따라 시간이 달라질 수 있어요") + // cycle 1을 건너뛰었으므로 다음 탭이 cycle 0(택시비)으로 돌아가도록 index를 2 올려줌 + postAlarmTapIndex += 2 + } else { + showTransientBalloon(isFare: false, text: "시간에 맞춰 알림을 드릴게요") + postAlarmTapIndex += 1 + } } else { showTransientBalloon(isFare: false, text: "교통 상황에 따라 시간이 달라질 수 있어요") From 8d76ff7157871b1a8d7ea04c988f2a4fabe9c73f Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Sun, 15 Mar 2026 14:40:13 +0900 Subject: [PATCH 10/13] =?UTF-8?q?[BUGFIX]=20=EC=B4=88=EA=B8=B0=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EC=8B=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD=20=EC=A0=9C=ED=95=9C=20=EB=A7=90=ED=92=8D=EC=84=A0?= =?UTF-8?q?=EC=9D=B4=20=EC=9E=98=EB=AA=BB=20=EB=85=B8=EC=B6=9C=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Location/MainViewController.swift | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 18def083..513bc756 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -180,7 +180,7 @@ final class MainViewController: BaseViewController, if self.viewModel.bottomType == .search || self.viewModel.bottomType == nil { self.showOrUpdatePersistentBalloon( isFirstVisit: self.isFirstVisit, - isServiceRegion: self.latestIsServiceRegion ?? false, + isServiceRegion: self.latestIsServiceRegion, fareStr: self.latestFareString ) } @@ -688,10 +688,10 @@ extension MainViewController { mapContainerView.beforeUserMarker() updateAtchaImageConstraint(relativeTo: lastTrainSearchView) - let isService = self.latestIsServiceRegion ?? false + let isService = self.latestIsServiceRegion // 여기도 동일하게 로딩중 무시 조건 적용 - if !isService || self.viewModel.isGuest || self.latestFareString != nil { + if isService != true || self.viewModel.isGuest || self.latestFareString != nil { showOrUpdatePersistentBalloon( isFirstVisit: self.isFirstVisit, isServiceRegion: isService, @@ -723,10 +723,10 @@ extension MainViewController { // 검색 모드일 때는 즉시 말풍선 글자 업데이트 if self.viewModel.bottomType == .search { - let isService = self.latestIsServiceRegion ?? false + let isService = self.latestIsServiceRegion // 핵심: 회원이면서 서비스 지역인데 아직 택시비가 없으면(로딩중) 업데이트 생략! - if !isService || isGuest || self.latestFareString != nil { + if isService != true || isGuest || self.latestFareString != nil { self.showOrUpdatePersistentBalloon( isFirstVisit: self.isFirstVisit, isServiceRegion: isService, @@ -759,10 +759,10 @@ extension MainViewController { // 검색 모드일 때는 즉시 말풍선 글자 업데이트 if ok != nil && self.viewModel.bottomType == .search { - let isService = ok ?? false + let isService = ok // 핵심: 회원이면서 서비스 지역인데 아직 택시비가 없으면(로딩중) 업데이트 생략! - if !isService || self.viewModel.isGuest || self.latestFareString != nil { + if isService != true || self.viewModel.isGuest || self.latestFareString != nil { self.showOrUpdatePersistentBalloon( isFirstVisit: self.isFirstVisit, isServiceRegion: isService, @@ -1148,30 +1148,27 @@ extension MainViewController { } // 1 & 2. 알람 등록 전 (고정형) - 위치 이동시 글자만 바뀜 - private func showOrUpdatePersistentBalloon(isFirstVisit: Bool, isServiceRegion: Bool, fareStr: String?) { - guard !isShowingToast else { return } // 토스트 떠있으면 무조건 무시! + private func showOrUpdatePersistentBalloon(isFirstVisit: Bool, isServiceRegion: Bool?, fareStr: String?) { // 타입 Bool? 로 변경 + guard !isShowingToast else { return } let displayFare = viewModel.isGuest ? "???원" : "\(fareStr ?? "???")원" let topText = "지도를 움직여 출발지를 설정해요" - // 말풍선 내용 세팅 - if isServiceRegion { - ballonView.separationTitle(grayMessage: "여기서 막차 놓치면 택시비 ", whiteMessage: "약 \(displayFare)", showTopLine: isFirstVisit) - } else { + // 확실하게 false일 때만 지역 제한 문구 노출, nil(로딩중)이거나 true면 택시비 안내 + if isServiceRegion == false { ballonView.setupTitle(topMessage: isFirstVisit ? topText : nil, bottomMessage: "서울, 경기, 인천 내에서만 사용할 수 있어요") + } else { + ballonView.separationTitle(grayMessage: "여기서 막차 놓치면 택시비 ", whiteMessage: "약 \(displayFare)", showTopLine: isFirstVisit) } - // 이미 떠 있으면 텍스트만 업데이트, 아니면 새로 등장 if ballonView.isHidden || ballonView.alpha == 0 { safeStartJump() // 무조건 점프! ballonView.isHidden = false ballonView.alpha = 1 - // 처음 뜰 때는 서서히 애니메이션 적용 (두 줄이면 0.8초 딜레이 뒤 아래쪽 등장) let delay: TimeInterval = isFirstVisit ? 0.8 : 0.0 ballonView.animateStaggered(secondaryDelay: delay, fade: 0.3) } else { - // 이미 떠있는 상태면 위치 이동으로 인한 글자 업데이트이므로 즉시 바꿈 ballonView.revealImmediately() } } From e863b367786577f921d70de9699c3a934702828d Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Sun, 15 Mar 2026 18:22:44 +0900 Subject: [PATCH 11/13] =?UTF-8?q?[FEAT]=20=ED=83=9D=EC=8B=9C=EB=B9=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=A4=91=20=EB=A1=9C=EB=94=A9=20UI(?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=A4=91...)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Location/MainViewController.swift | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 513bc756..39c785d8 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -1148,17 +1148,30 @@ extension MainViewController { } // 1 & 2. 알람 등록 전 (고정형) - 위치 이동시 글자만 바뀜 - private func showOrUpdatePersistentBalloon(isFirstVisit: Bool, isServiceRegion: Bool?, fareStr: String?) { // 타입 Bool? 로 변경 + private func showOrUpdatePersistentBalloon(isFirstVisit: Bool, isServiceRegion: Bool?, fareStr: String?) { guard !isShowingToast else { return } - let displayFare = viewModel.isGuest ? "???원" : "\(fareStr ?? "???")원" let topText = "지도를 움직여 출발지를 설정해요" - // 확실하게 false일 때만 지역 제한 문구 노출, nil(로딩중)이거나 true면 택시비 안내 if isServiceRegion == false { + // 1. 확실하게 서비스 지역이 아닐 때 ballonView.setupTitle(topMessage: isFirstVisit ? topText : nil, bottomMessage: "서울, 경기, 인천 내에서만 사용할 수 있어요") + } else { - ballonView.separationTitle(grayMessage: "여기서 막차 놓치면 택시비 ", whiteMessage: "약 \(displayFare)", showTopLine: isFirstVisit) + // 2. 서비스 지역이거나 로딩 중일 때 + if viewModel.isGuest { + // 비회원: ???원 유지 (색상 분리) + ballonView.separationTitle(grayMessage: "여기서 막차 놓치면 택시비 ", whiteMessage: "약 ???원", showTopLine: isFirstVisit) + } else { + // 회원 + if let fare = fareStr { + // 요금 조회가 완료되었을 때 (색상 분리) + ballonView.separationTitle(grayMessage: "여기서 막차 놓치면 택시비 ", whiteMessage: "약 \(fare)원", showTopLine: isFirstVisit) + } else { + // 요금 조회 중일 때 (단일 색상으로 '계산중...' 표시) + ballonView.separationTitle(grayMessage: "여기서 막차 놓치면 택시비 계산중...", whiteMessage: "", showTopLine: isFirstVisit) + } + } } if ballonView.isHidden || ballonView.alpha == 0 { From 4c75061e978d2586f6a4031178884a7a437f117b Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Sun, 15 Mar 2026 19:12:50 +0900 Subject: [PATCH 12/13] =?UTF-8?q?[BUGFIX]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=A7=81=ED=9B=84=20=ED=83=9D=EC=8B=9C=EB=B9=84=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EC=83=81=ED=83=9C=EA=B0=80=20=EC=A6=89=EC=8B=9C=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Location/MainViewController.swift | 36 ++++++++----------- .../Presentation/Main/MainCoordinator.swift | 12 +++---- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 39c785d8..027df1e6 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -722,17 +722,13 @@ extension MainViewController { } // 검색 모드일 때는 즉시 말풍선 글자 업데이트 - if self.viewModel.bottomType == .search { - let isService = self.latestIsServiceRegion - - // 핵심: 회원이면서 서비스 지역인데 아직 택시비가 없으면(로딩중) 업데이트 생략! - if isService != true || isGuest || self.latestFareString != nil { - self.showOrUpdatePersistentBalloon( - isFirstVisit: self.isFirstVisit, - isServiceRegion: isService, - fareStr: self.latestFareString - ) - } + if self.viewModel.bottomType == .search || self.viewModel.bottomType == nil { + // 방해물(업데이트 생략 조건문) 제거! 이제 무조건 뷰를 업데이트합니다. + self.showOrUpdatePersistentBalloon( + isFirstVisit: self.isFirstVisit, + isServiceRegion: self.latestIsServiceRegion, + fareStr: self.latestFareString + ) } } .store(in: &cancellables) @@ -758,17 +754,13 @@ extension MainViewController { } // 검색 모드일 때는 즉시 말풍선 글자 업데이트 - if ok != nil && self.viewModel.bottomType == .search { - let isService = ok - - // 핵심: 회원이면서 서비스 지역인데 아직 택시비가 없으면(로딩중) 업데이트 생략! - if isService != true || self.viewModel.isGuest || self.latestFareString != nil { - self.showOrUpdatePersistentBalloon( - isFirstVisit: self.isFirstVisit, - isServiceRegion: isService, - fareStr: self.latestFareString - ) - } + if self.viewModel.bottomType == .search || self.viewModel.bottomType == nil { + // 방해물(업데이트 생략 조건문) 제거! + self.showOrUpdatePersistentBalloon( + isFirstVisit: self.isFirstVisit, + isServiceRegion: ok, + fareStr: self.latestFareString + ) } } .store(in: &cancellables) diff --git a/Atcha-iOS/Presentation/Main/MainCoordinator.swift b/Atcha-iOS/Presentation/Main/MainCoordinator.swift index c8c0009a..736777a5 100644 --- a/Atcha-iOS/Presentation/Main/MainCoordinator.swift +++ b/Atcha-iOS/Presentation/Main/MainCoordinator.swift @@ -336,14 +336,14 @@ final class MainCoordinator: NSObject { loginCoordinator.onFinishWithExistUser = { [weak self] isExist in DispatchQueue.main.async { - self?.navigationController.dismiss(animated: true) { - guard let self = self else { return } - - let newGuestStatus = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.isGuest.rawValue) ?? false - self.mainViewModel?.isGuest = newGuestStatus + guard let self = self else { return } + + UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) + self.mainViewModel?.isGuest = false + + self.navigationController.dismiss(animated: true) { if isExist { - // self.mainViewModel?.setupLocation() self.mainViewModel?.refreshCurrentMapCenterData() } else { self.routeToOnboarding?() From a5c016bb19e4ba33e45633846b8251a3fd415d22 Mon Sep 17 00:00:00 2001 From: JaeWoong Eum Date: Sun, 15 Mar 2026 19:35:41 +0900 Subject: [PATCH 13/13] =?UTF-8?q?[BUGFIX]=20=EC=B4=88=EA=B8=B0=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=ED=83=90=EC=83=89=20=EC=86=8D=EB=8F=84=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Location/DetailRoute/DetailRouteViewModel.swift | 8 +++++++- Atcha-iOS/Presentation/Location/MainViewModel.swift | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index e7c575e6..f1d25b8a 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift @@ -216,7 +216,13 @@ final class DetailRouteViewModel: BaseViewModel { let timeGap = self.lastValidTime != nil ? now.timeIntervalSince(self.lastValidTime!) : 999.0 let isRecovering = timeGap > 60.0 - let accuracyThreshold = isRecovering ? 300.0 : 150.0 + let accuracyThreshold: CLLocationAccuracy + + if isInitialTracking { + accuracyThreshold = 1000.0 // 시청 탈출용 널널한 기준 + } else { + accuracyThreshold = isRecovering ? 300.0 : 150.0 // 회원님의 지하철 복구 로직 유지! + } guard location.horizontalAccuracy < accuracyThreshold else { self.consecutiveValidCount = 0 diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index 96b6e811..afe1fcbd 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -241,7 +241,13 @@ final class MainViewModel: BaseViewModel{ let timeGap = self.lastValidTime != nil ? now.timeIntervalSince(self.lastValidTime!) : 999.0 let isRecovering = timeGap > 60.0 - let accuracyThreshold = isRecovering ? 300.0 : 150.0 + let accuracyThreshold: CLLocationAccuracy + + if isInitialTracking { + accuracyThreshold = 1000.0 // 시청 탈출용 널널한 기준 + } else { + accuracyThreshold = isRecovering ? 300.0 : 150.0 // 회원님의 지하철 복구 로직 유지! + } // 정확도 필터링 guard location.horizontalAccuracy < accuracyThreshold else {