diff --git a/Atcha-iOS.xcodeproj/project.pbxproj b/Atcha-iOS.xcodeproj/project.pbxproj index 62ace6c2..f3833992 100644 --- a/Atcha-iOS.xcodeproj/project.pbxproj +++ b/Atcha-iOS.xcodeproj/project.pbxproj @@ -151,6 +151,9 @@ 6DC3BF5E2E07123F00831470 /* IntroCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF5D2E07123F00831470 /* IntroCell.swift */; }; 6DC3BF602E071F0900831470 /* LoginUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF5F2E071F0900831470 /* LoginUseCase.swift */; }; 6DC3BF682E0721F300831470 /* LoginDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3BF672E0721F300831470 /* LoginDTO.swift */; }; + 6DC617A72F60EB1A002DD641 /* LocationSmoother.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC617A62F60EB1A002DD641 /* LocationSmoother.swift */; }; + 6DC617A92F610238002DD641 /* HomeArrivalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC617A82F610238002DD641 /* HomeArrivalManager.swift */; }; + 6DC617AD2F62EB59002DD641 /* HomeRegistrationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC617AC2F62EB59002DD641 /* HomeRegistrationCoordinator.swift */; }; 6DD632B12E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD632B02E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift */; }; 6DD632B62E52E23A00C6A66E /* ProximityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD632B52E52E23A00C6A66E /* ProximityManager.swift */; }; 6DD632B92E52E8E300C6A66E /* ProximityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD632B82E52E8E300C6A66E /* ProximityViewController.swift */; }; @@ -472,6 +475,9 @@ 6DC3BF5D2E07123F00831470 /* IntroCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroCell.swift; sourceTree = ""; }; 6DC3BF5F2E071F0900831470 /* LoginUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUseCase.swift; sourceTree = ""; }; 6DC3BF672E0721F300831470 /* LoginDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginDTO.swift; sourceTree = ""; }; + 6DC617A62F60EB1A002DD641 /* LocationSmoother.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSmoother.swift; sourceTree = ""; }; + 6DC617A82F610238002DD641 /* HomeArrivalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeArrivalManager.swift; sourceTree = ""; }; + 6DC617AC2F62EB59002DD641 /* HomeRegistrationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeRegistrationCoordinator.swift; sourceTree = ""; }; 6DD632B02E4F8A9F00C6A66E /* CheckServiceRegionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckServiceRegionRequest.swift; sourceTree = ""; }; 6DD632B52E52E23A00C6A66E /* ProximityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityManager.swift; sourceTree = ""; }; 6DD632B82E52E8E300C6A66E /* ProximityViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityViewController.swift; sourceTree = ""; }; @@ -687,6 +693,7 @@ 6D1EE2D62E08E4AB00F7BBF1 /* HomeRegister */ = { isa = PBXGroup; children = ( + 6DC617AB2F62EB4B002DD641 /* Coordinator */, 6D1EE2D92E08E4E000F7BBF1 /* HomeRegisterViewController.swift */, 6D1EE2DB2E08E4EA00F7BBF1 /* HomeRegisterViewModel.swift */, ); @@ -1109,6 +1116,21 @@ path = Login; sourceTree = ""; }; + 6DC617AA2F62E931002DD641 /* ChangeHome */ = { + isa = PBXGroup; + children = ( + ); + path = ChangeHome; + sourceTree = ""; + }; + 6DC617AB2F62EB4B002DD641 /* Coordinator */ = { + isa = PBXGroup; + children = ( + 6DC617AC2F62EB59002DD641 /* HomeRegistrationCoordinator.swift */, + ); + path = Coordinator; + sourceTree = ""; + }; 6DD632AE2E4F8A6600C6A66E /* SearchLocation */ = { isa = PBXGroup; children = ( @@ -1181,6 +1203,8 @@ 6DADA5952EA09B9500CA9BE2 /* Amplitude */, B61C448D2E3F57B600285A4B /* AlarmManager.swift */, 6DD632B72E52E8D000C6A66E /* Proximity */, + 6DC617A62F60EB1A002DD641 /* LocationSmoother.swift */, + 6DC617A82F610238002DD641 /* HomeArrivalManager.swift */, ); path = Manager; sourceTree = ""; @@ -1305,6 +1329,7 @@ B65C12D82E042A320016D2F0 /* DIContainer */ = { isa = PBXGroup; children = ( + 6DC617AA2F62E931002DD641 /* ChangeHome */, 6D61ABE62F57174500111C9B /* Intro */, B61C44962E40340C00285A4B /* LockScreen */, 6D2B8CB62E39C87F00608104 /* BusInfo */, @@ -2144,6 +2169,7 @@ 6DB7636D2E45C69400D06A49 /* AlarmRepository.swift in Sources */, 6DB7636F2E45C6D100D06A49 /* AlarmRepositoryImpl.swift in Sources */, B65C12E22E042D400016D2F0 /* FetchUserUseCase.swift in Sources */, + 6DC617AD2F62EB59002DD641 /* HomeRegistrationCoordinator.swift in Sources */, B6EDD73C2E0D87F2006170DF /* FileStorageImpl.swift in Sources */, B6793D4B2E3493D6001BE9F5 /* AtcahaInsetLabel.swift in Sources */, 6D2B8CB52E39AC7200608104 /* BusPositionInfoResponse.swift in Sources */, @@ -2199,6 +2225,7 @@ B664018B2E2277A900A397AE /* PushAlarmOption.swift in Sources */, 6D26E0072F3C197F005097A4 /* SubwayInfoRepository.swift in Sources */, B65C13082E057C590016D2F0 /* APIService.swift in Sources */, + 6DC617A92F610238002DD641 /* HomeArrivalManager.swift in Sources */, 6D6879D02E4211B800E59C55 /* HomePatchRequest.swift in Sources */, 6D9283742E3AFF6A0090889B /* BusRouteCell.swift in Sources */, 6D73EB5F2E16120200F8DF8B /* CourseSearchViewModel.swift in Sources */, @@ -2281,6 +2308,7 @@ 6DADA5972EA09BA400CA9BE2 /* AmplitudeManager.swift in Sources */, 6DD632C22E544DB500C6A66E /* PushAlarmBottomView.swift in Sources */, 6D5E03D02E28853E0065AFBE /* CourseSearchResponse.swift in Sources */, + 6DC617A72F60EB1A002DD641 /* LocationSmoother.swift in Sources */, 6D91A8E62E29F5BC0081BAFC /* CourseSettingViewModel.swift in Sources */, 6D26E0002F3C17C3005097A4 /* SubwayRealTimeInfoRequest.swift in Sources */, B673C4912E0424FD00EE4AD0 /* SplashViewModel.swift in Sources */, @@ -2460,7 +2488,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 23SCTLK482; FRAMEWORK_SEARCH_PATHS = ( @@ -2483,7 +2511,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 1.9; PRODUCT_BUNDLE_IDENTIFIER = com.atcha.iOS; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2507,7 +2535,7 @@ CODE_SIGN_ENTITLEMENTS = "Atcha-iOS/Atcha-iOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 23SCTLK482; EXCLUDED_ARCHS = ""; FRAMEWORK_SEARCH_PATHS = ( @@ -2530,7 +2558,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 1.9; OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.atcha.iOS; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2555,7 +2583,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 23SCTLK482; FRAMEWORK_SEARCH_PATHS = ( @@ -2578,7 +2606,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 1.9; PRODUCT_BUNDLE_IDENTIFIER = com.atcha.iOS; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift b/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift index 866c9924..636b6a63 100644 --- a/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Main/MainDIContainer.swift @@ -48,6 +48,10 @@ final class MainDIContainer { LoginDIContainer(apiService: apiService) }() + private lazy var homeRegisterDI: HomeRegisterDIContainer = { + HomeRegisterDIContainer(apiService: apiService, locationStateHolder: locationStateHolder) + }() + init(apiService: APIService, locationStateHolder: LocationStateHolder) { self.apiService = apiService self.locationStateHolder = locationStateHolder @@ -128,3 +132,10 @@ extension MainDIContainer{ } } + +// MARK: - HomeRegister +extension MainDIContainer{ + func makeHomeRegisterDIContainer() -> HomeRegisterDIContainer { + return homeRegisterDI + } +} diff --git a/Atcha-iOS/Core/Manager/AlarmManager.swift b/Atcha-iOS/Core/Manager/AlarmManager.swift index 7747603c..765aab0e 100644 --- a/Atcha-iOS/Core/Manager/AlarmManager.swift +++ b/Atcha-iOS/Core/Manager/AlarmManager.swift @@ -33,6 +33,7 @@ final class AlarmManager { private var shouldKeepBackgroundAudio = false private var isPreviewing = false private var hapticEngine: CHHapticEngine? + private var autoStopWorkItem: DispatchWorkItem? // MARK: - Init private init() { @@ -71,6 +72,8 @@ final class AlarmManager { /// 서버에서 받은 출발 시각 기준으로 1분 전에 반복 푸시/사운드/진동을 시작 func startAlarm(title: String, body: String) { // 기존 알람 상태만 정리 (silent는 유지 or 다시 켜기) + autoStopWorkItem?.cancel() + autoStopWorkItem = nil stopAlarm(keepSilent: true) ensureBackgroundSilentRunning() applySavedVolumeForAlarmStart() @@ -99,6 +102,9 @@ final class AlarmManager { /// 완전 정지: 예약/타이머/진동/알림/오디오 모두 끊기 func stopAlarm(keepSilent: Bool = false) { + autoStopWorkItem?.cancel() + autoStopWorkItem = nil + shouldKeepBackgroundAudio = false pendingStartWorkItem?.cancel() pendingStartWorkItem = nil @@ -121,6 +127,7 @@ final class AlarmManager { } print("알람 종료 (keepSilent = \(keepSilent))") + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [AlarmNotificationID.autoStopInfo]) } func alarmInit() { @@ -220,13 +227,19 @@ extension AlarmManager { self.sendImmediateLocalPush(title: title, body: body) } + + scheduleAutoStop() } - func sendImmediateLocalPush(title: String, body: String) { + func sendImmediateLocalPush(title: String, body: String, playSound: Bool = false) { let content = UNMutableNotificationContent() content.title = title content.body = body - content.sound = nil // 사운드는 직접 재생 중 + if playSound { + content.sound = .default + } else { + content.sound = nil + } let request = UNNotificationRequest( identifier: UUID().uuidString, @@ -593,3 +606,37 @@ extension AlarmManager { print("미리듣기 종료 (keep=\(shouldKeepBackgroundAudio))") } } + +extension AlarmManager { + private func scheduleAutoStop() { + autoStopWorkItem?.cancel() + + let content = UNMutableNotificationContent() + content.title = "출발 알람이 자동 종료되었어요" + content.body = "클릭해서 경로 재탐색하기" + content.sound = .default + + let request = UNNotificationRequest( + identifier: AlarmNotificationID.autoStopInfo, + content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 120.0, repeats: false) + ) + UNUserNotificationCenter.current().add(request, withCompletionHandler: nil) + + // 2. 앱 내부의 정지 로직 및 팝업 신호 (포그라운드일 때 즉시, 백그라운드면 켜질 때 실행됨) + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + + // 음악/진동 정지 + self.stopAlarm(keepSilent: false) // 아예 무음까지 끄기 + + UserDefaults.standard.set(true, forKey: "isAlarmTimedOut") + + // 메인 뷰에 타임아웃 팝업 띄우라고 신호 + NotificationCenter.default.post(name: NSNotification.Name("alarmDidTimeout"), object: nil) + } + + autoStopWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 120.0, execute: workItem) + } +} diff --git a/Atcha-iOS/Core/Manager/Amplitude/AmplitudeEvent.swift b/Atcha-iOS/Core/Manager/Amplitude/AmplitudeEvent.swift index d43c0d19..bac7ac18 100644 --- a/Atcha-iOS/Core/Manager/Amplitude/AmplitudeEvent.swift +++ b/Atcha-iOS/Core/Manager/Amplitude/AmplitudeEvent.swift @@ -8,42 +8,89 @@ import Foundation enum AmplitudeEvent: String { - case permission_setting = "permission_setting" - case search_location_click = "search_location_click" - case current_location_click = "current_location_click" - case home_register = "home_register" - case alarm_alert_type_setting = "alarm_alert_type_setting" - case signup = "signup" - case course_search_click = "course_search_click" - case origin_search_click = "origin_search_click" - case origin_setting = "origin_setting" - case course_change_click = "course_change_click" - case course_detail_toggle_click = "course_detail_toggle_click" - case course_detail_click = "course_detail_click" - case bus_detail_click = "bus_detail_click" - case bus_info_click = "bus_info_click" - case long_interval_alarm_register = "long_interval_alarm_register" - case alarm_register = "alarm_register" - case character_click = "character_click" - case origin_time_click = "origin_time_click" - case course_click = "course_click" - case course_refresh_click = "course_refresh_click" - case start_click = "start_click" - case later_course_click = "later_course_click" - case another_alarm_register = "another_alarm_register" - case logout = "logout" - case withdraw = "withdraw" - case alarm_cancel = "alarm_cancel" + // MARK: - 온보딩 + case intro_view = "인트로_진입" + case intro_start_click = "인트로_시작_클릭" + + // MARK: - 메인 + case main_view = "메인_진입" // 1. 비로그인 진입 2. 로그인 진입 + case mypage_click = "마이페이지_클릭" + case current_location_click = "현재_위치_버튼_클릭" + + case departure_modify_click = "출발지_수정_클릭" + case home_modify_click = "집주소_수정_클릭" + case course_search_click = "막차_검색하기_클릭" + + case login_view = "로그인_진입" // 1. 마이페이지 2.출발지 수정 3.집주소 수정 4.막차검색하기 + case login_click = "로그인_클릭" + case home_register_view = "집주소_등록_진입" // 1.가입 2.메인 3.설정 + case home_search_view = "집주소_검색_진입" + case home_setting_view = "집주소_설정_진입" + case home_setting_click = "집주소_설정_클릭" + case signup = "회원가입" + + case character_click = "캐릭터_클릭" + case alarm_force_stop = "알람_강제_종료" + case alarm_timeout_stop = "알람_타임아웃_종료" + case alarm_arrive_stop = "알람_도착_종료" + case departure_time_click = "출발시간_영역_클릭" + case course_click = "경로_영역_클릭" + + + // MARK: - 마이페이지 + case mypage_view = "마이페이지_진입" + case logout = "로그아웃" + case withdraw = "회원탈퇴" + case alarm_alert_type_setting = "알람설정" + case term = "약관동의_진입" + case feedback = "피드백_진입" + + + // MARK: - 알람화면 + case alarm_view = "알람_진입" + case start_click = "출발하기_클릭" + case later_course_click = "늦은_경로_확인하기_클릭" + + + // MARK: - 경로 탐색 + case course_search_view = "경로_탐색_진입" + case course_modify_click = "경로_수정_클릭" + case alarm_register = "알람_등록" // 1. 경로 탐색 2.경로 상세 + case another_alarm_register = "다른_알람_등록" + case long_interval_alarm_register = "배차_긴_알람_등록" + case course_detail_click = "경로_상세_영역_클릭" + + // MARK: - 경로 상세 + case course_detail_view = "경로_상세_진입" + case course_refresh_click = "경로_새로고침_클릭" + case bus_detail_view = "버스_상세_진입" + case bus_detail_click = "버스_상세_클릭" + case bus_info_click = "버스_정보_클릭" + case bus_info_view = "버스_정보_진입" + case bus_refresh_click = "버스_새로고침_클릭" + case bus_info_refresh_click = "버스_정보_새로고침_클릭" + + + // MARK: - 경로 수정 + case departure_modify_view = "출발지_수정_진입" + case departure_setting_view = "출발지_설정_진입" + case departure_setting_click = "출발지_설정_클릭" } enum AmplitudePropertyKey: String { - case alertType = "alert_type" - case dwellTime = "dwell_time" - case screenName = "screen_name" - case withdrawReason = "withdraw_reason" + case alertType = "알람_방식" + case dwellTime = "알람_등록_시간" + case withdrawReason = "탈퇴_사유" + case social = "로그인_방식" + case userStatus = "로그인_상태" + case entryPoint = "진입_경로" } enum AmplitudeProperty { + static func social(_ social: SocialType) -> (String, Any) { + (AmplitudePropertyKey.social.rawValue, social.rawValue) + } + static func alertType(_ type: AlertType) -> (String, Any) { (AmplitudePropertyKey.alertType.rawValue, type.rawValue) } @@ -52,32 +99,17 @@ enum AmplitudeProperty { (AmplitudePropertyKey.dwellTime.rawValue, seconds) } - static func screenName(_ screen: ScreenName) -> (String, Any) { - (AmplitudePropertyKey.screenName.rawValue, screen.rawValue) + static func userStatus(_ userStatus: UserStatus) -> (String, Any) { + (AmplitudePropertyKey.userStatus.rawValue, userStatus.rawValue) } static func withdrawReason(_ reason: WithdrawReason) -> (String, Any) { (AmplitudePropertyKey.withdrawReason.rawValue, reason.rawValue) } -} - -enum ScreenName: String { - case home_register = "우리집 등록" - case home_search = "우리집 검색" - case home_setting = "우리집 설정" - case alarm_setting = "알람 설정" - case mypage = "마이페이지" - case account = "내 계정" - case withdraw = "계정 탈퇴" - case terms = "약관" - case main = "메인" - case course_search = "경로 탐색" - case course_detail = "상세 경로" - case origin_search = "출발지 검색" - case origin_setting = "출발지 설정" - case alarm = "알람" - case bus_detail = "버스 경로" - case bus_info = "버스 정보" + + static func entryPoint(_ entryPoint: EntryPoint) -> (String, Any) { + (AmplitudePropertyKey.entryPoint.rawValue, entryPoint.rawValue) + } } enum AlertType: String { @@ -94,3 +126,22 @@ enum WithdrawReason: String { case dont_know_how = "앱 사용법을 모르겠어요" case map_app_enough = "기존에 쓰던 지도 앱으로 충분해요" } + +enum SocialType: String { + case kakao = "카카오" + case apple = "애플" +} + +enum UserStatus: String { + case guest = "게스트" + case member = "로그인" +} + +enum EntryPoint: String { + case signup = "회원가입" + case main = "메인" + case mypage = "마이페이지" + case departure = "출발지_수정" + case home_modify = "집주소_수정" + case course_search = "막차_검색하기" +} diff --git a/Atcha-iOS/Core/Manager/Amplitude/AmplitudeManager.swift b/Atcha-iOS/Core/Manager/Amplitude/AmplitudeManager.swift index 8bf74eec..6a7a3fa2 100644 --- a/Atcha-iOS/Core/Manager/Amplitude/AmplitudeManager.swift +++ b/Atcha-iOS/Core/Manager/Amplitude/AmplitudeManager.swift @@ -13,14 +13,14 @@ import UIKit final class AmplitudeManager { static let shared = AmplitudeManager() private init() {} - + private let queue = DispatchQueue(label: "amp.manager.queue") private var client: Amplitude? - + private var timers: [String: Date] = [:] - + // MARK: Public API - + func start( userId: Int? = nil, autocapture: AutocaptureOptions = [.sessions, .appLifecycles], @@ -32,13 +32,13 @@ final class AmplitudeManager { assertionFailure("[AmplitudeManager] Missing AMPLITUDE_API_KEY. Check Info.plist + xcconfig mapping.") return } - + let config = Configuration( apiKey: apiKey, logLevel: logLevel, autocapture: autocapture ) - + queue.sync { let c = Amplitude(configuration: config) if let uid = userId { @@ -47,18 +47,18 @@ final class AmplitudeManager { self.client = c } } - + func bindUser(id: String) { queue.async { [weak self] in guard let self, let client = self.client else { return } client.setUserId(userId: "USER_ID: \(id)") } } - + func track(_ event: AmplitudeEvent, _ properties: [String: Any?] = [:]) { track(event.rawValue, properties) } - + func track(_ event: String, _ properties: [String: Any?] = [:]) { queue.async { [weak self] in guard let self, let client = self.client else { return } @@ -66,13 +66,7 @@ final class AmplitudeManager { client.track(eventType: event, eventProperties: props) } } - - func trackScreen(_ screen: ScreenName, _ properties: [String: Any?] = [:]) { - var props = properties - props[AmplitudePropertyKey.screenName.rawValue] = screen.rawValue - track("screen_view", props) - } - + func identify( set: [String: Any?] = [:], add: [String: Double] = [:], @@ -91,25 +85,25 @@ final class AmplitudeManager { client.identify(identify: i) } } - + func setUserProperties(_ properties: [String: Any?]) { identify(set: properties) } - + func reset() { queue.async { [weak self] in guard let self, let client = self.client else { return } client.reset() } } - + func flush() { queue.async { [weak self] in guard let self, let client = self.client else { return } client.flush() } } - + var deviceId: String? { queue.sync { client?.getDeviceId() } } @@ -120,7 +114,7 @@ private extension AmplitudeManager { static func readApiKey() -> String? { Bundle.main.object(forInfoDictionaryKey: "AMPLITUDE_API_KEY") as? String } - + static func clean(_ dict: [String: Any?]) -> [String: Any] { var out: [String: Any] = [:] dict.forEach { k, v in if let v = v { out[k] = v } } @@ -130,8 +124,8 @@ private extension AmplitudeManager { // MARK: - UIKit convenience extension UIViewController { - func amp_trackScreen(_ screen: ScreenName, extra: [String: Any?] = [:]) { - AmplitudeManager.shared.trackScreen(screen, extra) + func amp_track(_ event: AmplitudeEvent, properties: [String: Any?] = [:]) { + AmplitudeManager.shared.track(event, properties) } } @@ -140,7 +134,7 @@ extension AmplitudeManager { func timerStart(_ key: String) { queue.async { [weak self] in self?.timers[key] = Date() } } - + /// 타이머 종료(초 단위 반환). 없으면 0 @discardableResult func timerEndSeconds(_ key: String) -> Int { @@ -149,7 +143,7 @@ extension AmplitudeManager { guard let s = start else { return 0 } return Int(Date().timeIntervalSince(s).rounded()) } - + /// 사용자 프로퍼티 값을 누적(+) func incrementUserProperty(_ key: String, by value: Double = 1) { queue.async { [weak self] in @@ -168,3 +162,4 @@ func props(_ items: (String, Any)... ) -> AmpProps { items.forEach { dict[$0.0] = $0.1 } return dict } + diff --git a/Atcha-iOS/Core/Manager/HomeArrivalManager.swift b/Atcha-iOS/Core/Manager/HomeArrivalManager.swift new file mode 100644 index 00000000..eda71c91 --- /dev/null +++ b/Atcha-iOS/Core/Manager/HomeArrivalManager.swift @@ -0,0 +1,55 @@ +// +// HomeArrivalManager.swift +// Atcha-iOS +// +// Created by wodnd on 3/11/26. +// + +import Foundation +import CoreLocation +import UserNotifications +import UIKit + +final class HomeArrivalManager { + static let shared = HomeArrivalManager() + private init() {} + + private var isArrivalSignalSent = false + + func checkHomeArrival(currentCoord: CLLocationCoordinate2D) { + let wrapper = UserDefaultsWrapper.shared + + guard wrapper.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) == true else { + isArrivalSignalSent = false + return + } + + guard !isArrivalSignalSent else { return } + + let homeLat = wrapper.double(forKey: UserDefaultsWrapper.Key.homeLat.rawValue) ?? 0.0 + let homeLon = wrapper.double(forKey: UserDefaultsWrapper.Key.homeLon.rawValue) ?? 0.0 + + guard homeLat != 0 && homeLon != 0 else { return } + + let homeLoc = CLLocation(latitude: homeLat, longitude: homeLon) + let currentLoc = CLLocation(latitude: currentCoord.latitude, longitude: currentCoord.longitude) + + let distance = currentLoc.distance(from: homeLoc) + + if distance <= 50 { + isArrivalSignalSent = true + + AlarmManager.shared.sendImmediateLocalPush( + title: "막차 안내 종료", + body: "목적지 부근에 도착했어요", + playSound: true + ) + + NotificationCenter.default.post(name: NSNotification.Name("userArrivedHome"), object: nil) + } + } + + func reset() { + isArrivalSignalSent = false + } +} diff --git a/Atcha-iOS/Core/Manager/LocationSmoother.swift b/Atcha-iOS/Core/Manager/LocationSmoother.swift new file mode 100644 index 00000000..e63fa2da --- /dev/null +++ b/Atcha-iOS/Core/Manager/LocationSmoother.swift @@ -0,0 +1,72 @@ +// +// LocationSmoother.swift +// Atcha-iOS +// +// Created by wodnd on 3/11/26. +// + +import Foundation +import CoreLocation +import MapKit + +final class LocationSmoother { + private var buffer: [CLLocationCoordinate2D] = [] + private let bufferLimit: Int + + init(limit: Int = 5) { + self.bufferLimit = limit + } + + func reset(_ coordinate: CLLocationCoordinate2D) { + buffer.removeAll() + buffer.append(coordinate) + } + + func smooth(_ next: CLLocationCoordinate2D) -> CLLocationCoordinate2D { + buffer.append(next) + if buffer.count > bufferLimit { buffer.removeFirst() } + + let avgLat = buffer.map { $0.latitude }.reduce(0, +) / Double(buffer.count) + let avgLon = buffer.map { $0.longitude }.reduce(0, +) / Double(buffer.count) + + return CLLocationCoordinate2D(latitude: avgLat, longitude: avgLon) + } +} + +extension LocationSmoother { + /// 좌표를 경로선(Polyline) 위 가장 가까운 점으로 고정합니다. + func snap(current: CLLocationCoordinate2D, polyline: [CLLocationCoordinate2D], threshold: Double = 150) -> CLLocationCoordinate2D { + guard polyline.count >= 2 else { return current } + + let p = MKMapPoint(current) + var minDistance = Double.greatestFiniteMagnitude + var closestPoint = p + + for i in 0..<(polyline.count - 1) { + let a = MKMapPoint(polyline[i]) + let b = MKMapPoint(polyline[i + 1]) + + let projected = closestPointOnSegment(p, a, b) + let distance = projected.distance(to: p) + + if distance < minDistance { + minDistance = distance + closestPoint = projected + } + } + + // 임계값(150m) 보다 멀어지면 사용자가 경로를 이탈한 것으로 간주하여 원본 좌표 반환 + return minDistance < threshold ? closestPoint.coordinate : current + } + + private func closestPointOnSegment(_ p: MKMapPoint, _ a: MKMapPoint, _ b: MKMapPoint) -> MKMapPoint { + let dx = b.x - a.x + let dy = b.y - a.y + if dx == 0 && dy == 0 { return a } + + let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy) + if t < 0 { return a } + if t > 1 { return b } + return MKMapPoint(x: a.x + t * dx, y: a.y + t * dy) + } +} diff --git a/Atcha-iOS/Data/Model/UserDTO/UserInfoResponse.swift b/Atcha-iOS/Data/Model/UserDTO/UserInfoResponse.swift index 852731a6..1f277d76 100644 --- a/Atcha-iOS/Data/Model/UserDTO/UserInfoResponse.swift +++ b/Atcha-iOS/Data/Model/UserDTO/UserInfoResponse.swift @@ -16,7 +16,6 @@ struct UserInfoResponse: Codable { let address: String? let latitude: Double? let longitude: Double? - let alarmFrequent: [Int?] let appVersion: String enum CodingKeys: String, CodingKey { @@ -27,7 +26,6 @@ struct UserInfoResponse: Codable { case address case latitude = "lat" case longitude = "lon" - case alarmFrequent = "alertFrequencies" case appVersion } } @@ -41,6 +39,6 @@ extension UserInfoResponse { providerId: providerId, address: address, coordinate: coordinate, - alarmFrequent: alarmFrequent, appVersion: appVersion) + appVersion: appVersion) } } diff --git a/Atcha-iOS/Data/Model/UserInfoPatchDTO/UserInfoPatchResponse.swift b/Atcha-iOS/Data/Model/UserInfoPatchDTO/UserInfoPatchResponse.swift index b45df031..ab4ad0b4 100644 --- a/Atcha-iOS/Data/Model/UserInfoPatchDTO/UserInfoPatchResponse.swift +++ b/Atcha-iOS/Data/Model/UserInfoPatchDTO/UserInfoPatchResponse.swift @@ -13,5 +13,4 @@ struct UserInfoPatchResponse: Codable { let address: String? let lat: Double? let lon: Double? - let alertFrequencies: [Int]? } 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/DesignSource/AtchaColor/AtchaColor.swift b/Atcha-iOS/DesignSource/AtchaColor/AtchaColor.swift index 96a5e006..a10425d2 100644 --- a/Atcha-iOS/DesignSource/AtchaColor/AtchaColor.swift +++ b/Atcha-iOS/DesignSource/AtchaColor/AtchaColor.swift @@ -82,6 +82,8 @@ enum AtchaColor{ static let remainTime = UIColor(named: "remainTime")! static let paddingLabel = UIColor(named: "paddingLabel")! } + + static let neutral = UIColor(named: "neutral") // MARK: - 사용 예시 // // titleLabel.textColor = AtchaColor.gray900 diff --git a/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Line/Contents.json b/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Line/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Line/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Line/neutral.colorset/Contents.json b/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Line/neutral.colorset/Contents.json new file mode 100644 index 00000000..2ac6711a --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets/Transportation/Line/neutral.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x42", + "green" : "0x3C", + "red" : "0x39" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaToast/AtchaToast.swift b/Atcha-iOS/DesignSource/AtchaToast/AtchaToast.swift index 7b671e76..7e08b8f7 100644 --- a/Atcha-iOS/DesignSource/AtchaToast/AtchaToast.swift +++ b/Atcha-iOS/DesignSource/AtchaToast/AtchaToast.swift @@ -17,6 +17,7 @@ final class AtchaToast: UIView { setupLabel(message: message) setupView() setupAutoLayout() + setupGesture() } required init?(coder: NSCoder) { @@ -35,6 +36,7 @@ final class AtchaToast: UIView { backgroundColor = UIColor.gray930 layer.cornerRadius = 12 clipsToBounds = true + self.isUserInteractionEnabled = true } private func setupAutoLayout() { @@ -42,6 +44,55 @@ final class AtchaToast: UIView { make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)) } } + + private func setupGesture() { + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) + self.addGestureRecognizer(panGesture) + } + + @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: self.superview) + let velocity = gesture.velocity(in: self.superview) + + switch gesture.state { + case .began: + // 중요: 자동 사라짐 예약 취소! + NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(autoHide), object: nil) + self.layer.removeAllAnimations() + + case .changed: + if translation.y < 0 { + self.transform = CGAffineTransform(translationX: 0, y: translation.y) + } + + case .ended: + if translation.y < -30 || velocity.y < -500 { + dismissWithAnimation() + } else { + UIView.animate(withDuration: 0.3, delay: 0, options: [.allowUserInteraction], animations: { + self.transform = .identity + }) { _ in + // 다시 제자리로 왔으니 자동 사라짐 재예약 (선택 사항) + self.perform(#selector(self.autoHide), with: nil, afterDelay: 2.0) + } + } + default: break + } + } + + // 공통 삭제 애니메이션 + // dismissWithAnimation에서도 안전하게 한 번 더 취소해주는 게 좋습니다. + private func dismissWithAnimation() { + NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(autoHide), object: nil) + self.isUserInteractionEnabled = false + + UIView.animate(withDuration: 0.3, animations: { + self.alpha = 0 + self.transform = CGAffineTransform(translationX: 0, y: -100) + }) { _ in + self.removeFromSuperview() + } + } } extension AtchaToast { @@ -50,7 +101,6 @@ extension AtchaToast { topOffset: CGFloat = 10) { guard self.superview == nil else { return } - parentView.addSubview(self) snp.makeConstraints { make in @@ -61,35 +111,43 @@ extension AtchaToast { parentView.layoutIfNeeded() + // 초기 상태 alpha = 0.0 transform = CGAffineTransform(translationX: 0, y: -10) + // 1. 등장 애니메이션 (사용자 터치 허용 옵션 추가) UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5, - options: [.curveEaseOut], - animations: { [weak self] in - guard let self else { return } - alpha = 1.0 - transform = .identity - }, completion: { _ in - UIView.animate(withDuration: 0.8, - delay: duration, - options: [.curveEaseIn], - animations: { [weak self] in - guard let self else { return } - alpha = 1.0 - transform = CGAffineTransform(translationX: 0, y: -parentView.bounds.height) - }, completion: { [weak self] _ in - guard let self else { return } - removeFromSuperview() - }) - }) + options: [.beginFromCurrentState, .allowUserInteraction], // 터치 허용! + animations: { + self.alpha = 1.0 + self.transform = .identity + }) { _ in + // 2. 일정 시간 뒤에 자동으로 사라지게 함 (애니메이션 내부 delay 대신 사용) + // 이렇게 해야 대기 시간 동안 제스처가 먹습니다. + self.perform(#selector(self.autoHide), with: nil, afterDelay: duration) + } + } + + @objc private func autoHide() { + dismissWithAnimation() } + // AtchaToast.swift 내부 수정 + func hideImmediately() { - layer.removeAllAnimations() - removeFromSuperview() + // 1. 예약된 autoHide 타이머를 즉시 취소 (가장 중요) + NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(autoHide), object: nil) + + // 2. 현재 실행 중인 모든 레이어 애니메이션 중단 + self.layer.removeAllAnimations() + + // 3. 부모 뷰에서 즉시 제거 + self.removeFromSuperview() + + // 4. 터치 상태 초기화 + self.isUserInteractionEnabled = false } } diff --git a/Atcha-iOS/Domain/Entity/UserInfo.swift b/Atcha-iOS/Domain/Entity/UserInfo.swift index b5c7acda..88f1375a 100644 --- a/Atcha-iOS/Domain/Entity/UserInfo.swift +++ b/Atcha-iOS/Domain/Entity/UserInfo.swift @@ -13,6 +13,5 @@ struct UserInfo { let providerId: String? let address: String? let coordinate: CLLocationCoordinate2D? - let alarmFrequent: [Int?] let appVersion: String } diff --git a/Atcha-iOS/Presentation/BusDetail/BusDetailViewController.swift b/Atcha-iOS/Presentation/BusDetail/BusDetailViewController.swift index 6b1ab5b2..99d3b03a 100644 --- a/Atcha-iOS/Presentation/BusDetail/BusDetailViewController.swift +++ b/Atcha-iOS/Presentation/BusDetail/BusDetailViewController.swift @@ -72,7 +72,7 @@ class BusDetailViewController: BaseViewController { } } - AmplitudeManager.shared.trackScreen(.bus_detail) + amp_track(.bus_detail_view) } override func viewDidDisappear(_ animated: Bool) { @@ -148,7 +148,7 @@ class BusDetailViewController: BaseViewController { private func bindActions() { headerView.onInfoTap = { [weak self] in self?.viewModel.didTapInfo() - AmplitudeManager.shared.track(.bus_info_click) + self?.amp_track(.bus_info_click) } } @@ -285,6 +285,8 @@ class BusDetailViewController: BaseViewController { refreshButton.start() showLoadingOnce() viewModel.refresh() + + amp_track(.bus_refresh_click) } private func itemAt(_ indexPath: IndexPath) -> BusRouteStationList? { diff --git a/Atcha-iOS/Presentation/BusDetail/BusInfo/BusInfoViewController.swift b/Atcha-iOS/Presentation/BusDetail/BusInfo/BusInfoViewController.swift index 496b8a57..dadcc42d 100644 --- a/Atcha-iOS/Presentation/BusDetail/BusInfo/BusInfoViewController.swift +++ b/Atcha-iOS/Presentation/BusDetail/BusInfo/BusInfoViewController.swift @@ -71,7 +71,7 @@ class BusInfoViewController: BaseViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - AmplitudeManager.shared.trackScreen(.bus_info) + amp_track(.bus_info_view) } override func viewDidDisappear(_ animated: Bool) { @@ -332,5 +332,7 @@ class BusInfoViewController: BaseViewController { refreshButton.start() showLoadingOnce() viewModel.refresh() + + amp_track(.bus_info_refresh_click) } } diff --git a/Atcha-iOS/Presentation/Course/CourseModify/CourseModifyViewController.swift b/Atcha-iOS/Presentation/Course/CourseModify/CourseModifyViewController.swift index 2f745127..63449fcc 100644 --- a/Atcha-iOS/Presentation/Course/CourseModify/CourseModifyViewController.swift +++ b/Atcha-iOS/Presentation/Course/CourseModify/CourseModifyViewController.swift @@ -54,7 +54,7 @@ final class CourseModifyViewController: BaseViewController Bool { diff --git a/Atcha-iOS/Presentation/Intro/IntroViewController.swift b/Atcha-iOS/Presentation/Intro/IntroViewController.swift index dbf1dd20..77855fc2 100644 --- a/Atcha-iOS/Presentation/Intro/IntroViewController.swift +++ b/Atcha-iOS/Presentation/Intro/IntroViewController.swift @@ -79,6 +79,12 @@ final class IntroViewController: BaseViewController { autoScrollTimer = nil } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + amp_track(.intro_view) + } + private func setupUI() { view.addSubViews(backgroundImageView, pageControl, collectionView, guestLoginButton) @@ -184,6 +190,8 @@ extension IntroViewController { // MARK: - Actions @objc private func didTapGuestLogin() { viewModel.guestLoginTapped() + + amp_track(.intro_start_click) } } diff --git a/Atcha-iOS/Presentation/Location/Coordinator/MainRoute.swift b/Atcha-iOS/Presentation/Location/Coordinator/MainRoute.swift index d8619350..59384ca2 100644 --- a/Atcha-iOS/Presentation/Location/Coordinator/MainRoute.swift +++ b/Atcha-iOS/Presentation/Location/Coordinator/MainRoute.swift @@ -16,4 +16,5 @@ enum MainRoute { case proximity // 가까운 거리 알림 모달 case dismissLockScreen case loginSheet + case changeHome } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift index 82bd67d1..df69983c 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteBusCell.swift @@ -49,7 +49,7 @@ final class DetailRouteBusCell: UICollectionViewCell { private let stationListStackView = UIStackView() private let busTimerFirstLabel: UILabel = UILabel() -// private let busTimerSecondLabel: UILabel = UILabel() + // private let busTimerSecondLabel: UILabel = UILabel() private lazy var busTimerStackView: UIStackView = { let stack = UIStackView(arrangedSubviews: [busTimerFirstLabel]) stack.axis = .vertical @@ -83,6 +83,10 @@ final class DetailRouteBusCell: UICollectionViewCell { private var isArrivedEffectOn = false private var isAlarmFired: Bool = false + private var hasMetZero: Bool = false + private var isBoarded: Bool = false + private var hasDepartedStartStation: Bool = false + override init(frame: CGRect) { super.init(frame: frame) setupUI() @@ -114,6 +118,10 @@ final class DetailRouteBusCell: UICollectionViewCell { isAlarmFired = false busTimerStackView.isHidden = true + + hasMetZero = false + isBoarded = false + hasDepartedStartStation = false } override func preferredLayoutAttributesFitting( @@ -310,6 +318,9 @@ final class DetailRouteBusCell: UICollectionViewCell { endLabel.attributedText = endCombinedLabel self.busTimerStackView.isHidden = !isAlarmFired + if isAlarmFired && !currentBusInfo.isEmpty { + updateBusTimerLabels() // 데이터가 이미 있다면 레이블을 즉시 그림 + } } @@ -351,14 +362,26 @@ final class DetailRouteBusCell: UICollectionViewCell { } } - func isNowUserLocationArrived() { - if isArrivedEffectOn { return } + func isNowUserLocationArrived(hasDeparted: Bool = false) { + // 정류장에 도착해서 0초(도착)를 본 적이 있을 때만 '출발'을 인정합니다. + if hasDeparted && hasMetZero { + self.hasDepartedStartStation = true + } + + if isArrivedEffectOn { + // 출발 상태가 들어왔다면 라벨 갱신 (탑승 완료 띄우기) + if self.hasDepartedStartStation { updateBusTimerLabels() } + return + } + isArrivedEffectOn = true animationView.isHidden = false animationView.startAnimationIfNeeded(forceRestart: true) backgroundColor = UIColor.opacity100 + + updateBusTimerLabels() } - + func stopArrivedEffectIfNeeded() { guard isArrivedEffectOn else { return } isArrivedEffectOn = false @@ -434,73 +457,75 @@ extension DetailRouteBusCell { extension DetailRouteBusCell { func setupBusRealTimeInfo(info: LegTrafficInfo?, busInfo: [RealTimeBusArrival]) { busTimerStackView.isHidden = false - + let filtered = busInfo .filter { ($0.remainingTime ?? -1) > 0 } - + currentBusInfo = filtered - + guard !busInfo.isEmpty else { stopCountdownTimer() busTimerFirstLabel.attributedText = AtchaFont.B6_R_14("", color: .gray300) return } - + guard !currentBusInfo.isEmpty else { stopCountdownTimer() busTimerFirstLabel.attributedText = AtchaFont.B6_R_14("", color: .widearea) return } - + updateBusTimerLabels() startCountdownTimerIfNeeded() } - + private func startCountdownTimerIfNeeded() { // 이미 돌고 있으면 유지 if countdownTimer != nil { return } - + countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.decrementRemainingTime() } - + // 스크롤 중에도 잘 돌게 common mode 추천 if let timer = countdownTimer { RunLoop.main.add(timer, forMode: .common) } } - + private func stopCountdownTimer() { countdownTimer?.invalidate() countdownTimer = nil } - + // 1초마다 감소 - private func decrementRemainingTime() { - guard !currentBusInfo.isEmpty else { - stopCountdownTimer() - return - } - - for i in 0.. 0 } - - if currentBusInfo.isEmpty { - stopCountdownTimer() - busTimerFirstLabel.attributedText = AtchaFont.B6_R_14("도착 또는 출발", color: .widearea) -// busTimerSecondLabel.text = "" - return + private func decrementRemainingTime() { + guard !currentBusInfo.isEmpty else { + stopCountdownTimer() + return + } + + for i in 0.. 0 } + + if currentBusInfo.isEmpty { + stopCountdownTimer() + busTimerFirstLabel.attributedText = AtchaFont.B6_R_14("도착 또는 출발", color: .widearea) + return + } + + updateBusTimerLabels() } - - updateBusTimerLabels() - } - + private func updateBusTimerLabels() { - + guard isAlarmFired else { busTimerStackView.isHidden = true return @@ -509,42 +534,69 @@ extension DetailRouteBusCell { busTimerStackView.isHidden = false func labelText(for info: RealTimeBusArrival) -> NSAttributedString { + if self.isBoarded { + return AtchaFont.B6_R_14("탑승 완료", color: .gray300) + } + if info.busStatus == .end { return AtchaFont.B6_R_14("운행 종료", color: .gray) } - + guard let remaining = info.remainingTime else { - return AtchaFont.B6_R_14("", color: .gray300) + // 예외처리: API 갱신으로 정보가 날아갔는데 0초를 본 적이 있다면 탑승으로 간주 + if self.hasMetZero { + self.isBoarded = true + return AtchaFont.B6_R_14("탑승 완료", color: .gray300) + } else { + return AtchaFont.B6_R_14("", color: .gray300) + } } - - if remaining <= 120 { + + if remaining <= 0 { + self.hasMetZero = true + } + + if self.hasMetZero { + // (1) 도착 후 60초가 지났거나 + // (2) 0초를 봤는데 API가 갱신되었거나 + // (3) 정류장에서 150m 멀어짐을 감지했을 때 + if remaining <= -60 || (remaining > 0 && self.isArrivedEffectOn) || self.hasDepartedStartStation { + self.isBoarded = true + self.stopCountdownTimer() // 버스 타이머 멈춤 + return AtchaFont.B6_R_14("탑승 완료", color: .gray300) + } + } + + if remaining <= 0 { + return AtchaFont.B6_R_14("도착 또는 출발", color: .widearea) + } else if remaining <= 120 { return AtchaFont.B6_R_14("곧 도착", color: .widearea) } else { return AtchaFont.B6_R_14(formatSecondsToHMS(remaining), color: .widearea) } } - + switch currentBusInfo.count { case 2: busTimerFirstLabel.attributedText = labelText(for: currentBusInfo[0]) -// busTimerSecondLabel.attributedText = labelText(for: currentBusInfo[1]) + // busTimerSecondLabel.attributedText = labelText(for: currentBusInfo[1]) case 1: busTimerFirstLabel.attributedText = labelText(for: currentBusInfo[0]) -// busTimerSecondLabel.text = "" + // busTimerSecondLabel.text = "" default: // 3개 이상이면 우선 2개만 보여주거나, 숨기지 말고 2개만 보여주자 busTimerFirstLabel.attributedText = labelText(for: currentBusInfo[0]) -// busTimerSecondLabel.attributedText = labelText(for: currentBusInfo[1]) + // busTimerSecondLabel.attributedText = labelText(for: currentBusInfo[1]) } } - + private func formatSecondsToHMS(_ seconds: Int?) -> String { guard let seconds, seconds >= 0 else { return "" } - + let h = seconds / 3600 let m = (seconds % 3600) / 60 let s = seconds % 60 - + if h > 0 { // 시가 있으면 시/분/초 // (원하면 "1시간 0분 5초"처럼 0분도 보여줄지 결정 가능) @@ -554,12 +606,12 @@ extension DetailRouteBusCell { return "\(h)시간 \(s)초" } } - + if m > 0 { // 시가 없으면 분/초 return "\(m)분 \(s)초" } - + // 분도 없으면 초만 return "\(s)초" } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift index 58b410f7..68bcb1de 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/Cell/DetailRouteSubwayCell.swift @@ -73,6 +73,10 @@ final class DetailRouteSubwayCell: UICollectionViewCell { var currentLegTrafficInfo: LegTrafficInfo? = nil private var isArrivedEffectOn = false private var isAlarmFired: Bool = false + private var hasMetZero: Bool = false + private var isBoarded: Bool = false + private var hasDepartedStartStation: Bool = false + override init(frame: CGRect) { super.init(frame: frame) @@ -108,6 +112,10 @@ final class DetailRouteSubwayCell: UICollectionViewCell { currentRemainingSec = nil isAlarmFired = false subwayTimerLabel.isHidden = true + + hasMetZero = false + isBoarded = false + hasDepartedStartStation = false } override func preferredLayoutAttributesFitting( @@ -313,6 +321,9 @@ final class DetailRouteSubwayCell: UICollectionViewCell { // } self.subwayTimerLabel.isHidden = !isAlarmFired + if isAlarmFired && currentRemainingSec != nil { + updateSubwayTimerLabel() + } } private func addStationNameLabel(info: [PassStopList]) { @@ -325,12 +336,23 @@ final class DetailRouteSubwayCell: UICollectionViewCell { } } - func isNowUserLocationArrived() { - if isArrivedEffectOn { return } + func isNowUserLocationArrived(hasDeparted: Bool = false) { + if hasDeparted && hasMetZero { + self.hasDepartedStartStation = true + } + + if isArrivedEffectOn { + // 출발 상태가 들어왔다면 라벨 갱신 (탑승 완료 띄우기) + if self.hasDepartedStartStation { updateSubwayTimerLabel() } + return + } + isArrivedEffectOn = true animationView.isHidden = false animationView.startAnimationIfNeeded(forceRestart: true) backgroundColor = UIColor.opacity100 + + updateSubwayTimerLabel() } func stopArrivedEffectIfNeeded() { @@ -478,17 +500,14 @@ extension DetailRouteSubwayCell { return } + // 무조건 1초씩 뺌 (음수 허용) let next = sec - 1 currentRemainingSec = next - if next <= 0 { - currentRemainingSec = 0 - updateSubwayTimerLabel() - stopSubwayCountdownTimer() - return - } + updateSubwayTimerLabel() // 라벨 업데이트 (판정은 여기서 알아서 함) - updateSubwayTimerLabel() + // 탑승 완료 자물쇠가 잠기면 타이머 정지 + if isBoarded { stopSubwayCountdownTimer() } } private func updateSubwayTimerLabel() { @@ -500,12 +519,38 @@ extension DetailRouteSubwayCell { subwayTimerLabel.isHidden = false + if isBoarded { + subwayTimerLabel.attributedText = AtchaFont.B6_R_14("탑승 완료", color: .gray300) + return + } + guard let sec = currentRemainingSec else { - subwayTimerLabel.attributedText = AtchaFont.B6_R_14("", color: .widearea) + if hasMetZero { + isBoarded = true + subwayTimerLabel.attributedText = AtchaFont.B6_R_14("탑승 완료", color: .gray300) + } else { + subwayTimerLabel.attributedText = AtchaFont.B6_R_14("", color: .widearea) + } return } - if sec == 0 { + if sec <= 0 { + hasMetZero = true + } + + if hasMetZero { + // (1) 역에서 150m 이상 멀어짐 (ViewModel에서 알려줌) + // (2) 0초 도달 후 1분(-60초)이 경과함 + // (3) 아직 애니메이션 켜져 있는데 API가 다음 배차(sec > 0)로 갱신됨 + if self.hasDepartedStartStation || sec <= -60 || (sec > 0 && isArrivedEffectOn) { + isBoarded = true // 자물쇠 딸깍 + subwayTimerLabel.attributedText = AtchaFont.B6_R_14("탑승 완료", color: .gray300) + stopSubwayCountdownTimer() // 타이머 종료 + return + } + } + + if sec <= 0 { subwayTimerLabel.attributedText = AtchaFont.B6_R_14("도착 또는 출발", color: .widearea) return } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift index 05f586e7..4fca61b2 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteInfo/DetailRouteInfoBottomView.swift @@ -377,15 +377,19 @@ extension DetailRouteInfoBottomView { } } - func updateProximityHighlight(nearLegIDs: Set) { + func updateProximityHighlight(nearLegIDs: Set, departedLegIDs: Set = []) { let actualNearIDs = isAlarmFired ? nearLegIDs : [] + let actualDepartedIDs = isAlarmFired ? departedLegIDs : [] currentNearLegIDs = actualNearIDs for cell in collectionView.visibleCells { if let busCell = cell as? DetailRouteBusCell, let leg = busCell.currentLegTrafficInfo { + + let hasDeparted = actualDepartedIDs.contains(leg.id) + if actualNearIDs.contains(leg.id) { - busCell.isNowUserLocationArrived() + busCell.isNowUserLocationArrived(hasDeparted: hasDeparted) } else { busCell.stopArrivedEffectIfNeeded() } @@ -393,8 +397,10 @@ extension DetailRouteInfoBottomView { if let subwayCell = cell as? DetailRouteSubwayCell, let leg = subwayCell.currentLegTrafficInfo { + let hasDeparted = actualDepartedIDs.contains(leg.id) + if actualNearIDs.contains(leg.id) { - subwayCell.isNowUserLocationArrived() + subwayCell.isNowUserLocationArrived(hasDeparted: hasDeparted) } else { subwayCell.stopArrivedEffectIfNeeded() } diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift index 7b3861d5..ce84370a 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewController.swift @@ -32,7 +32,6 @@ final class DetailRouteViewController: BaseViewController, private var isAlarmFired: Bool { UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false } - private var legPolylineById: [UUID: [CLLocationCoordinate2D]] = [:] override func viewDidLoad() { super.viewDidLoad() @@ -78,7 +77,7 @@ final class DetailRouteViewController: BaseViewController, } override func viewDidAppear(_ animated: Bool) { - AmplitudeManager.shared.trackScreen(.course_detail) + amp_track(.course_detail_view) } override func viewWillAppear(_ animated: Bool) { @@ -228,18 +227,28 @@ final class DetailRouteViewController: BaseViewController, .sink { [weak self] address in self?.bottomSheet.setupStartAddress(address) } .store(in: &cancellables) - viewModel.$nearLegIDs + // viewModel.$nearLegIDs + // .receive(on: RunLoop.main) + // .sink { [weak self] near in + // guard let self = self else { return } + // let idsToHighlight = self.isAlarmFired ? near : [] + // self.bottomSheet.updateProximityHighlight(nearLegIDs: idsToHighlight) + // } + // .store(in: &cancellables) + Publishers.CombineLatest(viewModel.$nearLegIDs, viewModel.$departedLegIDs) .receive(on: RunLoop.main) - .sink { [weak self] near in + .sink { [weak self] near, departed in guard let self = self else { return } - let idsToHighlight = self.isAlarmFired ? near : [] - self.bottomSheet.updateProximityHighlight(nearLegIDs: idsToHighlight) + let actualNear = self.isAlarmFired ? near : [] + let actualDeparted = self.isAlarmFired ? departed : [] + + self.bottomSheet.updateProximityHighlight(nearLegIDs: actualNear, departedLegIDs: actualDeparted) } .store(in: &cancellables) bottomSheet.onBusDetail = { [weak self] info in self?.viewModel.onBusDetail?(info) - AmplitudeManager.shared.track(.bus_detail_click) + self?.amp_track(.bus_detail_click) } bottomSheet.getNewBusRealTime = { [weak self] in @@ -253,68 +262,78 @@ final class DetailRouteViewController: BaseViewController, } .store(in: &cancellables) - Publishers.CombineLatest(viewModel.$legTrafficInfo, viewModel.$legtPathInfo) - .receive(on: DispatchQueue.global(qos: .userInitiated)) - .sink { [weak self] trafficInfos, pathInfos in - guard let self else { return } - guard !trafficInfos.isEmpty, !pathInfos.isEmpty else { return } - - var dict: [UUID: [CLLocationCoordinate2D]] = [:] - - for (traffic, path) in zip(trafficInfos, pathInfos) { - guard traffic.mode == path.mode else { continue } - - if let shape = path.passShape, !shape.isEmpty { - dict[traffic.id] = self.convertShapeToCoords(shape) - continue - } - - if let steps = path.step, !steps.isEmpty { - let merged = steps - .compactMap { $0.linestring } - .filter { !$0.isEmpty } - .joined(separator: " ") - - if !merged.isEmpty { - dict[traffic.id] = self.convertShapeToCoords(merged) - } - } - } - - DispatchQueue.main.async { [weak self] in - self?.legPolylineById = dict + viewModel.$isRefreshing + .receive(on: RunLoop.main) + .removeDuplicates() // 상태가 실제로 바뀔 때만 실행 + .sink { [weak self] refreshing in + // 데이터 로딩이 시작될 때(true) 버튼 애니메이션 실행 + if refreshing { + self?.refreshButton.start() } } .store(in: &cancellables) + + // Publishers.CombineLatest(viewModel.$legTrafficInfo, viewModel.$legtPathInfo) + // .receive(on: DispatchQueue.global(qos: .userInitiated)) + // .sink { [weak self] trafficInfos, pathInfos in + // guard let self else { return } + // guard !trafficInfos.isEmpty, !pathInfos.isEmpty else { return } + // + // var dict: [UUID: [CLLocationCoordinate2D]] = [:] + // + // for (traffic, path) in zip(trafficInfos, pathInfos) { + // guard traffic.mode == path.mode else { continue } + // + // if let shape = path.passShape, !shape.isEmpty { + // dict[traffic.id] = self.convertShapeToCoords(shape) + // continue + // } + // + // if let steps = path.step, !steps.isEmpty { + // let merged = steps + // .compactMap { $0.linestring } + // .filter { !$0.isEmpty } + // .joined(separator: " ") + // + // if !merged.isEmpty { + // dict[traffic.id] = self.convertShapeToCoords(merged) + // } + // } + // } + // + // DispatchQueue.main.async { [weak self] in + // self?.legPolylineById = dict + // } + // } + // .store(in: &cancellables) } private func bindFollowLogic() { // 위치 viewModel.$currentLocation - .compactMap { $0 } .receive(on: RunLoop.main) .sink { [weak self] coord in guard let self else { return } + guard let unwrappedCoord = coord else { return } + // 1) 유저 마커 업데이트 (메인) let isAlarmRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false - self.mapContainerView.updateUserMarker(location: coord, isRegistered: isAlarmRegistered) + self.mapContainerView.updateUserMarker(location: unwrappedCoord, isRegistered: isAlarmRegistered) + - // 2) 지도 follow 로직 (메인) - if self.isAlarmFired { - // 알람 울린 후엔 계속 따라감 - self.mapContainerView.setupZoomCenter(location: coord) - } else if self.isFollowingUser { - // 알람 전: following 켰을 때만 따라감 - self.mapContainerView.setupZoomCenter(location: coord) + if self.isFollowingUser { + self.mapContainerView.setupZoomCenter(location: unwrappedCoord) + } else if self.shouldCenterToCurrentLocationOnce { + self.mapContainerView.setupZoomCenter(location: unwrappedCoord) + self.shouldCenterToCurrentLocationOnce = false } // else: fit 유지 (건드리지 않음) // 3) 근처(150m) 지나가면 반짝임 계산 (백그라운드) let threshold: CLLocationDistance = 150 - - let polylines = self.legPolylineById - let orderedLegs = self.viewModel.legTrafficInfo // 화면 표시 순서(위→아래) + let polylines = self.viewModel.legPolylineById + let orderedLegs = self.viewModel.legTrafficInfo DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self else { return } @@ -323,7 +342,7 @@ final class DetailRouteViewController: BaseViewController, var nearCandidates = Set() for (id, polyline) in polylines { - let d = self.distanceToPolylineMeters(point: coord, polyline: polyline) + let d = self.distanceToPolylineMeters(point: unwrappedCoord, polyline: polyline) if d <= threshold { nearCandidates.insert(id) } @@ -349,7 +368,7 @@ final class DetailRouteViewController: BaseViewController, .receive(on: RunLoop.main) .sink { [weak self] heading in guard let self else { return } - guard isFollowingUser || isAlarmFired else { return } + guard isFollowingUser else { return } mapContainerView.setHeading(heading) } .store(in: &cancellables) @@ -521,10 +540,9 @@ final class DetailRouteViewController: BaseViewController, self.viewModel.getAlarmTapped?(self.viewModel.address, self.viewModel.infos) let dwellSeconds = AmplitudeManager.shared.timerEndSeconds("alarm_dwell") - AmplitudeManager.shared.track( - .another_alarm_register, - props(AmplitudeProperty.dwellTime(seconds: dwellSeconds)) - ) + self.amp_track(.alarm_register, properties: props( + AmplitudeProperty.dwellTime(seconds: dwellSeconds) + )) // 등록 완료 후 메인 지도로 이동 (필요시 호출) self.navigationController?.popToMainViewControllerNoAnimation() @@ -540,16 +558,15 @@ final class DetailRouteViewController: BaseViewController, viewModel.startHeading() lastRouteFitApplied = true // 알람 시에는 Fit 방지 - // ✅ 1. 지도의 크기(제약 조건)를 여기서 먼저 결정 + // 1. 지도의 크기(제약 조건)를 여기서 먼저 결정 mapContainerView.snp.remakeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalToSuperview() make.bottom.equalToSuperview().inset(200) } - // ✅ 2. 좌표가 있다면 '애니메이션 없이' 즉시 현위치로 이동 + // 2. 좌표가 있다면 '애니메이션 없이' 즉시 현위치로 이동 if let currentCoord = viewModel.currentLocation { - mapContainerView.setupCenter(location: currentCoord) // setupZoomCenter 대신 setupCenter(이동만) mapContainerView.setupZoomCenter(location: currentCoord) // 필요 시 줌까지 } } else { @@ -586,6 +603,8 @@ extension DetailRouteViewController { @objc private func didTapLocationButton() { ensureLocationPermissionOrShowToast() + viewModel.forceLocationSnap() + isFollowingUser = true shouldCenterToCurrentLocationOnce = true viewModel.startHeading() @@ -597,6 +616,8 @@ extension DetailRouteViewController { make.top.equalToSuperview() make.bottom.equalToSuperview().inset(200) } + + amp_track(.current_location_click) } @objc private func didTapReload() { @@ -611,7 +632,7 @@ extension DetailRouteViewController { } viewModel.fetchInfo() - AmplitudeManager.shared.track(.course_refresh_click) + amp_track(.course_refresh_click) } } @@ -625,7 +646,6 @@ extension DetailRouteViewController { if isAlarmFired { if let currentCoord = viewModel.currentLocation { // 애니메이션 없이 즉시 이동하여 '깜빡임' 방지 - mapContainerView.setupCenter(location: currentCoord) mapContainerView.setupZoomCenter(location: currentCoord) } } @@ -655,12 +675,10 @@ extension DetailRouteViewController { UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.popRegister.rawValue) let dwellSeconds = AmplitudeManager.shared.timerEndSeconds("alarm_dwell") - AmplitudeManager.shared.track( - .another_alarm_register, - props( - AmplitudeProperty.dwellTime(seconds: dwellSeconds) - ) - ) + self.amp_track(.long_interval_alarm_register, properties: props( + AmplitudeProperty.dwellTime(seconds: dwellSeconds) + )) + }, for: .touchUpInside) popupVC.cancelButton.addAction(UIAction { [weak self, weak popupVC] _ in @@ -684,12 +702,10 @@ extension DetailRouteViewController { UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.popRegister.rawValue) let dwellSeconds = AmplitudeManager.shared.timerEndSeconds("alarm_dwell") - AmplitudeManager.shared.track( - .another_alarm_register, - props( - AmplitudeProperty.dwellTime(seconds: dwellSeconds) - ) - ) + self.amp_track(.another_alarm_register, properties: props( + AmplitudeProperty.dwellTime(seconds: dwellSeconds) + )) + }, for: .touchUpInside) popupVC.cancelButton.addAction(UIAction { [weak self, weak popupVC] _ in diff --git a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift index d043f990..f1d25b8a 100644 --- a/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift +++ b/Atcha-iOS/Presentation/Location/DetailRoute/DetailRouteViewModel.swift @@ -8,6 +8,7 @@ import Foundation import UIKit import CoreLocation +import MapKit enum DetailRouteContext { case beforeRegister @@ -36,23 +37,36 @@ final class DetailRouteViewModel: BaseViewModel { @Published var busRealTimeInfos: [[RealTimeBusArrival]] = [] private var busRoutes: [String] = [] private var busRealTimeMap: [String: [RealTimeBusArrival]] = [:] - private var busPollingTask: Task? @Published var subwayRealTimeInfos: [SubwayRealTimeInfo] = [] private var subwayRoutes: [String] = [] - private var subwayPollingTask: Task? @Published private(set) var context: DetailRouteContext @Published var nearLegIDs: Set = [] + @Published var departedLegIDs: Set = [] @Published var deviceHeading: CLLocationDirection? private let headingManager = HeadingManager() - -//#if DEBUG -//@Published var mockLocation: CLLocationCoordinate2D? = nil -//#endif - + private let smoother = LocationSmoother(limit: 5) + @Published var legPolylineById: [UUID: [CLLocationCoordinate2D]] = [:] + + private var pollingTask: Task? + @Published var isRefreshing: Bool = false + + private var lastValidTime: Date? = nil + 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 + init(address: String, infos: LegInfo, context: DetailRouteContext, @@ -78,42 +92,40 @@ final class DetailRouteViewModel: BaseViewModel { func fetchInfo() { legtPathInfo = infos.pathInfo legTrafficInfo = infos.trafficInfo - print("위치: \(legtPathInfo)") + + var dict: [UUID: [CLLocationCoordinate2D]] = [:] + for (traffic, path) in zip(legTrafficInfo, legtPathInfo) { + if let shape = path.passShape, !shape.isEmpty { + dict[traffic.id] = self.convertShapeToCoords(shape) + continue + } + if let steps = path.step, !steps.isEmpty { + let merged = steps.compactMap { $0.linestring }.filter { !$0.isEmpty }.joined(separator: " ") + if !merged.isEmpty { + dict[traffic.id] = self.convertShapeToCoords(merged) + } + } + } + self.legPolylineById = dict guard context == .afterReigster else { return } - // 버스 + + setupRoutes() + startPolling() + } + + private func setupRoutes() { let routes = infos.busInfo .compactMap { $0.routeName } .filter { !$0.isEmpty && $0.contains(":") } + busRoutes = Array(Set(routes)) - busRoutes = Array(Set(routes)) // 중복 제거 - busRealTimeMap.removeAll() - busRealTimeInfos = [] - - // 최초 1회 로드 - Task { [weak self] in - await self?.refreshAllBusRealTime() - } - - // 15초 폴링 시작 - startBusPolling() - - let subwayRoutes = Array(Set( + subwayRoutes = Array(Set( infos.trafficInfo .filter { $0.mode == .subway } .compactMap { $0.route } .filter { !$0.isEmpty } )) - - self.subwayRoutes = subwayRoutes - // 여기서 removeAll 하면 첫 표시가 비었다가 생길 수 있음. - // "최초 진입 때만 비우고", 폴링에서는 기존 유지가 더 안정적. - self.subwayRealTimeInfos = [] - - Task { [weak self] in - await self?.refreshAllSubwayRealTime() - } - startSubwayPolling() } @MainActor @@ -129,6 +141,55 @@ final class DetailRouteViewModel: BaseViewModel { } } + @MainActor + func refreshAllRealTimeData() async { + guard !busRoutes.isEmpty || !subwayRoutes.isEmpty else { return } + + isRefreshing = true // 애니메이션 시작 신호 + + // async let을 사용하여 버스와 지하철 정보를 동시에(병렬) 요청함 (속도 최적화) + async let refreshBus: () = refreshAllBusRealTime() + async let refreshSubway: () = refreshAllSubwayRealTime() + + _ = await [refreshBus, refreshSubway] + + // 애니메이션이 시각적으로 잘 보이도록 최소 0.5초 대기 후 종료 + try? await Task.sleep(nanoseconds: 500_000_000) + isRefreshing = false + } + + private func startPolling() { + stopPolling() + + pollingTask = Task { [weak self] in + guard let self = self else { return } + + // 1. 진입 시 최초 1회 즉시 실행 + await self.refreshAllRealTimeData() + + while !Task.isCancelled { + // 2. 15초 대기 + try? await Task.sleep(nanoseconds: 15_000_000_000) + + // 3. 루프 중간에 취소 여부 및 알람 등록 상태 재확인 + let isRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false + if !isRegistered || Task.isCancelled { + self.stopPolling() + break + } + + // 4. 통합 데이터 새로고침 실행 + await self.refreshAllRealTimeData() + } + } + } + + func stopPolling() { + pollingTask?.cancel() + pollingTask = nil + } + + @MainActor func getSubwayRealTimeInfo(routeName: String) async { do { @@ -141,74 +202,107 @@ final class DetailRouteViewModel: BaseViewModel { } func requestPermissionAndStartTracking() { - Task { - let status = await authorizationUseCase.askLocationPermission() - guard status == .authorizedAlways || status == .authorizedWhenInUse else { return } - - streamTask?.cancel() - streamTask = Task { - for await location in streamUseCase.startUpdate() { - let coord = CLLocationCoordinate2D( - latitude: location.coordinate.latitude, - longitude: location.coordinate.longitude - ) - await MainActor.run { self.currentLocation = coord } + Task { + let status = await authorizationUseCase.askLocationPermission() + guard status == .authorizedAlways || status == .authorizedWhenInUse else { return } + + streamTask?.cancel() + streamTask = Task { + for await location in streamUseCase.startUpdate() { + 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: CLLocationAccuracy + + if isInitialTracking { + accuracyThreshold = 1000.0 // 시청 탈출용 널널한 기준 + } else { + accuracyThreshold = isRecovering ? 300.0 : 150.0 // 회원님의 지하철 복구 로직 유지! } + + guard location.horizontalAccuracy < accuracyThreshold else { + self.consecutiveValidCount = 0 + continue + } + + if isRecovering { + // 지상 탈출이 의심될 때: 바로 안 믿고 카운터를 올립니다. + self.consecutiveValidCount += 1 + + let requiredCount = isInitialTracking ? 1 : 3 + + if self.consecutiveValidCount >= requiredCount { + // 3번 연속(약 3초) 정상 신호가 들어왔다? 이건 100% 진짜 지상이다! + smoother.reset(location.coordinate) + + // 리셋했으니 이제 평상시 상태로 복구 + self.lastValidTime = now + self.consecutiveValidCount = 0 + } else { + // 아직 1~2번만 들어왔으면 마커를 움직이지 않고 무시 (가짜일 수 있으므로 대기) + continue + } + } else { + // 평상시 (지상에서 잘 걸어 다니고 있을 때) + self.lastValidTime = now + self.consecutiveValidCount = 0 // 평소엔 카운터 필요 없음 + } + + // 항상 Smoothing 적용 + let smoothedCoord = smoother.smooth(location.coordinate) + + var finalCoord = smoothedCoord + + // 알람이 울린 상태라면 스냅 적용 + let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false + + if isAlarmFired && !legtPathInfo.isEmpty { + let allCoords = legtPathInfo.flatMap { convertShapeToCoords($0.passShape ?? "") } + finalCoord = smoother.snap(current: smoothedCoord, polyline: allCoords) + } + + let capturedCoord = finalCoord + + await MainActor.run { + self.currentLocation = capturedCoord + if !self.didSendInitialLocation { + self.didSendInitialLocation = true + } + HomeArrivalManager.shared.checkHomeArrival(currentCoord: capturedCoord) + } + + self.calculateProximity(coord: finalCoord) } } } + } func startHeading() { - headingManager.onHeading = { [weak self] h in - DispatchQueue.main.async { self?.deviceHeading = h } - } - headingManager.start() - } - - func stopHeading() { - headingManager.stop() - } - - func stopTracking() { - streamTask?.cancel() - streamUseCase.stopUpdate() - headingManager.stop() - } - - deinit { - stopTracking() - stopBusPolling() - stopSubwayPolling() + headingManager.onHeading = { [weak self] h in + DispatchQueue.main.async { self?.deviceHeading = h } } + headingManager.start() + } + + func stopHeading() { + headingManager.stop() + } + + func stopTracking() { + streamTask?.cancel() + streamUseCase.stopUpdate() + headingManager.stop() + } + + deinit { + stopTracking() + stopPolling() + } -// func requestPermissionAndStartTracking() { -// Task { -// let status = await authorizationUseCase.askLocationPermission() -// guard status == .authorizedAlways || status == .authorizedWhenInUse else { return } -// -// streamTask = Task { [weak self] in -// guard let self else { return } -// -// for await location in streamUseCase.startUpdate() { -// let real = CLLocationCoordinate2D( -// latitude: location.coordinate.latitude, -// longitude: location.coordinate.longitude -// ) -// -// #if DEBUG -// // ✅ 디버그에선 mock 있으면 그걸로 덮어씀 -// if let mock = self.mockLocation { -// self.currentLocation = mock -// } else { -// self.currentLocation = real -// } -// #else -// self.currentLocation = real -// #endif -// } -// } -// } -// } func setupLocation() { requestPermissionAndStartTracking() @@ -231,24 +325,6 @@ final class DetailRouteViewModel: BaseViewModel { } } - private func startBusPolling() { - stopBusPolling() - - busPollingTask = Task { [weak self] in - guard let self else { return } - - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 15_000_000_000) - if Task.isCancelled { break } - await self.refreshAllBusRealTime() - } - } - } - - private func stopBusPolling() { - busPollingTask?.cancel() - busPollingTask = nil - } @MainActor private func refreshAllBusRealTime() async { @@ -268,26 +344,6 @@ final class DetailRouteViewModel: BaseViewModel { } // MARK: - Subway Polling - - private func startSubwayPolling() { - stopSubwayPolling() - - subwayPollingTask = Task { [weak self] in - guard let self else { return } - - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 15_000_000_000) - if Task.isCancelled { break } - await self.refreshAllSubwayRealTime() - } - } - } - - private func stopSubwayPolling() { - subwayPollingTask?.cancel() - subwayPollingTask = nil - } - @MainActor private func refreshAllSubwayRealTime() async { guard !subwayRoutes.isEmpty else { return } @@ -306,3 +362,99 @@ final class DetailRouteViewModel: BaseViewModel { } } } + +extension DetailRouteViewModel { + private func convertShapeToCoords(_ shape: String) -> [CLLocationCoordinate2D] { + shape.split(separator: " ").compactMap { pair in + let parts = pair.split(separator: ",") + guard parts.count == 2, + let lon = Double(parts[0]), + let lat = Double(parts[1]) else { return nil } + return CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + } +} + +extension DetailRouteViewModel { + /// 현재 좌표를 기준으로 가장 가까운 경로를 찾아 nearLegIDs를 업데이트합니다. + func calculateProximity(coord: CLLocationCoordinate2D?) { + guard let coord = coord else { return } + + let threshold: CLLocationDistance = 150 + let polylines = self.legPolylineById + let orderedLegs = self.legTrafficInfo + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { return } + var nearCandidates = Set() + + for (id, polyline) in polylines { + let d = self.distanceToPolylineMeters(point: coord, polyline: polyline) + if d <= threshold { + nearCandidates.insert(id) + } + } + + var picked: Set = [] + if let first = orderedLegs.first(where: { nearCandidates.contains($0.id) })?.id { + picked = [first] + } + + var departed: Set = [] + if let activeID = picked.first, + let leg = orderedLegs.first(where: { $0.id == activeID }), + let firstStop = leg.passStopList?.first, + let latStr = firstStop.lat, let lat = Double(latStr), + let lonStr = firstStop.lon, let lon = Double(lonStr) { + + let startLoc = CLLocation(latitude: lat, longitude: lon) + let currentLoc = CLLocation(latitude: coord.latitude, longitude: coord.longitude) + + // 첫 정류장에서 300m 이상 벗어났다면 '출발(탑승)'한 것으로 간주 + if currentLoc.distance(from: startLoc) > 300 { + departed.insert(activeID) + } + } + + DispatchQueue.main.async { + self.nearLegIDs = picked + self.departedLegIDs = departed + } + } + } + + // 뷰컨트롤러에서 가져온 거리 계산 함수 + private func distanceToPolylineMeters( + point: CLLocationCoordinate2D, + polyline: [CLLocationCoordinate2D] + ) -> CLLocationDistance { + guard polyline.count >= 2 else { return .greatestFiniteMagnitude } + + let p = MKMapPoint(point) + var best = CLLocationDistance.greatestFiniteMagnitude + + for i in 0..<(polyline.count - 1) { + let a = MKMapPoint(polyline[i]) + let b = MKMapPoint(polyline[i + 1]) + + let abx = b.x - a.x + let aby = b.y - a.y + let apx = p.x - a.x + let apy = p.y - a.y + + let ab2 = abx*abx + aby*aby + if ab2 == 0 { + best = min(best, p.distance(to: a)) + continue + } + + var t = (apx*abx + apy*aby) / ab2 + t = max(0, min(1, t)) + + let closest = MKMapPoint(x: a.x + t*abx, y: a.y + t*aby) + best = min(best, p.distance(to: closest)) + } + + return best + } +} diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 3f8a08bb..027df1e6 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,19 +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 @@ -126,7 +106,6 @@ final class MainViewController: BaseViewController, setupUI() setupAutoLayout() - // installMapUserGestureDetector() mapContainerView.onUserInteraction = { [weak self] in self?.stopFollowingOnUserInteraction() } @@ -150,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,10 +144,17 @@ final class MainViewController: BaseViewController, } } else if isAlarmRegistered && isAlarmFired { - // 3. 알람 울리고 나서는 계속 따라가고 회전 + // 3. 알람 울린 후 (현위치 추적 모드) self.mapContainerView.afterUserMarker() self.isFollowingUser = true self.viewModel.startHeading() + + if let currentCoord = self.viewModel.currentLocation { + self.mapContainerView.setupCenter(location: currentCoord) + self.shouldCenterToCurrentLocationOnce = false + } else { + self.shouldCenterToCurrentLocationOnce = true + } } } } @@ -177,21 +163,40 @@ 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 + ) + } } - AmplitudeManager.shared.trackScreen(.main) + self.viewModel.refreshCurrentMapCenterData() + + let isGuest = UserDefaultsWrapper.shared.bool( + forKey: UserDefaultsWrapper.Key.isGuest.rawValue + ) ?? false + + if isGuest { + amp_track(.main_view, properties: props(AmplitudeProperty.userStatus(.guest))) + } else { + amp_track(.main_view, properties: props(AmplitudeProperty.userStatus(.member))) + } } override func viewWillDisappear(_ animated: Bool) { @@ -302,10 +307,11 @@ extension MainViewController { bindTaxiFareUpdates() bindServiceRegionUpdates() bindLockView() - bindAlarmTimeoutView() bindDeviceHeadingUpdates() bindPermissionAlert() bindAlarmFireStatus() + observeArrival() + observeAlarmTimeout() } private func bindPermissionAlert() { @@ -314,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) } @@ -344,24 +350,19 @@ 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 } + 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) } @@ -375,22 +376,27 @@ extension MainViewController { private func handleSearchViewAction(_ action: LastTrainSearchBottomView.Action) { switch action { + case .homeChangeTapped: + if viewModel.isGuest { + presentLoginAlert() + amp_track(.login_view, properties: props(AmplitudeProperty.entryPoint(.home_modify))) + } else { + viewModel.handleRoute(route: .changeHome) + } case .currentTapped: if viewModel.isGuest { presentLoginAlert() + amp_track(.login_view, properties: props(AmplitudeProperty.entryPoint(.departure))) } else { - AmplitudeManager.shared.track(.origin_search_click) - viewModel.handleRoute(route: .changeCourse( location: Location(name: "", lat: 0.0, lon: 0.0, businessCategory: "", address: "", radius: ""))) } case .searchTapped: if viewModel.isGuest { presentLoginAlert() + amp_track(.login_view, properties: props(AmplitudeProperty.entryPoint(.course_search))) } else { - AmplitudeManager.shared.track(.course_search_click) - - guard let startCoord = viewModel.currentLocation else { + guard let startCoord = mapContainerView.tMapWrapper.mapView.getCenter() else { view.showToast(message: "현재 위치를 확인 중이에요. 잠시 후 다시 시도해 주세요.") return } @@ -411,7 +417,7 @@ extension MainViewController { } viewModel.handleRoute(route: .courseSearch( - startLat: "", startLon: "", startAddress: "" + startLat: String(startCoord.latitude), startLon: String(startCoord.longitude), startAddress: "" )) } } @@ -429,17 +435,15 @@ extension MainViewController { ) case .locationTapped: - self.showOrUpdateImmediateBalloon( - .text(top: nil, bottom: "위치를 변경하려면 알람을 종료해야 해요") - ) - AmplitudeManager.shared.track(.course_click) + showTransientBalloon(isFare: false, text: "위치를 변경하려면 알람을 종료해야 해요") + amp_track(.course_click) + case .reloadTapped: viewModel.refreshDepatrueTime() + case .timeTapped: - self.showOrUpdateImmediateBalloon( - .text(top: "이때 자리에서 출발하면 돼요", bottom: "교통 상황에 따라 시간이 달라질 수 있어요") - ) - AmplitudeManager.shared.track(.origin_time_click) + showSequentialBalloons() + amp_track(.departure_time_click) } } @@ -451,7 +455,6 @@ extension MainViewController { guard let self else { return } popupVC?.dismiss(animated: false) AlarmManager.shared.alarmInit() - self.viewModel.showAlarmStopPopUpView = false }, for: .touchUpInside) popupVC.modalPresentationStyle = .overFullScreen @@ -463,7 +466,7 @@ extension MainViewController { let popupVC = AtchaPopupViewController(viewModel: popupVM) popupVC.cancelButton.addAction(UIAction { [weak popupVC] _ in - popupVC?.dismiss(animated: true) + popupVC?.dismiss(animated: false) }, for: .touchUpInside) popupVC.confirmButton.addAction(UIAction { [weak self, weak popupVC] _ in @@ -472,6 +475,8 @@ extension MainViewController { self.viewModel.alarmDelete() self.exitButtonTapped() + + amp_track(.alarm_force_stop) }, for: .touchUpInside) popupVC.modalPresentationStyle = .overFullScreen @@ -479,87 +484,82 @@ 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 coord = viewModel.selectedLocation ?? viewModel.currentLocation { - mapContainerView.setupCenter(location: coord) + if let currentCoord = viewModel.currentLocation { + mapContainerView.setupCenter(location: 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 + ) } } // MARK: - ViewModel Bindings private func bindCurrentLocationUpdates() { - viewModel.$currentLocation - .removeDuplicates() - .compactMap { $0 } - .receive(on: RunLoop.main) - .sink { [weak self] coord in - guard let self = self else { return } - - // 1. 파란색 내 위치 마커는 무조건 실시간 업데이트 - - let isAlarmRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false - let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false - self.mapContainerView.updateUserMarker(location: coord, isRegistered: isAlarmRegistered) - // 2. 알람이 울린 상태면 무조건 강제로 센터 유지 - if isAlarmRegistered && isAlarmFired { - self.isFollowingUser = true - self.viewModel.startHeading() - self.mapContainerView.setupCenter(location: coord) - return - } - - // 3. 앱 최초 진입이거나, 내가 현위치 버튼을 눌러서 '추적 모드'일 때만 카메라 중심 이동 - if self.shouldCenterToCurrentLocationOnce || self.isFollowingUser { - self.mapContainerView.setupCenter(location: coord) - self.shouldCenterToCurrentLocationOnce = false - } - } - .store(in: &cancellables) - } - - private func bindSelectedLocationUpdates() { - viewModel.$selectedLocation - .removeDuplicates() - .compactMap { $0 } - .receive(on: RunLoop.main) - .sink { _ in - // 👉 뷰모델에서 알아서 주소를 검색하므로 뷰컨트롤러는 카메라를 건드리지 않음! + viewModel.$currentLocation + .removeDuplicates() + .compactMap { $0 } + .receive(on: RunLoop.main) + .sink { [weak self] coord in + guard let self = self else { return } + + let isAlarmRegistered = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.alarmRegister.rawValue) ?? false + self.mapContainerView.updateUserMarker(location: coord, isRegistered: isAlarmRegistered) + + if self.isFollowingUser { + self.mapContainerView.setupCenter(location: coord) + } else if self.shouldCenterToCurrentLocationOnce { + self.mapContainerView.setupCenter(location: coord) + self.shouldCenterToCurrentLocationOnce = false } - .store(in: &cancellables) - } + } + .store(in: &cancellables) + } + + private func bindSelectedLocationUpdates() { + viewModel.$selectedLocation + .removeDuplicates() + .compactMap { $0 } + .receive(on: RunLoop.main) + .sink { _ in } + .store(in: &cancellables) + } private func bindDeviceHeadingUpdates() { viewModel.$deviceHeading @@ -577,25 +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.showOrUpdatePreBalloon( - .text( - top: (self.preSessionShowTopLine ?? true) ? "지도를 움직여 출발지를 설정해요" : nil, - bottom: "서울, 경기, 인천 내에서만 사용할 수 있어요" - ) - ) - } - } else { - self.showInitialPreAlarmBalloons(force: false) - } } .store(in: &cancellables) } @@ -654,85 +641,62 @@ extension MainViewController { .store(in: &cancellables) } - private func bindAlarmTimeoutView() { - viewModel.$showAlarmStopPopUpView - .receive(on: RunLoop.main) - .sink { [weak self] show in - guard let self, show else { return } - self.viewModel.alarmDelete() - self.exitButtonTapped() - - guard self.presentedViewController == nil else { return } - self.showAlarmTimeoutPopup() - self.viewModel.showAlarmStopPopUpView = false - } - .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: @@ -744,127 +708,63 @@ 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 { - DispatchQueue.main.asyncAfter(deadline: .now() + popToastDelay) { - self.view.showToast(message: "알람이 등록되었습니다.") - 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, let fare = fare else { return } - - let fareInt = Int(fare) - let fareStr = self.decimalFormatter.string(from: NSNumber(value: fareInt)) ?? "\(fareInt)" - self.latestFareString = fareStr + guard let self = self else { return } - if self.isPreAlarmBalloonActive(), self.latestIsServiceRegion == true { - // 이제 파라미터로 들어오는 최신 isGuest 상태에 따라 ??? 혹은 금액이 결정됩니다. - let displayFare = isGuest ? "???원" : "\(fareStr)원" - let content: BalloonContent = .separation( - gray: "여기서 막차 놓치면 택시비 ", white: "약 \(displayFare)" - ) - - if self.ballonView.isHidden { - self.showOrUpdatePreBalloon(content, showTopLine: self.preSessionShowTopLine ?? true) - } else { - self.updatePreBalloonContent(content, showTopLine: self.preSessionShowTopLine ?? true) - } + if let fare = fare { + let fareInt = Int(fare) + self.latestFareString = self.decimalFormatter.string(from: NSNumber(value: fareInt)) ?? "\(fareInt)" + } else { + self.latestFareString = nil } - // 알람 등록 후 말풍선 큐 갱신 - if !self.postAlarmMessages.isEmpty { - let displayFare = isGuest ? "???원" : "\(fareStr)원" - self.postAlarmMessages[self.postAlarmMessages.count - 1] = - .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(displayFare)") + // 검색 모드일 때는 즉시 말풍선 글자 업데이트 + if self.viewModel.bottomType == .search || self.viewModel.bottomType == nil { + // 방해물(업데이트 생략 조건문) 제거! 이제 무조건 뷰를 업데이트합니다. + self.showOrUpdatePersistentBalloon( + isFirstVisit: self.isFirstVisit, + isServiceRegion: self.latestIsServiceRegion, + fareStr: self.latestFareString + ) } } .store(in: &cancellables) } private func bindServiceRegionUpdates() { - viewModel.$isServiceRegion - .removeDuplicates() - .receive(on: RunLoop.main) - .sink { [weak self] ok in - guard let self = self else { return } - let previous = self.latestIsServiceRegion - self.latestIsServiceRegion = ok - - 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.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) - } + viewModel.$isServiceRegion + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] ok in + guard let self = self else { return } + self.latestIsServiceRegion = ok + + switch ok { + case .some(true): + self.lastTrainSearchView.updateSearchEnabled(true) + case .some(false): + self.latestFareString = nil + self.viewModel.taxiFare = nil + self.lastTrainSearchView.updateSearchEnabled(false) + case .none: + self.lastTrainSearchView.updateSearchEnabled(false) } - .store(in: &cancellables) - } + + // 검색 모드일 때는 즉시 말풍선 글자 업데이트 + if self.viewModel.bottomType == .search || self.viewModel.bottomType == nil { + // 방해물(업데이트 생략 조건문) 제거! + self.showOrUpdatePersistentBalloon( + isFirstVisit: self.isFirstVisit, + isServiceRegion: ok, + fareStr: self.latestFareString + ) + } + } + .store(in: &cancellables) + } // MARK: - Constraint Helper private func updateAtchaImageConstraint(relativeTo view: UIView) { @@ -967,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 @@ -990,26 +888,31 @@ extension MainViewController { @objc private func didTapMyPageButton() { if viewModel.isGuest { presentLoginAlert() + amp_track(.login_view, properties: props(AmplitudeProperty.entryPoint(.mypage))) } else { viewModel.handleRoute(route: .myPage) + amp_track(.mypage_click) } } @objc private func didTapLocationButton() { - guard ensureLocationPermissionOrShowToast() else { return } - - isFollowingUser = true - viewModel.startHeading() - - // 🚨 수정: 무조건 내 "진짜 위치(currentLocation)"로 지도를 이동시킴 - if let coord = viewModel.currentLocation { - mapContainerView.setupCenter(location: coord) - viewModel.selectedLocation = coord // 주소도 현위치로 다시 검색하게 덮어씀 - } else { - shouldCenterToCurrentLocationOnce = true - viewModel.setupLocation() - } + guard ensureLocationPermissionOrShowToast() else { return } + + viewModel.forceLocationSnap() + + isFollowingUser = true + viewModel.startHeading() + + if let coord = viewModel.currentLocation { + mapContainerView.setupCenter(location: coord) + viewModel.selectedLocation = coord + } else { + shouldCenterToCurrentLocationOnce = true + viewModel.setupLocation() } + + amp_track(.current_location_click) + } private func safeStartJump() { let now = CACurrentMediaTime() @@ -1020,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 scope = lastShownScope + let cycle = postAlarmTapIndex % 3 - switch scope { - case .pre: - print("") - case .next: - AmplitudeManager.shared.track(.character_click) + // 추가: 현재 알람이 울린 상태인지 확인 + let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false + + 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 - } - - // 요금이 있고 서비스 지역일 때 - let displayFare = viewModel.isGuest ? "???원" : "\(fare)원" - showOrUpdatePreBalloon( - .separation(gray: "여기서 막차 놓치면 택시비 ", white: "약 \(displayFare)"), - delay: d1, animated: true, showTopLine: showTopLine - ) - + } else if cycle == 1 { + // 수정: 알람이 울렸다면 이 메시지를 건너뛰고 다음 메시지를 띄움 + if isAlarmFired { + showTransientBalloon(isFare: false, text: "교통 상황에 따라 시간이 달라질 수 있어요") + // cycle 1을 건너뛰었으므로 다음 탭이 cycle 0(택시비)으로 돌아가도록 index를 2 올려줌 + postAlarmTapIndex += 2 } 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 + } else { + showTransientBalloon(isFare: false, text: "교통 상황에 따라 시간이 달라질 수 있어요") + postAlarmTapIndex += 1 } - - // 즉시 표시(터치 등): 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 } @@ -1357,22 +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() { - let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false - - // 3. 알람이 울린 후라면 지도를 터치해도 계속 따라가도록 무시 - if isAlarmFired { - return - } - - // 1, 2. 평상시엔 지도를 조작하면 추적과 회전을 중지 if isFollowingUser { isFollowingUser = false shouldCenterToCurrentLocationOnce = false @@ -1394,15 +1048,184 @@ 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) } } +// MARK: - 도착 자동 종료 처리 +extension MainViewController { + private func observeArrival() { + NotificationCenter.default.publisher(for: NSNotification.Name("userArrivedHome")) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self = self else { return } + + self.navigationController?.popToRootViewController(animated: true) + self.viewModel.alarmDelete() + self.exitButtonTapped() + + amp_track(.alarm_arrive_stop) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.showArrivalPopup() + } + } + .store(in: &cancellables) + } + + private func showArrivalPopup() { + if presentedViewController is AtchaPopupViewController { return } + + let popupVM = AtchaPopupViewModel(info: .arrive) + let popupVC = AtchaPopupViewController(viewModel: popupVM) + + popupVC.modalPresentationStyle = .overFullScreen + popupVC.modalTransitionStyle = .crossDissolve + + popupVC.confirmButton.addAction(UIAction { [weak popupVC] _ in + popupVC?.dismiss(animated: false) + HomeArrivalManager.shared.reset() + }, for: .touchUpInside) + + self.present(popupVC, animated: false) + } + + private func observeAlarmTimeout() { + NotificationCenter.default.publisher(for: NSNotification.Name("alarmDidTimeout")) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self = self else { return } + + self.navigationController?.popToRootViewController(animated: true) + self.viewModel.alarmDelete() + self.exitButtonTapped() + + amp_track(.alarm_timeout_stop) + + if let coord = self.viewModel.currentLocation { + self.mapContainerView.setupCenter(location: coord) + self.viewModel.selectedLocation = coord + } else { + self.viewModel.setupLocation() + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.showAlarmTimeoutPopup() + } + } + .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 1ccf84df..afe1fcbd 100644 --- a/Atcha-iOS/Presentation/Location/MainViewModel.swift +++ b/Atcha-iOS/Presentation/Location/MainViewModel.swift @@ -32,7 +32,6 @@ final class MainViewModel: BaseViewModel{ @Published var bottomType: MapBottomType? @Published var showLockView: Bool = false - @Published var showAlarmStopPopUpView: Bool = false @Published var departureStr: String? // @Published var currentCourse: CLLocationDirection? @@ -56,6 +55,10 @@ final class MainViewModel: BaseViewModel{ @Published var showLocationDeniedAlert: Bool = false @Published var isGuest: Bool = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.isGuest.rawValue) ?? false + private var didSendInitialLocation = false + private let smoother = LocationSmoother(limit: 5) + private var lastValidTime: Date? = nil + private var consecutiveValidCount = 0 init(authorizationUseCase: RequestLocationAuthorizationUseCase, streamUseCase: ObserveLocationStreamUseCase, @@ -94,7 +97,10 @@ final class MainViewModel: BaseViewModel{ .debounce(for: .seconds(0.3), scheduler: RunLoop.main) .sink { [weak self] loc in guard let self = self else { return } - Task { await self.updateAddressOnly(for: loc) } + Task { + await self.updateAddressOnly(for: loc) + await self.refreshRegionAndFareForCurrentAddress() + } } .store(in: &cancellables) $isGuest @@ -105,13 +111,13 @@ final class MainViewModel: BaseViewModel{ } .store(in: &cancellables) - $address - .compactMap { $0 } - .removeDuplicates() - .sink { [weak self] _ in - Task { await self?.refreshRegionAndFareForCurrentAddress() } - } - .store(in: &cancellables) + // $address + // .compactMap { $0 } + // .removeDuplicates() + // .sink { [weak self] _ in + // Task { await self?.refreshRegionAndFareForCurrentAddress() } + // } + // .store(in: &cancellables) } private func updateAddressOnly(for location: CLLocationCoordinate2D) async { @@ -122,7 +128,8 @@ final class MainViewModel: BaseViewModel{ } catch { print("❌ 역지오코딩 실패: \(error)") } } - private func refreshRegionAndFareForCurrentAddress() async { + func refreshRegionAndFareForCurrentAddress() async { + self.taxiFare = nil guard let lat = lastReverseGeocode?.lat, let lon = lastReverseGeocode?.lon else { return } @@ -133,7 +140,7 @@ final class MainViewModel: BaseViewModel{ await MainActor.run { self.isServiceRegion = ok } } catch { print("서비스 지역 확인 실패: \(error)") } - + guard self.isServiceRegion == true, !isGuest else { return } let req = FetchTaxiFareRequest( originLat: lastReverseGeocode?.lat, @@ -223,19 +230,71 @@ final class MainViewModel: BaseViewModel{ } self.startHeading() + streamTask?.cancel() streamTask = Task { - var didSendInitialLocation = false for await location in streamUseCase.startUpdate() { - let newLocation = CLLocationCoordinate2D(latitude: location.coordinate.latitude, longitude: location.coordinate.longitude) + 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: CLLocationAccuracy + + if isInitialTracking { + accuracyThreshold = 1000.0 // 시청 탈출용 널널한 기준 + } else { + accuracyThreshold = isRecovering ? 300.0 : 150.0 // 회원님의 지하철 복구 로직 유지! + } + + // 정확도 필터링 + guard location.horizontalAccuracy < accuracyThreshold else { + self.consecutiveValidCount = 0 + continue + } + + if isRecovering { + self.consecutiveValidCount += 1 + let requiredCount = isInitialTracking ? 1 : 3 + + if self.consecutiveValidCount >= requiredCount { + smoother.reset(location.coordinate) + + self.lastValidTime = now + self.consecutiveValidCount = 0 + } else { + continue + } + } else { + self.lastValidTime = now + self.consecutiveValidCount = 0 + } + // 이동 평균 필터링 (항상 적용하여 부드러운 움직임 확보) + let smoothedCoord = smoother.smooth(location.coordinate) + + var finalCoord = smoothedCoord - // 내 진짜 GPS 위치는 계속 업데이트 - self.currentLocation = newLocation + // 알람이 울린 후(`isAlarmFired`)에만 경로 스냅 적용 + let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false - // [수정]: 지도의 중심(selectedLocation)은 "앱 최초 진입 시" 딱 1번만 GPS 위치로 맞춰줍니다. - if !didSendInitialLocation { - self.selectedLocation = newLocation - didSendInitialLocation = true + if isAlarmFired, let path = self.legInfo?.pathInfo { + let allCoords = path.flatMap { convertShapeToCoords($0.passShape ?? "") } + if !allCoords.isEmpty { + finalCoord = smoother.snap(current: smoothedCoord, polyline: allCoords) + } + } + + let capturedCoord = finalCoord + + await MainActor.run { + self.currentLocation = capturedCoord + if !didSendInitialLocation { + self.selectedLocation = capturedCoord + didSendInitialLocation = true + } + HomeArrivalManager.shared.checkHomeArrival(currentCoord: capturedCoord) } } } @@ -323,7 +382,7 @@ final class MainViewModel: BaseViewModel{ let _ = try await alarmUseCase.alarmDelete(request) wrapper.remove(forKey: UserDefaultsWrapper.Key.lastRouteId.rawValue) print("알람 취소 성공") - AmplitudeManager.shared.track(.alarm_cancel) + } catch { print("알람 취소 실패: \(error)") } @@ -361,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 @@ -380,7 +454,7 @@ extension MainViewModel { showLockView = true AlarmManager.shared.startAlarm(title: "눌러서 출발 알람 끄기", body: "자리에서 일어나야 할 시간이에요!") - startAlarmTimeoutTimer() + // startAlarmTimeoutTimer() stopAlarmTimer() } else { print("미래") @@ -458,6 +532,8 @@ extension MainViewModel { extension MainViewModel { func handleRoute(route: MainRoute) { switch route { + case .changeHome: + routeHandler?(.changeHome) case .changeCourse: routeHandler?(.changeCourse(location: Location( name: lastReverseGeocode?.name, @@ -467,15 +543,14 @@ extension MainViewModel { address: lastReverseGeocode?.address, radius: lastReverseGeocode?.radius))) - case .courseSearch: - guard let currentLocation else { return } - let lat: String = "\(currentLocation.latitude)" - let lon: String = "\(currentLocation.longitude)" - let address: String = address ?? "" + case .courseSearch(let startLat, let startLon, _): + + routeHandler?(.courseSearch( + startLat: (lastReverseGeocode?.lat).map { String($0) } ?? startLat, + startLon: (lastReverseGeocode?.lon).map { String($0) } ?? startLon, + startAddress: address ?? lastReverseGeocode?.address ?? "" + )) - routeHandler?(.courseSearch(startLat: lat, - startLon: lon, - startAddress: address)) case .myPage: routeHandler?(.myPage) @@ -587,32 +662,21 @@ extension MainViewModel { } } -// MARK: - 2분 타임아웃 extension MainViewModel { - private func startAlarmTimeoutTimer() { - alarmTimeoutCancellable?.cancel() - - let task = Task { [weak self] in - guard let self else { return } - - do { - try await Task.sleep(nanoseconds: 120 * 1_000_000_000) - } catch { - return - } - - guard !Task.isCancelled else { return } - - await MainActor.run { - self.routeHandler?(.dismissLockScreen) - } - } - - alarmTimeoutCancellable = AnyCancellable { task.cancel() } - } - func stopAlarmTimeoutTimer() { alarmTimeoutCancellable?.cancel() alarmTimeoutCancellable = nil } } + +extension MainViewModel { + private func convertShapeToCoords(_ shape: String) -> [CLLocationCoordinate2D] { + shape.split(separator: " ").compactMap { pair in + let parts = pair.split(separator: ",") + guard parts.count == 2, + let lon = Double(parts[0]), + let lat = Double(parts[1]) else { return nil } + return CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + } +} diff --git a/Atcha-iOS/Presentation/Location/SearchLocation/SearchLocationViewController.swift b/Atcha-iOS/Presentation/Location/SearchLocation/SearchLocationViewController.swift index 65ccaf7d..097e67e3 100644 --- a/Atcha-iOS/Presentation/Location/SearchLocation/SearchLocationViewController.swift +++ b/Atcha-iOS/Presentation/Location/SearchLocation/SearchLocationViewController.swift @@ -31,7 +31,12 @@ final class SearchLocationViewController: BaseViewController() @@ -33,15 +34,17 @@ final class LastTrainSearchBottomView: UIView { return stack }() - private lazy var arrivalLocationView: UIStackView = { - let stack = UIStackView(arrangedSubviews: [arrivalDotView, arrivalLocationLabel]) - stack.axis = .horizontal - stack.alignment = .center - stack.spacing = 8 - stack.backgroundColor = .clear - stack.isLayoutMarginsRelativeArrangement = true - stack.layoutMargins = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) - return stack + private let arrivalLocationView: UIView = { + let view = UIView() + view.backgroundColor = .clear + return view + }() + + private let arrivalImageView: UIImageView = { + let iv = UIImageView(image: .chevronRight) + iv.tintColor = AtchaColor.neutral + iv.contentMode = .scaleAspectFit + return iv }() private let searchButton: AtchaButton = AtchaButton(text: "막차 검색하기", @@ -80,6 +83,8 @@ final class LastTrainSearchBottomView: UIView { currentLocationLabel.attributedText = AtchaFont.B1_R_17(lineHeight: 0, "현위치: 조회 중..", color: .main) arrivalLocationLabel.attributedText = AtchaFont.B1_R_17(lineHeight: 0, "도착지: 우리집", color: .gray200) + arrivalLocationView.addSubViews(arrivalDotView, arrivalLocationLabel, arrivalImageView) + addSubViews(currentLocationView, arrivalLocationView, searchButton) let tap = UITapGestureRecognizer(target: self, action: #selector(handleCurrentTap)) @@ -110,10 +115,27 @@ final class LastTrainSearchBottomView: UIView { arrivalLocationView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(16) - make.top.equalTo(currentLocationView.snp.bottom).inset(-8) + make.top.equalTo(currentLocationView.snp.bottom).offset(8) make.height.equalTo(48) } + arrivalDotView.snp.makeConstraints { make in + make.width.height.equalTo(4) + make.leading.equalToSuperview().offset(12) + make.centerY.equalToSuperview() + } + + arrivalLocationLabel.snp.makeConstraints { make in + make.leading.equalTo(arrivalDotView.snp.trailing).offset(8) + make.centerY.equalToSuperview() + } + + arrivalImageView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(8) + make.centerY.equalToSuperview() + make.height.width.equalTo(20) + } + searchButton.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(18) make.top.equalTo(arrivalLocationView.snp.bottom).inset(-24) @@ -136,12 +158,19 @@ final class LastTrainSearchBottomView: UIView { extension LastTrainSearchBottomView { @objc private func handleCurrentTap() { actionPublisher.send(.currentTapped) + + AmplitudeManager.shared.track(.departure_modify_click) } @objc private func handleSearchTap() { actionPublisher.send(.searchTapped) + + AmplitudeManager.shared.track(.course_search_click) } @objc private func handleDestinationTap() { + actionPublisher.send(.homeChangeTapped) + + AmplitudeManager.shared.track(.home_modify_click) } } diff --git a/Atcha-iOS/Presentation/Location/View/TMapContainerView.swift b/Atcha-iOS/Presentation/Location/View/TMapContainerView.swift index 2cb92c39..605b2fe4 100644 --- a/Atcha-iOS/Presentation/Location/View/TMapContainerView.swift +++ b/Atcha-iOS/Presentation/Location/View/TMapContainerView.swift @@ -11,7 +11,7 @@ import TMapSDK import SnapKit final class TMapContainerView: UIView { - private var tMapWrapper: TMapWrapper! + var tMapWrapper: TMapWrapper! var gestureTargetView: UIView { tMapWrapper.mapView } var onUserInteraction: (() -> Void)? diff --git a/Atcha-iOS/Presentation/Lock/LockViewController.swift b/Atcha-iOS/Presentation/Lock/LockViewController.swift index 952fff81..46f79918 100644 --- a/Atcha-iOS/Presentation/Lock/LockViewController.swift +++ b/Atcha-iOS/Presentation/Lock/LockViewController.swift @@ -27,6 +27,7 @@ final class LockViewController: BaseViewController { bind() setupUI() setupAutoLayout() + observeAlarmTimeout() } override func viewWillAppear(_ animated: Bool) { @@ -47,7 +48,7 @@ final class LockViewController: BaseViewController { } override func viewDidAppear(_ animated: Bool) { - AmplitudeManager.shared.trackScreen(.alarm) + amp_track(.alarm_view) } // MARK: - ViewModel 바인딩 @@ -56,13 +57,13 @@ final class LockViewController: BaseViewController { .receive(on: DispatchQueue.main) .sink { [weak self] fare in guard let self = self else { return } - + if fare == 0 { self.taxiFareLabel.attributedText = - AtchaFont.D1_EB_56("계산 중...", color: AtchaColor.Bus.widearea) + AtchaFont.D1_EB_56("계산 중...", color: AtchaColor.Bus.widearea) } else { self.taxiFareLabel.attributedText = - AtchaFont.D1_EB_56("-\(fare.formattedWithComma)", color: AtchaColor.Bus.widearea) + AtchaFont.D1_EB_56("-\(fare.formattedWithComma)", color: AtchaColor.Bus.widearea) } } .store(in: &cancellables) @@ -141,6 +142,7 @@ final class LockViewController: BaseViewController { @objc private func startTapped() { viewModel.cancelLockScreenTimer() AlarmManager.shared.stopAlarm() + AlarmManager.shared.removeAllAlarmNotificationsExceptAutoStop() let wrapper = UserDefaultsWrapper.shared let legInfo = wrapper.object(forKey: UserDefaultsWrapper.Key.legInfo.rawValue, of: LegInfo.self) @@ -151,12 +153,12 @@ final class LockViewController: BaseViewController { true, forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue ) - - AmplitudeManager.shared.track(.start_click) + + amp_track(.start_click) } @objc private func detailRouteTapped() { - + AlarmManager.shared.stopAlarm() let wrapper = UserDefaultsWrapper.shared @@ -164,7 +166,17 @@ final class LockViewController: BaseViewController { let lon = wrapper.string(forKey: UserDefaultsWrapper.Key.startLon.rawValue) ?? "" let address = wrapper.string(forKey: UserDefaultsWrapper.Key.startAddress.rawValue) ?? "" - AmplitudeManager.shared.track(.later_course_click) + amp_track(.later_course_click) viewModel.routerHandler?(.courseSearch(startLat: lat, startLon: lon, startAddress: address)) } + + private func observeAlarmTimeout() { + NotificationCenter.default.publisher(for: NSNotification.Name("alarmDidTimeout")) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + // 수정됨: 화면을 닫고 메인으로 돌아가는 올바른 라우터 명령 전달 + self?.viewModel.routerHandler?(.dismissLockScreen) + } + .store(in: &cancellables) + } } diff --git a/Atcha-iOS/Presentation/Login/LoginViewController.swift b/Atcha-iOS/Presentation/Login/LoginViewController.swift index 4987e3cf..a4c63712 100644 --- a/Atcha-iOS/Presentation/Login/LoginViewController.swift +++ b/Atcha-iOS/Presentation/Login/LoginViewController.swift @@ -174,6 +174,8 @@ extension LoginViewController { @objc private func didTapKakaoLoginButton() { print("카카오 로그인 버튼 터치됨") viewModel.kakaoLoginTapped() + + amp_track(.login_click, properties: props(AmplitudeProperty.social(.kakao))) } @objc private func didTapAppleLoginButton() { @@ -183,6 +185,8 @@ extension LoginViewController { ) { [weak self] delegate in self?.appleLoginDelegateWrapper = delegate } + + amp_track(.login_click, properties: props(AmplitudeProperty.social(.apple))) } } diff --git a/Atcha-iOS/Presentation/Main/MainCoordinator.swift b/Atcha-iOS/Presentation/Main/MainCoordinator.swift index 970f95ba..736777a5 100644 --- a/Atcha-iOS/Presentation/Main/MainCoordinator.swift +++ b/Atcha-iOS/Presentation/Main/MainCoordinator.swift @@ -9,12 +9,13 @@ import UIKit import TMapSDK import Foundation -final class MainCoordinator { +final class MainCoordinator: NSObject { private let navigationController: UINavigationController private let diContainer: MainDIContainer private var myPageCoordinator: MyPageCoordinator? private var busDetailCoordinator: BusDetailCoordinator? private var loginCoordinator: LoginCoordinator? + private var homeRegisterCoordinator: HomeRegistrationCoordinator? private var mainViewModel: MainViewModel? @@ -27,6 +28,8 @@ final class MainCoordinator { diContainer: MainDIContainer) { self.navigationController = navigationController self.diContainer = diContainer + super.init() + self.navigationController.delegate = self } func start(info: LegInfo? = nil, @@ -53,6 +56,13 @@ final class MainCoordinator { private func handle(route: MainRoute) { switch route { + case .changeHome: + let homeDI = diContainer.makeHomeRegisterDIContainer() + let coordinator = HomeRegistrationCoordinator(navigationController: navigationController, diContainer: homeDI) + + self.homeRegisterCoordinator = coordinator + coordinator.start() + case .myPage: let myPageDI = diContainer.makeMyPageDIContainer() let myPageCoordinator = MyPageCoordinator( @@ -66,6 +76,8 @@ final class MainCoordinator { self.mainViewModel?.isGuest = true self.mainViewModel?.bottomType = .search + self.mainViewModel?.resetLocationState() + self.navigationController.popToRootViewController(animated: true) self.myPageCoordinator = nil @@ -73,7 +85,10 @@ final class MainCoordinator { } myPageCoordinator.withdrawFinish = { [weak self] in DispatchQueue.main.async { + self?.mainViewModel?.resetLocationState() self?.withdrawFinish?() + + self?.myPageCoordinator = nil } } @@ -241,6 +256,10 @@ final class MainCoordinator { self.navigationController.pushViewController(modifyVC, animated: true) case .detailRoute(let address, let infos, let context): + if navigationController.topViewController is DetailRouteViewController { + print("이미 상세 경로 화면입니다. 중복 push를 방지합니다.") + return + } let routeDI = diContainer.makeRouteDIContainer() let vm = routeDI.makeDetailRouteViewModel(address: address, infos: infos, context: context) let vc = routeDI.makeDetailRouteViewController(viewModel: vm) @@ -285,6 +304,8 @@ final class MainCoordinator { startLon: startLon, startAddress: startAddress)) } + case .dismissLockScreen: + self?.navigationController.dismiss(animated: true) default: do {} } } @@ -303,13 +324,6 @@ final class MainCoordinator { dismissPresentedIfNeeded { DispatchQueue.global(qos: .utility).async { self.mainViewModel?.showLockView = false - self.mainViewModel?.showAlarmStopPopUpView = true - - AlarmManager.shared.sendBackgroundPush( - title: "출발 알람이 자동 종료되었어요", - body: "클릭해서 경로 재탐색하기" - ) - } } case .loginSheet: @@ -322,14 +336,14 @@ final class MainCoordinator { 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?() @@ -378,3 +392,22 @@ extension UINavigationController { } } } + +extension MainCoordinator: UINavigationControllerDelegate { + func navigationController(_ navigationController: UINavigationController, + didShow viewController: UIViewController, + animated: Bool) { + + guard viewController is MainViewController else { return } + + clearChildCoordinators() + mainViewModel?.refreshCurrentMapCenterData() + } + + private func clearChildCoordinators() { + self.myPageCoordinator = nil + self.busDetailCoordinator = nil + self.loginCoordinator = nil + self.homeRegisterCoordinator = nil + } +} diff --git a/Atcha-iOS/Presentation/Onboarding/HomeRegister/Coordinator/HomeRegistrationCoordinator.swift b/Atcha-iOS/Presentation/Onboarding/HomeRegister/Coordinator/HomeRegistrationCoordinator.swift new file mode 100644 index 00000000..354b8260 --- /dev/null +++ b/Atcha-iOS/Presentation/Onboarding/HomeRegister/Coordinator/HomeRegistrationCoordinator.swift @@ -0,0 +1,67 @@ +// +// HomeRegistrationCoordinator.swift +// Atcha-iOS +// +// Created by wodnd on 3/12/26. +// + +import Foundation +import UIKit +import Combine + +final class HomeRegistrationCoordinator { + private let navigationController: UINavigationController + private let diContainer: HomeRegisterDIContainer // 전용 컨테이너로 교체 + private var cancellables = Set() + + + init(navigationController: UINavigationController, + diContainer: HomeRegisterDIContainer) { + self.navigationController = navigationController + self.diContainer = diContainer + } + + func start() { + // 메인에서 진입했으므로 context를 .home(또는 해당되는 케이스)으로 주입 + let viewModel = diContainer.makeHomeRegisterViewModel(context: .home) + + viewModel.routeHandler = { [weak self] route in + self?.handleHomeRouter(route) + } + + let viewController = diContainer.makeHomeRegisterViewController(viewModel: viewModel) + + navigationController.pushViewController(viewController, animated: true) + } + + private func handleHomeRouter(_ route: HomeRouter) { + switch route { + case .searchAdress: + showSearchAddress() + case let .homeRegister(useDeviceLocation): + showHomeFind(useDeviceLocation: useDeviceLocation) + } + } + + private func showSearchAddress() { + let viewModel = diContainer.makeHomeSearchViewModel() + viewModel.routeHandler = { [weak self] route in + self?.handleHomeRouter(route) + } + + let viewController = diContainer.makeHomeSearchViewController(viewModel: viewModel) + navigationController.pushViewController(viewController, animated: true) + } + + private func showHomeFind(useDeviceLocation: Bool) { + let viewModel = diContainer.makeHomeFindViewModel(context: .home) + viewModel.forceDeviceLocation = useDeviceLocation + + viewModel.routeHandler = { [weak self] route in + self?.handleHomeRouter(route) + } + + let viewController = diContainer.makeHomeFindViewController(viewModel: viewModel) + navigationController.pushViewController(viewController, animated: true) + } +} diff --git a/Atcha-iOS/Presentation/Onboarding/HomeRegister/HomeRegisterViewController.swift b/Atcha-iOS/Presentation/Onboarding/HomeRegister/HomeRegisterViewController.swift index 22114e34..896f731e 100644 --- a/Atcha-iOS/Presentation/Onboarding/HomeRegister/HomeRegisterViewController.swift +++ b/Atcha-iOS/Presentation/Onboarding/HomeRegister/HomeRegisterViewController.swift @@ -12,7 +12,7 @@ import CoreLocation final class HomeRegisterViewController: BaseViewController { private lazy var navigationBar: TitleNavigationBar = AtchaNavigationBar.title("우리집 설정", shouldShowCloseButton: false, onBack: { [weak self] in guard let self else { return } - navigationController?.popViewController(animated: true) + self.navigationController?.popViewController(animated: true) }) private let titleLabel = UILabel() @@ -23,7 +23,7 @@ final class HomeRegisterViewController: BaseViewController { confirmButton.backgroundColor = info.confrimBackgroundColor // alarmTimeout에서는 cancel이 없으니, cancel 세팅은 조건부로 - if info != .alarmTimeout { + if info != .alarmTimeout || info != .arrive { let cancelAttr = AtchaFont.B5_SB_14(info.cancelTitle, color: info.cancelForegroundColor) cancelButton.setAttributedTitle(cancelAttr, for: .normal) cancelButton.backgroundColor = info.cancelBackgroundColor @@ -94,7 +94,7 @@ final class AtchaPopupViewController: BaseViewController { $0.removeFromSuperview() } - if info == .alarmTimeout { + if info == .alarmTimeout || info == .arrive{ buttonStackView.addArrangedSubview(confirmButton) } else { buttonStackView.addArrangedSubview(cancelButton) diff --git a/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewController.swift b/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewController.swift index b54d4190..bf1533f7 100644 --- a/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewController.swift +++ b/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewController.swift @@ -33,8 +33,8 @@ final class PushAlarmViewController: BaseViewController { if let selectedOption = self.selectedOption { AlarmManager.shared.setAlarmOption(selectedOption) - AmplitudeManager.shared.track( - .alarm_alert_type_setting, + amp_track( + .alarm_alert_type_setting, properties: props( AmplitudeProperty.alertType(self.mapAlertType(selectedOption)) ) @@ -52,12 +52,6 @@ final class PushAlarmViewController: BaseViewController { } } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - AmplitudeManager.shared.trackScreen(.alarm_setting) - - } override func viewDidLoad() { super.viewDidLoad() diff --git a/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewModel.swift b/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewModel.swift index 80915394..7644eea7 100644 --- a/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewModel.swift +++ b/Atcha-iOS/Presentation/Setting/PushAlarm/PushAlarmViewModel.swift @@ -72,13 +72,6 @@ final class PushAlarmViewModel: BaseViewModel { UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.reVisit .rawValue) - let dwellSeconds = AmplitudeManager.shared.timerEndSeconds("signup_dwell") - AmplitudeManager.shared.track( - .signup, - props( - AmplitudeProperty.dwellTime(seconds: dwellSeconds) - ) - ) print("회원가입 lat/lon 저장 완료: \(lat), \(lon)") } else { print("회원가입 응답에 lat/lon 없음") diff --git a/Atcha-iOS/Presentation/Setting/PushAlarmSheet/PushAlarmSheetViewController.swift b/Atcha-iOS/Presentation/Setting/PushAlarmSheet/PushAlarmSheetViewController.swift index 6edad519..92d09f0e 100644 --- a/Atcha-iOS/Presentation/Setting/PushAlarmSheet/PushAlarmSheetViewController.swift +++ b/Atcha-iOS/Presentation/Setting/PushAlarmSheet/PushAlarmSheetViewController.swift @@ -236,10 +236,26 @@ extension PushAlarmSheetViewController { AlarmManager.shared.setAlarmOption(option) AlarmManager.shared.setAlarmArmed(true) isConfirmed = true + + amp_track( + .alarm_alert_type_setting, properties: + props( + AmplitudeProperty.alertType(self.mapAlertType(option)) + ) + ) } + dismissSheet() } + private func mapAlertType(_ option: PushAlarmOption) -> AlertType { + switch option { + case .onlyVibration: return .onlyVibration + case .onlySound: return .onlySound + case .both: return .soundAndVibration + } + } + @objc private func sliderChanged(_ sender: UISlider) { let clampedValue = max(sender.value, 0.1) sender.setValue(clampedValue, animated: false) diff --git a/Atcha-iOS/Presentation/Splash/SplashViewModel.swift b/Atcha-iOS/Presentation/Splash/SplashViewModel.swift index 75b7b124..b68ef628 100644 --- a/Atcha-iOS/Presentation/Splash/SplashViewModel.swift +++ b/Atcha-iOS/Presentation/Splash/SplashViewModel.swift @@ -54,7 +54,23 @@ final class SplashViewModel: BaseViewModel { func fetchUserInfo() { Task { do { - let _ = try await fetchUserUseCase.excute() + let response = try await fetchUserUseCase.excute() + if let lat = response?.coordinate?.latitude, + let lon = response?.coordinate?.longitude, + let id = response?.id{ + UserDefaultsWrapper.shared.set(lat, forKey: UserDefaultsWrapper.Key.homeLat.rawValue) + UserDefaultsWrapper.shared.set(lon, forKey: UserDefaultsWrapper.Key.homeLon.rawValue) + UserDefaultsWrapper.shared.set(id, forKey: UserDefaultsWrapper.Key.userId + .rawValue) + UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.reVisit + .rawValue) + + AmplitudeManager.shared.bindUser(id: String(id)) + AmplitudeManager.shared.flush() + } + + UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) + } catch { print("유저정보 패치 실패") } diff --git a/Atcha-iOS/Presentation/User/Home/HomeFindViewController.swift b/Atcha-iOS/Presentation/User/Home/HomeFindViewController.swift index a7b84ca0..542f9f9c 100644 --- a/Atcha-iOS/Presentation/User/Home/HomeFindViewController.swift +++ b/Atcha-iOS/Presentation/User/Home/HomeFindViewController.swift @@ -16,7 +16,7 @@ final class HomeFindViewController: BaseViewController, private let bottomView: HomeRegisterBottomView = HomeRegisterBottomView() private let flagImageView: UIImageView = UIImageView() private let backButton: UIButton = UIButton() - private let loactionButton: UIButton = UIButton() + private let locationButton: UIButton = UIButton() private let exitButton: UIButton = UIButton() var routeHandler: ((HomeRouter) -> Void)? @@ -34,7 +34,7 @@ final class HomeFindViewController: BaseViewController, override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - AmplitudeManager.shared.trackScreen(.home_setting) + amp_track(.home_setting_view) } override func viewWillDisappear(_ animated: Bool) { @@ -47,7 +47,7 @@ final class HomeFindViewController: BaseViewController, view.addSubViews(mapContainerView, flagImageView, bottomView, - loactionButton, + locationButton, backButton) mapContainerView.delegate = self @@ -58,7 +58,7 @@ final class HomeFindViewController: BaseViewController, backButton.clipsToBounds = true backButton.setCornerRadius(18) - configureButton(loactionButton, + configureButton(locationButton, imageName: "mylocation-filled", action: #selector(didTapLocationButton)) } @@ -80,14 +80,14 @@ final class HomeFindViewController: BaseViewController, ) if ok { self.viewModel.handleRegister() - if viewModel.context == .myPage { + if viewModel.context == .myPage || viewModel.context == .home { self.navigationController?.popToViewController(ofType: HomeRegisterViewController.self) } } else { AtchaToast(message: "앗차는 현재 서울, 경기, 인천에서만 이용 가능해요") .show(in: self.view) } - + amp_track(.home_setting_click) self.bottomView.isUserInteractionEnabled = true } } @@ -145,7 +145,7 @@ final class HomeFindViewController: BaseViewController, make.width.equalTo(48) } - loactionButton.snp.makeConstraints { make in + locationButton.snp.makeConstraints { make in make.bottom.equalTo(bottomView.snp.top).inset(-16) make.trailing.equalToSuperview().inset(16) make.width.height.equalTo(40) @@ -205,6 +205,8 @@ extension HomeFindViewController { @objc private func didTapLocationButton() { ensureLocationPermissionOrShowToast() viewModel.setupLocation() + + amp_track(.current_location_click) } } diff --git a/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift b/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift index 065ed7fc..30fc5c18 100644 --- a/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift +++ b/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift @@ -73,13 +73,11 @@ final class HomeFindViewModel: BaseViewModel { } func handleRegister() { - AmplitudeManager.shared.track(.home_register) - switch context { case .onboarding: saveCurrentLoaction() signUp() - case .myPage: + case .myPage, .home: guard let currentLocation, let address else { print("집주소 변경 불가: 값 없음") @@ -270,12 +268,8 @@ extension HomeFindViewModel { UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.reVisit .rawValue) - let dwellSeconds = AmplitudeManager.shared.timerEndSeconds("signup_dwell") AmplitudeManager.shared.track( - .signup, - props( - AmplitudeProperty.dwellTime(seconds: dwellSeconds) - ) + .signup ) print("회원가입 lat/lon 저장 완료: \(lat), \(lon)") UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) diff --git a/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewController.swift b/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewController.swift index ffe4142d..31bd3bbc 100644 --- a/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewController.swift +++ b/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewController.swift @@ -36,10 +36,6 @@ final class MyAccountViewController: BaseViewController { setupAutoLayout() } - override func viewDidAppear(_ animated: Bool) { - AmplitudeManager.shared.trackScreen(.account) - } - private func setupUI() { view.addSubViews(navigationBar, collectionView) 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 { diff --git a/Atcha-iOS/Presentation/User/MyPage/MyPageViewController.swift b/Atcha-iOS/Presentation/User/MyPage/MyPageViewController.swift index 34e13aae..1722357c 100644 --- a/Atcha-iOS/Presentation/User/MyPage/MyPageViewController.swift +++ b/Atcha-iOS/Presentation/User/MyPage/MyPageViewController.swift @@ -38,7 +38,7 @@ final class MyPageViewController: BaseViewController { } override func viewDidAppear(_ animated: Bool) { - AmplitudeManager.shared.trackScreen(.mypage) + amp_track(.mypage_view) } private func setupUI() { @@ -93,6 +93,8 @@ final class MyPageViewController: BaseViewController { @objc func bannerTapped() { viewModel.bannerTapped() + + amp_track(.feedback) } } @@ -134,6 +136,7 @@ extension MyPageViewController: UICollectionViewDelegate, viewModel.navigationTarget.send(.notification) case .term: viewModel.navigationTarget.send(.term) + amp_track(.term) case .version: viewModel.navigationTarget.send(.versionUpdate) } diff --git a/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewController.swift b/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewController.swift index 187c711f..ac6af2ba 100644 --- a/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewController.swift +++ b/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewController.swift @@ -31,8 +31,6 @@ class WithdrawViewController: BaseViewController { if #available(iOS 16.0, *) { view.keyboardLayoutGuide.followsUndockedKeyboard = true } - - AmplitudeManager.shared.trackScreen(.withdraw) } override func viewDidLoad() { diff --git a/Atcha-iOS/Presentation/WebView/WebViewController.swift b/Atcha-iOS/Presentation/WebView/WebViewController.swift index dc389532..f3de0606 100644 --- a/Atcha-iOS/Presentation/WebView/WebViewController.swift +++ b/Atcha-iOS/Presentation/WebView/WebViewController.swift @@ -54,7 +54,7 @@ final class WebViewController: BaseViewController { override func viewDidAppear(_ animated: Bool) { if type == .term { - AmplitudeManager.shared.trackScreen(.terms) + amp_track(.term) } }