diff --git a/Atcha-iOS/DesignSource/AtchaBallon/AtchaBallon.swift b/Atcha-iOS/DesignSource/AtchaBallon/AtchaBallon.swift index b5e31a3..9da1123 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/DetailRoute/DetailRouteViewController.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift index 94d7633..ce84370 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 { @@ -604,6 +603,8 @@ extension DetailRouteViewController { @objc private func didTapLocationButton() { ensureLocationPermissionOrShowToast() + viewModel.forceLocationSnap() + isFollowingUser = true shouldCenterToCurrentLocationOnce = true viewModel.startHeading() @@ -645,7 +646,6 @@ extension DetailRouteViewController { if isAlarmFired { if let currentCoord = viewModel.currentLocation { // 애니메이션 없이 즉시 이동하여 '깜빡임' 방지 - mapContainerView.setupCenter(location: currentCoord) mapContainerView.setupZoomCenter(location: currentCoord) } } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index 7b484c3..f1d25b8 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,10 +210,19 @@ 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 - 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 @@ -218,7 +233,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 +269,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 e0dc262..027df1e 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,33 @@ 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 + private var balloonHideWorkItem: DispatchWorkItem? // 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 +106,6 @@ final class MainViewController: BaseViewController, setupUI() setupAutoLayout() - // installMapUserGestureDetector() mapContainerView.onUserInteraction = { [weak self] in self?.stopFollowingOnUserInteraction() } @@ -151,9 +129,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 +143,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,18 +163,27 @@ final class MainViewController: BaseViewController, super.viewDidAppear(animated) if shouldShowWelcomeToast { - shouldShowWelcomeToast = false // 한 번 띄우고 바로 꺼줌 + shouldShowWelcomeToast = false + + + self.view.showToast(message: "집 주소가 등록되었어요") - // 첫 번째 토스트: 집 주소 등록 완료 - AtchaToast(message: "집 주소가 등록되었어요").show(in: self.view) - // 두 번째 토스트: 위치 권한 체크 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, + fareStr: self.latestFareString + ) + } } self.viewModel.refreshCurrentMapCenterData() @@ -213,7 +197,6 @@ final class MainViewController: BaseViewController, } else { amp_track(.main_view, properties: props(AmplitudeProperty.userStatus(.member))) } - } override func viewWillDisappear(_ animated: Bool) { @@ -337,7 +320,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 +350,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 +359,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 +435,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 +484,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 +539,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 +557,7 @@ extension MainViewController { .removeDuplicates() .compactMap { $0 } .receive(on: RunLoop.main) - .sink { _ in - // 뷰모델에서 알아서 주소를 검색하므로 뷰컨트롤러는 카메라를 건드리지 않음! - } + .sink { _ in } .store(in: &cancellables) } @@ -624,26 +577,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 +641,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 - // exit 흐름에서 지연 표시 예정이면 여기서는 자동 호출 안 함 - if !deferPreBalloonOnce { - showInitialPreAlarmBalloons(force: true) + // 여기도 동일하게 로딩중 무시 조건 적용 + if isService != true || self.viewModel.isGuest || self.latestFareString != nil { + showOrUpdatePersistentBalloon( + isFirstVisit: self.isFirstVisit, + isServiceRegion: isService, + fareStr: self.latestFareString + ) } case .detail: @@ -778,69 +708,27 @@ 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 || self.viewModel.bottomType == nil { + // 방해물(업데이트 생략 조건문) 제거! 이제 무조건 뷰를 업데이트합니다. + self.showOrUpdatePersistentBalloon( + isFirstVisit: self.isFirstVisit, + isServiceRegion: self.latestIsServiceRegion, + fareStr: self.latestFareString ) - - if self.ballonView.isHidden { - self.showOrUpdatePreBalloon(content, showTopLine: self.preSessionShowTopLine ?? true) - } else { - self.updatePreBalloonContent(content, showTopLine: self.preSessionShowTopLine ?? true) - } - } - - // 알람 등록 후 말풍선 큐 갱신 - if !self.postAlarmMessages.isEmpty { - let displayFare = isGuest ? "???원" : "\(fareStr)원" - self.postAlarmMessages[self.postAlarmMessages.count - 1] = - .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(displayFare)") } } .store(in: &cancellables) @@ -852,59 +740,28 @@ 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 self.viewModel.bottomType == .search || self.viewModel.bottomType == nil { + // 방해물(업데이트 생략 조건문) 제거! + self.showOrUpdatePersistentBalloon( + isFirstVisit: self.isFirstVisit, + isServiceRegion: ok, + fareStr: self.latestFareString + ) + } } .store(in: &cancellables) } @@ -1010,8 +867,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 +888,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) @@ -1044,13 +898,14 @@ extension MainViewController { @objc private func didTapLocationButton() { guard ensureLocationPermissionOrShowToast() else { return } + viewModel.forceLocationSnap() + 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 +923,73 @@ extension MainViewController { } @objc private func handleBallonTap() { - atchaImageView.stop() - atchaImageView.start() + // 알람 등록 후(departure 상태)일 때만 반응 + guard viewModel.bottomType == .departure else { return } + + amp_track(.character_click) + + let cycle = postAlarmTapIndex % 3 - let scope = lastShownScope + // 추가: 현재 알람이 울린 상태인지 확인 + let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false - 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) + let fareStr = latestFareString ?? "???" + let displayFare = viewModel.isGuest ? "???원" : "\(fareStr)원" + showTransientBalloon(isFare: true, text: displayFare) + self.postAlarmTapIndex += 1 } - 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 - } - } 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 + } else if cycle == 1 { + // 수정: 알람이 울렸다면 이 메시지를 건너뛰고 다음 메시지를 띄움 + if isAlarmFired { + showTransientBalloon(isFare: false, text: "교통 상황에 따라 시간이 달라질 수 있어요") + // cycle 1을 건너뛰었으므로 다음 탭이 cycle 0(택시비)으로 돌아가도록 index를 2 올려줌 + postAlarmTapIndex += 2 + } else { + showTransientBalloon(isFare: false, text: "시간에 맞춰 알림을 드릴게요") + postAlarmTapIndex += 1 } - // 요금이 있고 서비스 지역일 때 - let displayFare = viewModel.isGuest ? "???원" : "\(fare)원" - showOrUpdatePreBalloon( - .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(displayFare)"), - delay: d1, animated: true, showTopLine: showTopLine - ) - } else { - // 비서비스 지역은 기존 안내 문구 유지 - showOrUpdatePreBalloon( - .text(top: showTopLine ? "지도를 움직여 출발지를 설정해요" : nil, - bottom: "서울, 경기, 인천 내에서만 사용할 수 있어요"), - delay: d1 - ) - } - - // 여기까지 도달했을 때도 초기 방문이면 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 +1021,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 +1048,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 +1065,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 +1079,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 +1101,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 +1109,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 +1121,111 @@ 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 topText = "지도를 움직여 출발지를 설정해요" + + if isServiceRegion == false { + // 1. 확실하게 서비스 지역이 아닐 때 + ballonView.setupTitle(topMessage: isFirstVisit ? topText : nil, bottomMessage: "서울, 경기, 인천 내에서만 사용할 수 있어요") + + } else { + // 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 { + safeStartJump() // 무조건 점프! + ballonView.isHidden = false + ballonView.alpha = 1 + + 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 } // 토스트 떠있으면 무조건 무시! + + balloonHideWorkItem?.cancel() + + 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) + + 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() + 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) + + 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) + } +} diff --git a/Atcha-iOS/Presentation/Location/MainViewModel.swift b/Atcha-iOS/Presentation/Location/MainViewModel.swift index 6dea51d..afe1fcb 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -230,15 +230,24 @@ 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 - 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 { @@ -248,8 +257,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 +420,21 @@ 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() + } + + func forceLocationSnap() { + self.didSendInitialLocation = false + self.lastValidTime = nil + self.consecutiveValidCount = 0 + } } // MARK: - Alarm diff --git a/Atcha-iOS/Presentation/Main/MainCoordinator.swift b/Atcha-iOS/Presentation/Main/MainCoordinator.swift index 0ae9a1e..736777a 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 @@ -333,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?() diff --git a/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift b/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift index 669e8da..0f5ee9e 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 {