diff --git a/Atcha-iOS.xcodeproj/project.pbxproj b/Atcha-iOS.xcodeproj/project.pbxproj index 5f03f507..862e7052 100644 --- a/Atcha-iOS.xcodeproj/project.pbxproj +++ b/Atcha-iOS.xcodeproj/project.pbxproj @@ -2516,7 +2516,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 23SCTLK482; FRAMEWORK_SEARCH_PATHS = ( @@ -2539,7 +2539,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9.5; + MARKETING_VERSION = 1.9.6; PRODUCT_BUNDLE_IDENTIFIER = com.atcha.iOS; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2563,7 +2563,7 @@ CODE_SIGN_ENTITLEMENTS = "Atcha-iOS/Atcha-iOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 23SCTLK482; EXCLUDED_ARCHS = ""; FRAMEWORK_SEARCH_PATHS = ( @@ -2586,7 +2586,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9.5; + MARKETING_VERSION = 1.9.6; OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.atcha.iOS; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2611,7 +2611,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 23SCTLK482; FRAMEWORK_SEARCH_PATHS = ( @@ -2634,7 +2634,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9.5; + MARKETING_VERSION = 1.9.6; PRODUCT_BUNDLE_IDENTIFIER = com.atcha.iOS; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Atcha-iOS/App/AppConfig.swift b/Atcha-iOS/App/AppConfig.swift index 0cf35f5f..7e81d874 100644 --- a/Atcha-iOS/App/AppConfig.swift +++ b/Atcha-iOS/App/AppConfig.swift @@ -21,4 +21,6 @@ enum AppConfig { static var kakaoInitKey: String { required("KAKAO_INIT_KEY") } static var tmapApiKey: String { required("TMAP_API_KEY") } static var amplitudeApiKey: String { required("AMPLITUDE_API_KEY") } + static var errorWebhookURL: String { required("ERROR_WEBHOOK_URL") } + static var authWebhookURL: String { required("AUTH_WEBHOOK_URL") } } diff --git a/Atcha-iOS/App/AppFlowCoordinator.swift b/Atcha-iOS/App/AppFlowCoordinator.swift index dd2d147a..613ccccc 100644 --- a/Atcha-iOS/App/AppFlowCoordinator.swift +++ b/Atcha-iOS/App/AppFlowCoordinator.swift @@ -25,7 +25,7 @@ class AppFlowCoordinator { self.container = container } - func startApp() { + func startApp(launchType: LaunchType = .main) { let navigationController = UINavigationController() window.rootViewController = navigationController window.makeKeyAndVisible() @@ -35,11 +35,14 @@ class AppFlowCoordinator { DispatchQueue.main.async { AppDIContainer.shared.tokenStorage.clearAllTokens() UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.hasSeenIntro.rawValue) - self?.startApp() + self?.startApp(launchType: .fast) } } - let splashCoordinator = container.makeSplashCoordinator(navigationController: navigationController) + let splashCoordinator = container.makeSplashCoordinator( + navigationController: navigationController, + launchType: launchType + ) splashCoordinator.routerHandler = { [weak self] router in guard let self = self else { return } switch router { @@ -114,7 +117,7 @@ class AppFlowCoordinator { mainCoordinator.withdrawFinish = { [weak self] in DispatchQueue.main.async { // 앱 데이터를 다 지웠으니, 스플래시부터 앱을 아예 새로 시작(리부팅)합니다! - self?.startApp() + self?.startApp(launchType: .fast) } } diff --git a/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift b/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift index adc27d04..fb4299fd 100644 --- a/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift +++ b/Atcha-iOS/App/DIContainer/AppCompositionRoot.swift @@ -57,8 +57,8 @@ extension AppCompositionRoot: SplashCoordinatorFactory, MainCoordinatorFactory, LockScreenCoordinatorFactory, IntroCoordinatorFactory { - func makeSplashCoordinator(navigationController: UINavigationController) -> SplashCoordinator { - return splashDIContainer.makeSplashCoordinator(navigationController: navigationController) + func makeSplashCoordinator(navigationController: UINavigationController, launchType: LaunchType) -> SplashCoordinator { + return splashDIContainer.makeSplashCoordinator(navigationController: navigationController, launchType: launchType) } func makeLoginCoordinator(navigationController: UINavigationController) -> LoginCoordinator { diff --git a/Atcha-iOS/App/DIContainer/CoordinatorFactory.swift b/Atcha-iOS/App/DIContainer/CoordinatorFactory.swift index 43ed90b9..2573e102 100644 --- a/Atcha-iOS/App/DIContainer/CoordinatorFactory.swift +++ b/Atcha-iOS/App/DIContainer/CoordinatorFactory.swift @@ -13,7 +13,7 @@ import Foundation /// to avoid service locator style lookups and to enable constructor injection of dependencies. protocol SplashCoordinatorFactory { - func makeSplashCoordinator(navigationController: UINavigationController) -> SplashCoordinator + func makeSplashCoordinator(navigationController: UINavigationController, launchType: LaunchType) -> SplashCoordinator } protocol LoginCoordinatorFactory { @@ -45,8 +45,8 @@ extension AppDIContainer: SplashCoordinatorFactory, MainCoordinatorFactory, LockScreenCoordinatorFactory, IntroCoordinatorFactory { - func makeSplashCoordinator(navigationController: UINavigationController) -> SplashCoordinator { - return splashDIContainer.makeSplashCoordinator(navigationController: navigationController) + func makeSplashCoordinator(navigationController: UINavigationController, launchType: LaunchType) -> SplashCoordinator { + return splashDIContainer.makeSplashCoordinator(navigationController: navigationController, launchType: launchType) } func makeLoginCoordinator(navigationController: UINavigationController) -> LoginCoordinator { diff --git a/Atcha-iOS/App/DIContainer/Splash/SplashDIContainer.swift b/Atcha-iOS/App/DIContainer/Splash/SplashDIContainer.swift index c523b766..2132b7b2 100644 --- a/Atcha-iOS/App/DIContainer/Splash/SplashDIContainer.swift +++ b/Atcha-iOS/App/DIContainer/Splash/SplashDIContainer.swift @@ -32,12 +32,13 @@ final class SplashDIContainer { return UpdateAppVersionUseCaseImpl(repository: repository) } - func makeSplashViewModel() -> SplashViewModel { + func makeSplashViewModel(launchType: LaunchType) -> SplashViewModel { return SplashViewModel( fetchUserUseCase: makeFetchUserUseCase(), checkAppVersionUseCase: makeCheckAppVersionUseCase(), updateAppVersionUseCase: makeUpdateAppVersionUseCase(), - tokenStorage: tokenStorage + tokenStorage: tokenStorage, + launchType: launchType ) } @@ -45,8 +46,9 @@ final class SplashDIContainer { return SplashViewController(viewModel: viewModel) } - func makeSplashCoordinator(navigationController: UINavigationController) -> SplashCoordinator { + func makeSplashCoordinator(navigationController: UINavigationController, launchType: LaunchType) -> SplashCoordinator { SplashCoordinator(navigationController: navigationController, - diContainer: self) + diContainer: self, + launchType: launchType) } } diff --git a/Atcha-iOS/App/SceneDelegate.swift b/Atcha-iOS/App/SceneDelegate.swift index 6c61134c..939cc36b 100644 --- a/Atcha-iOS/App/SceneDelegate.swift +++ b/Atcha-iOS/App/SceneDelegate.swift @@ -17,9 +17,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { return } let window = UIWindow(windowScene: windowScene) + var launchType: LaunchType = .main appFlowCoordinator = AppFlowCoordinator(window: window, container: diContainer) - appFlowCoordinator?.startApp() + appFlowCoordinator?.startApp(launchType: launchType) self.window = window } diff --git a/Atcha-iOS/Base.lproj/LaunchScreen.storyboard b/Atcha-iOS/Base.lproj/LaunchScreen.storyboard index 82c0f0b6..cb2b988e 100644 --- a/Atcha-iOS/Base.lproj/LaunchScreen.storyboard +++ b/Atcha-iOS/Base.lproj/LaunchScreen.storyboard @@ -1,9 +1,9 @@ - + - + diff --git a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift index 75f79d46..f453f54a 100644 --- a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift +++ b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift @@ -11,8 +11,10 @@ final class DiscordWebhookManager { static let shared = DiscordWebhookManager() private init() {} - private let webhookURLString = "https://discord.com/api/webhooks/1483870710018474066/qyzNBI1Bwr7J5tQDrPx2-mOcej_9yLSOk5Bmlmza2D-4nSWqvWgcMd4CZDziG4vkpKrm" + private let errorWebhookURLString = AppConfig.errorWebhookURL + private let authWebhookURLString = AppConfig.authWebhookURL + // MARK: - 오류 로그 func sendErrorLog( baseURL: String, statusCode: Int, @@ -24,14 +26,10 @@ final class DiscordWebhookManager { requestBody: [String: Any]? = nil, requestParameters: [String: Any]? = nil ) { - guard let url = URL(string: webhookURLString) else { return } + guard let url = URL(string: errorWebhookURLString) else { return } - // Authorization 토큰 앞 30자만 노출 - let headersText = requestHeaders.map { key, value in - return "\(key): \(value)" - }.joined(separator: "\n") + let headersText = requestHeaders.map { "\($0.key): \($0.value)" }.joined(separator: "\n") - // body JSON 변환 let bodyText: String if let body = requestBody, let data = try? JSONSerialization.data(withJSONObject: body, options: .prettyPrinted), @@ -54,22 +52,58 @@ final class DiscordWebhookManager { "content": "🚨 [Atcha-iOS] API 에러 발생!", "embeds": [[ "title": "서버 에러 상세 보고", - "color": 16711680, + "color": 16711680, // 빨강 "fields": [ - ["name": "Base URL", "value": "`\(baseURL)`", "inline": false], - ["name": "Method & Path", "value": "`\(method) \(path)`", "inline": false], - ["name": "HTTP Status", "value": "\(statusCode)", "inline": true], - ["name": "responseCode", "value": responseCode, "inline": true], - ["name": "App Version", "value": AppInfoProvider.currentVersion, "inline": true], - ["name": "Error Message", "value": message, "inline": false], - ["name": "Request Headers", "value": "```\n\(headersText)\n```", "inline": false], - ["name": "Request Parameters", "value": paramsText, "inline": false], - ["name": "Request Body", "value": bodyText, "inline": false] + ["name": "Base URL", "value": "`\(baseURL)`", "inline": false], + ["name": "Method & Path", "value": "`\(method) \(path)`", "inline": false], + ["name": "HTTP Status", "value": "\(statusCode)", "inline": true], + ["name": "responseCode", "value": responseCode, "inline": true], + ["name": "App Version", "value": AppInfoProvider.currentVersion, "inline": true], + ["name": "Error Message", "value": message, "inline": false], + ["name": "Request Headers", "value": "```\n\(headersText)\n```", "inline": false], + ["name": "Request Parameters", "value": paramsText, "inline": false], + ["name": "Request Body", "value": bodyText, "inline": false] ], "footer": ["text": "발생 시각: \(Date().kstString)"] ]] ] + sendToWebhook(url: url, payload: payload) + } + + // MARK: - 로그인/탈퇴 로그 + func sendAuthLog(event: AuthEvent, userID: String, provider: String? = nil, reason: String? = nil) { + guard let url = URL(string: authWebhookURLString) else { return } + + var fields: [[String: Any]] = [ + ["name": "이벤트", "value": event.title, "inline": true], + ["name": "유저 ID", "value": "`\(userID)`", "inline": true], + ["name": "App Version", "value": AppInfoProvider.currentVersion, "inline": true] + ] + + if let provider { + fields.append(["name": "로그인 방식", "value": provider, "inline": true]) + } + + if let reason { + fields.append(["name": "탈퇴 사유", "value": reason, "inline": false]) + } + + let payload: [String: Any] = [ + "content": event.headerMessage, + "embeds": [[ + "title": event.embedTitle, + "color": event.color, + "fields": fields, + "footer": ["text": "발생 시각: \(Date().kstString)"] + ]] + ] + + sendToWebhook(url: url, payload: payload) + } + + // MARK: - 공통 전송 + private func sendToWebhook(url: URL, payload: [String: Any]) { var request = URLRequest(url: url) request.httpMethod = "POST" request.addValue("application/json", forHTTPHeaderField: "Content-Type") @@ -79,6 +113,51 @@ final class DiscordWebhookManager { } } +// MARK: - Auth Event 타입 +enum AuthEvent { + case login + case signup + case logout + case withdraw + + var title: String { + switch self { + case .login: return "로그인" + case .signup: return "회원가입" + case .logout: return "로그아웃" + case .withdraw: return "회원탈퇴" + } + } + + var embedTitle: String { + switch self { + case .login: return "로그인 이벤트" + case .signup: return "회원가입 이벤트" + case .logout: return "로그아웃 이벤트" + case .withdraw: return "회원탈퇴 이벤트" + } + } + + var headerMessage: String { + switch self { + case .login: return "✅ [Atcha-iOS] 로그인" + case .signup: return "🎉 [Atcha-iOS] 회원가입" + case .logout: return "👋 [Atcha-iOS] 로그아웃" + case .withdraw: return "❌ [Atcha-iOS] 회원탈퇴" + } + } + + var color: Int { + switch self { + case .login: return 3066993 // 초록 + case .signup: return 5814783 // 파랑 + case .logout: return 16776960 // 노랑 + case .withdraw: return 10038562 // 보라 + } + } +} + +// MARK: - Date Extension private extension Date { var kstString: String { let formatter = DateFormatter() diff --git a/Atcha-iOS/Core/Network/Token/SessionController.swift b/Atcha-iOS/Core/Network/Token/SessionController.swift index 3d82a51e..1b306b7d 100644 --- a/Atcha-iOS/Core/Network/Token/SessionController.swift +++ b/Atcha-iOS/Core/Network/Token/SessionController.swift @@ -26,6 +26,8 @@ final class SessionController { storage.clearRefreshToken() UserDefaultsWrapper.shared.removeAll() AppDIContainer.shared.locationStateHolder.clear() + UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) + UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.hasSeenIntro.rawValue) // 로그인 화면으로 DispatchQueue.main.async { [weak self] in diff --git a/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift b/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift index 97361f57..4d8c69f0 100644 --- a/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift +++ b/Atcha-iOS/Core/Network/Token/TokenInterceptor.swift @@ -77,8 +77,6 @@ final class TokenInterceptor: RequestInterceptor, @unchecked Sendable { return } - let actualHeaders = request.request?.allHTTPHeaderFields ?? [:] - guard let refreshToken = tokenStorage.refreshToken else { SessionController.shared.expireAndRouteToLogin() completion(.doNotRetry) @@ -109,24 +107,7 @@ final class TokenInterceptor: RequestInterceptor, @unchecked Sendable { waiters.forEach { $0(.doNotRetry) } return } - - let successBody = [ - "newAccessToken": p.accessToken, - "newRefreshToken": p.refreshToken - ] - - DiscordWebhookManager.shared.sendErrorLog( - baseURL: NetworkConstant.baseURL, - statusCode: 200, - method: "GET", - path: "/auth/reissue", - responseCode: "REISSUE_SUCCESS", - message: "토큰 재발급에 성공하여 새로운 토큰을 수신했습니다.", - requestHeaders: actualHeaders, - requestBody: successBody, // 여기서 받은 토큰 정보를 보냅니다. - requestParameters: nil - ) - + self.tokenStorage.accessToken = p.accessToken self.tokenStorage.refreshToken = p.refreshToken diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/setting_home_mark.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/setting_home_mark.imageset/Contents.json new file mode 100644 index 00000000..e334e205 --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/setting_home_mark.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "setting_home_mark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "setting_home_mark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "setting_home_mark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/setting_home_mark.imageset/setting_home_mark.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/setting_home_mark.imageset/setting_home_mark.png new file mode 100644 index 00000000..91b6e675 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/setting_home_mark.imageset/setting_home_mark.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/setting_home_mark.imageset/setting_home_mark@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/setting_home_mark.imageset/setting_home_mark@2x.png new file mode 100644 index 00000000..0d03202e Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/setting_home_mark.imageset/setting_home_mark@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/setting_home_mark.imageset/setting_home_mark@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/setting_home_mark.imageset/setting_home_mark@3x.png new file mode 100644 index 00000000..24f4a8f8 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/setting_home_mark.imageset/setting_home_mark@3x.png differ diff --git a/Atcha-iOS/Info.plist b/Atcha-iOS/Info.plist index 29cd94cd..0cbe917b 100644 --- a/Atcha-iOS/Info.plist +++ b/Atcha-iOS/Info.plist @@ -67,7 +67,11 @@ $(AMPLITUDE_API_KEY) API_BASE_URL $(API_BASE_URL) - ITSAppUsesNonExemptEncryption - + ITSAppUsesNonExemptEncryption + + AUTH_WEBHOOK_URL + $(AUTH_WEBHOOK_URL) + ERROR_WEBHOOK_URL + $(ERROR_WEBHOOK_URL) diff --git a/Atcha-iOS/Presentation/Intro/IntroViewController.swift b/Atcha-iOS/Presentation/Intro/IntroViewController.swift index 77855fc2..2f823535 100644 --- a/Atcha-iOS/Presentation/Intro/IntroViewController.swift +++ b/Atcha-iOS/Presentation/Intro/IntroViewController.swift @@ -178,11 +178,6 @@ final class IntroViewController: BaseViewController { label.snp.makeConstraints { make in make.center.equalToSuperview() } - - button.snp.makeConstraints { make in - make.horizontalEdges.equalToSuperview().inset(15) - make.height.equalTo(56) - } } } diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index 87ceab62..42f67c31 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -78,6 +78,8 @@ final class MainViewController: BaseViewController, if viewModel.isGuest { return false } return viewModel.isGuideActiveInSession } + private var guestTapCount = 0 + private var guestLoginWorkItem: DispatchWorkItem? // MARK: - Life Cycle @@ -152,6 +154,12 @@ final class MainViewController: BaseViewController, } } } + + self.guestTapCount = 0 + if viewModel.isGuest { + ballonView.isHidden = true + ballonView.alpha = 0 + } } override func viewDidAppear(_ animated: Bool) { @@ -229,6 +237,8 @@ final class MainViewController: BaseViewController, flagImageView.image = UIImage.settingLocationMark atchaImageView.isUserInteractionEnabled = true atchaImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleBallonTap))) + ballonView.isUserInteractionEnabled = true + ballonView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleBallonTap))) ballonView.isHidden = true ballonView.alpha = 0 @@ -309,6 +319,7 @@ extension MainViewController { observeArrival() observeScheduledArrivalTimeout() observeAlarmTimeout() + observeLoginDismissal() } private func bindPermissionAlert() { @@ -768,27 +779,28 @@ extension MainViewController { } private func bindServiceRegionUpdates() { - viewModel.$isServiceRegion - .removeDuplicates() + // isServiceRegion과 isGuest 상태를 결합하여 판단 + Publishers.CombineLatest(viewModel.$isServiceRegion, viewModel.$isGuest) + .removeDuplicates { $0.0 == $1.0 && $0.1 == $1.1 } .receive(on: RunLoop.main) - .sink { [weak self] ok in + .sink { [weak self] ok, isGuest in guard let self = self else { return } self.latestIsServiceRegion = ok - switch ok { - case .some(true): - self.lastTrainSearchView.updateSearchEnabled(true) - case .some(false): + // 핵심 로직: + // 1. 게스트 모드라면 지역에 상관없이 항상 활성화 (로그인 시트 유도) + // 2. 회원 모드라면 서비스 지역(ok == true)일 때만 활성화 + let shouldEnableSearch = isGuest || (ok == true) + self.lastTrainSearchView.updateSearchEnabled(shouldEnableSearch) + + // 서비스 지역이 아닐 때의 데이터 정리 + if ok == false { self.latestFareString = nil self.viewModel.taxiFare = nil - self.lastTrainSearchView.updateSearchEnabled(false) - case .none: - self.lastTrainSearchView.updateSearchEnabled(false) } - // 검색 모드일 때는 즉시 말풍선 글자 업데이트 + // 검색 모드일 때 말풍선 업데이트 로직 유지 if self.viewModel.bottomType == .search || self.viewModel.bottomType == nil { - // 방해물(업데이트 생략 조건문) 제거! self.showOrUpdatePersistentBalloon( isFirstVisit: self.isFirstVisit, isServiceRegion: ok, @@ -956,64 +968,105 @@ extension MainViewController { } @objc private func handleBallonTap() { - // 알람 등록 후(departure 상태)일 때만 반응 - guard viewModel.bottomType == .departure else { return } + safeStartJump() // 캐릭터 점프 - amp_track(.character_click) + // 1. 게스트 모드일 때 + if viewModel.isGuest { + // [요청사항 반영] 서비스 지역 외라면 즉시 로그인 시트 노출 (1-tap) + if latestIsServiceRegion == false { + presentLoginAlert() + return + } + + // 서비스 지역 내라면 문구 노출 후 2초 뒤 자동 로그인 (handleGuestBallonTap으로 이동) + handleGuestBallonTap() + return + } - let cycle = postAlarmTapIndex % 3 + // 2. 회원 모드일 때 (알람 등록된 상태에서만 동작) + guard viewModel.bottomType == .departure else { return } - // 추가: 현재 알람이 울린 상태인지 확인 + amp_track(.character_click) let isAlarmFired = UserDefaultsWrapper.shared.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false + let cycle = postAlarmTapIndex % 3 if cycle == 0 { - // 요금 정보 표시 (동적 로딩) - let now = CACurrentMediaTime() - if (now - lastFareRefreshTime) > fareRefreshInterval && !isFetchingFare { - isFetchingFare = true - Task { - defer { Task { @MainActor in self.isFetchingFare = false } } - do { - 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.lastFareRefreshTime = CACurrentMediaTime() - let displayFare = self.viewModel.isGuest ? "???원" : "\(fareStr)원" - self.showTransientBalloon(isFare: true, text: displayFare) - self.postAlarmTapIndex += 1 - } - } catch { - await MainActor.run { - self.showTransientBalloon(isFare: false, text: "택시비 조회에 실패했어요") - self.postAlarmTapIndex += 1 - } - } - } - } else { - let fareStr = latestFareString ?? "???" - let displayFare = viewModel.isGuest ? "???원" : "\(fareStr)원" - showTransientBalloon(isFare: true, text: displayFare) - self.postAlarmTapIndex += 1 - } - + handleMemberFareTap() // 요금 정보 조회/표시 로직 } else if cycle == 1 { - // 수정: 알람이 울렸다면 이 메시지를 건너뛰고 다음 메시지를 띄움 if isAlarmFired { showTransientBalloon(isFare: false, text: "교통 상황에 따라 시간이 달라질 수 있어요") - // cycle 1을 건너뛰었으므로 다음 탭이 cycle 0(택시비)으로 돌아가도록 index를 2 올려줌 postAlarmTapIndex += 2 } else { showTransientBalloon(isFare: false, text: "시간에 맞춰 알림을 드릴게요") postAlarmTapIndex += 1 } - } else { showTransientBalloon(isFare: false, text: "교통 상황에 따라 시간이 달라질 수 있어요") postAlarmTapIndex += 1 } } + + /// 게스트 전용: 서비스 지역 내(서울/경기/인천)에서 문구 노출 후 2초 뒤 자동 로그인 + private func handleGuestBallonTap() { + // 기존에 예약된 타이머가 있다면 취소 (중복 실행 방지) + guestLoginWorkItem?.cancel() + + ballonView.layer.removeAllAnimations() + ballonView.isHidden = false + ballonView.alpha = 1 + + ballonView.setupTitle(topMessage: nil, bottomMessage: "택시비가 궁금하면 로그인해봐요!") + ballonView.animateStaggered(secondaryDelay: 0, fade: 0.25) + + let workItem = DispatchWorkItem { [weak self] in + guard let self = self, self.viewModel.isGuest else { return } + + // 현재 떠있는 화면이 없을 때만 로그인 시트 노출 + if self.presentedViewController == nil { + self.presentLoginAlert() + } + } + + self.guestLoginWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem) + } + + /// 회원 전용: 실시간 택시 요금 조회 로직 + private func handleMemberFareTap() { + let now = CACurrentMediaTime() + + if (now - lastFareRefreshTime) < fareRefreshInterval || isFetchingFare { + let fareStr = latestFareString ?? "???" + showTransientBalloon(isFare: true, text: "\(fareStr)원") + postAlarmTapIndex += 1 + } else { + isFetchingFare = true + Task { [weak self] in // weak self 추가 + guard let self = self else { return } + defer { + Task { @MainActor in self.isFetchingFare = false } + } + + do { + 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.lastFareRefreshTime = CACurrentMediaTime() + self.showTransientBalloon(isFare: true, text: "\(fareStr)원") + self.postAlarmTapIndex += 1 + } + } catch { + await MainActor.run { + self.showTransientBalloon(isFare: false, text: "택시비 조회에 실패했어요") + self.postAlarmTapIndex += 1 + } + } + } + } + } } // MARK: - Map Delegate & Gesture @@ -1242,7 +1295,7 @@ extension MainViewController { } else { if viewModel.isGuest { // 비로그인: 가이드 없이 ???원만 노출 - ballonView.separationTitle(grayMessage: "여기서 막차 놓치면 택시비 ", whiteMessage: "약 ???원", showTopLine: false) + ballonView.separationTitle(grayMessage: "여기서 막차 놓치면 택시비 ", whiteMessage: "???원", showTopLine: false) } else { // 로그인 상태 if let fare = fareStr { @@ -1315,4 +1368,25 @@ extension MainViewController { balloonHideWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem) } + + private func observeLoginDismissal() { + NotificationCenter.default.publisher(for: NSNotification.Name("LoginSheetDismissed")) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self = self else { return } + + // 로그인 시트가 내려갔으니 guestTapCount도 초기화해주는 게 자연스러워요 + self.guestTapCount = 0 + + // 현재 검색 모드라면 다시 고정 말풍선 노출 + if self.viewModel.bottomType == .search || self.viewModel.bottomType == nil { + self.showOrUpdatePersistentBalloon( + isFirstVisit: self.isFirstVisit, + isServiceRegion: self.latestIsServiceRegion, + fareStr: self.latestFareString + ) + } + } + .store(in: &cancellables) + } } diff --git a/Atcha-iOS/Presentation/Login/LoginViewController.swift b/Atcha-iOS/Presentation/Login/LoginViewController.swift index a4c63712..a1b4c52f 100644 --- a/Atcha-iOS/Presentation/Login/LoginViewController.swift +++ b/Atcha-iOS/Presentation/Login/LoginViewController.swift @@ -245,6 +245,7 @@ extension LoginViewController { self.containerView.transform = CGAffineTransform(translationX: 0, y: self.sheetHeight) }) { _ in self.dismiss(animated: false) { + NotificationCenter.default.post(name: NSNotification.Name("LoginSheetDismissed"), object: nil) self.viewModel.loginCancelled?() } } diff --git a/Atcha-iOS/Presentation/Login/LoginViewModel.swift b/Atcha-iOS/Presentation/Login/LoginViewModel.swift index d26b1a94..39ab529a 100644 --- a/Atcha-iOS/Presentation/Login/LoginViewModel.swift +++ b/Atcha-iOS/Presentation/Login/LoginViewModel.swift @@ -74,6 +74,12 @@ extension LoginViewModel { AmplitudeManager.shared.bindUser(id: String(id)) AmplitudeManager.shared.flush() + + DiscordWebhookManager.shared.sendAuthLog( + event: .login, + userID: String(id), + provider: type == .kakao ? "카카오" : "애플" + ) } UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) diff --git a/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift b/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift index 14f80ec8..75c1a8e8 100644 --- a/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift +++ b/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift @@ -18,6 +18,8 @@ enum AtcahPopuInfo { case arrive case scheduledArrive case serverError + case update_essential + case update_recommended var title: String { switch self { @@ -31,6 +33,8 @@ enum AtcahPopuInfo { case .arrive: return "목적지 부근에 도착해\n안내를 종료합니다" case .scheduledArrive: return "예정된 도착 시간이 지나\n알람이 자동으로 종료됐어요" case .serverError: return "잠시 후 다시 시도해주세요\n앗차팀에서 확인 및 대응 중입니다" + case .update_essential: return "더 좋아진 앗차를 사용하기 위해\n업데이트가 필요해요" + case .update_recommended: return "더 좋아진 앗차를 사용하기 위해\n업데이트를 권장해요" } } @@ -46,6 +50,8 @@ enum AtcahPopuInfo { case .arrive: return "확인" case .scheduledArrive: return "닫기" case .serverError: return "확인" + case .update_essential: return "업데이트" + case .update_recommended: return "업데이트" } } @@ -68,6 +74,7 @@ enum AtcahPopuInfo { switch self { case .alarm, .re_register: return "돌아가기" case .course: return "돌아가기" + case .update_recommended: return "나중에" default: return "취소" } } diff --git a/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift b/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift index ed0b20d2..10d0cd7d 100644 --- a/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift +++ b/Atcha-iOS/Presentation/Popup/AtchaPopupViewController.swift @@ -80,7 +80,7 @@ final class AtchaPopupViewController: BaseViewController { confirmButton.setAttributedTitle(confirmAttr, for: .normal) confirmButton.backgroundColor = info.confrimBackgroundColor - if info != .alarmTimeout || info != .arrive || info != .serverError || info != .scheduledArrive { + if info != .alarmTimeout || info != .arrive || info != .serverError || info != .scheduledArrive || info != .update_essential { let cancelAttr = AtchaFont.B5_SB_14(info.cancelTitle, color: info.cancelForegroundColor) cancelButton.setAttributedTitle(cancelAttr, for: .normal) cancelButton.backgroundColor = info.cancelBackgroundColor @@ -93,7 +93,7 @@ final class AtchaPopupViewController: BaseViewController { $0.removeFromSuperview() } - if info == .alarmTimeout || info == .arrive || info == .serverError || info == .scheduledArrive { + if info == .alarmTimeout || info == .arrive || info == .serverError || info == .scheduledArrive || info == .update_essential { buttonStackView.addArrangedSubview(confirmButton) } else { buttonStackView.addArrangedSubview(cancelButton) diff --git a/Atcha-iOS/Presentation/Setting/PushAlarmSheet/PushAlarmSheetViewController.swift b/Atcha-iOS/Presentation/Setting/PushAlarmSheet/PushAlarmSheetViewController.swift index 92d09f0e..77a50c59 100644 --- a/Atcha-iOS/Presentation/Setting/PushAlarmSheet/PushAlarmSheetViewController.swift +++ b/Atcha-iOS/Presentation/Setting/PushAlarmSheet/PushAlarmSheetViewController.swift @@ -203,13 +203,10 @@ final class PushAlarmSheetViewController: BaseViewController Void)? - init(navigationController: UINavigationController, diContainer: SplashDIContainer) { + init(navigationController: UINavigationController, diContainer: SplashDIContainer, launchType: LaunchType) { self.navigationController = navigationController self.diContainer = diContainer + self.launchType = launchType } func start() { - let viewModel = diContainer.makeSplashViewModel() + let viewModel = diContainer.makeSplashViewModel(launchType: launchType) let viewController = diContainer.makeSplashViewController(viewModel: viewModel) viewModel.routerHandler = { [weak self] router in self?.routerHandler?(router) diff --git a/Atcha-iOS/Presentation/Splash/SplashViewController.swift b/Atcha-iOS/Presentation/Splash/SplashViewController.swift index 412c0578..f91f003b 100644 --- a/Atcha-iOS/Presentation/Splash/SplashViewController.swift +++ b/Atcha-iOS/Presentation/Splash/SplashViewController.swift @@ -26,7 +26,6 @@ final class SplashViewController: BaseViewController { setupUI() setupBindings() - viewModel.makeInitialFlow() } private func setupUI() { @@ -41,29 +40,12 @@ final class SplashViewController: BaseViewController { make.edges.equalToSuperview() } logoStackView.snp.makeConstraints { make in - make.centerX.equalTo(view.safeAreaLayoutGuide) - make.centerY.equalTo(view.safeAreaLayoutGuide) + make.centerX.equalToSuperview() + make.centerY.equalToSuperview() } } private func setupBindings() { - viewModel.$isLoading - .receive(on: DispatchQueue.main) - .sink { isLoading in - // 로딩 UI 표시/숨김 - print("isLoading: \(isLoading)") - } - .store(in: &cancellables) - - viewModel.$errorMessage - .compactMap { $0 } - .receive(on: DispatchQueue.main) - .sink { message in - // Alert 띄우기 - print("Error: \(message)") - } - .store(in: &cancellables) - viewModel.$appVersionInfo .compactMap { $0 } .receive(on: DispatchQueue.main) @@ -75,15 +57,16 @@ final class SplashViewController: BaseViewController { } private func updateAppVersion(_ version: String) { - let severVersion: String = version - let appVersion: String = AppInfoProvider.versionWithV - - if isVersion(severVersion, lessThan: appVersion) { - viewModel.updateAppVersion(version: appVersion) - } else { - print("강제 업데이트 표출해주세요!!") + let serverVersion = version + let appVersion = AppInfoProvider.versionWithV + + // 서버 버전이 앱 버전보다 높다면 업데이트가 필요한 상황 + if isVersion(appVersion, lessThan: serverVersion) { + showUpdatePopup(isEssential: false) + } else { + viewModel.makeInitialFlow() + } } - } /// lhs < rhs 인지 비교 (서버 버전 < 앱 버전?) private func isVersion(_ lhs: String, lessThan rhs: String) -> Bool { @@ -99,4 +82,33 @@ final class SplashViewController: BaseViewController { } return false } + + private func showUpdatePopup(isEssential: Bool) { + // 1. 팝업 뷰모델 생성 (info 타입은 프로젝트 정의에 맞게 조절하세요) + let popupVM = AtchaPopupViewModel(info: isEssential ? .update_essential : .update_recommended) + let popupVC = AtchaPopupViewController(viewModel: popupVM) + + // 2. [업데이트하기] 버튼 로직 + popupVC.confirmButton.addAction(UIAction { _ in + let appID = "6747877903" + if let url = URL(string: "itms-apps://itunes.apple.com/app/id\(appID)"), + UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + }, for: .touchUpInside) + + // 3. [닫기/취소] 버튼 로직 + popupVC.cancelButton.addAction(UIAction { [weak self, weak popupVC] _ in + popupVC?.dismiss(animated: false) + + if isEssential { + print("필수 업데이트입니다. 진행할 수 없습니다.") + } else { + self?.viewModel.makeInitialFlow() + } + }, for: .touchUpInside) + + popupVC.modalPresentationStyle = .overFullScreen + present(popupVC, animated: false) + } } diff --git a/Atcha-iOS/Presentation/Splash/SplashViewModel.swift b/Atcha-iOS/Presentation/Splash/SplashViewModel.swift index a346a080..e9d1cfd4 100644 --- a/Atcha-iOS/Presentation/Splash/SplashViewModel.swift +++ b/Atcha-iOS/Presentation/Splash/SplashViewModel.swift @@ -7,9 +7,21 @@ import Foundation +enum LaunchType { + case main // 일반 진입 (1.5초 대기) + case fast // 빠른 진입 (0초 대기) - 푸시 알림 등 + + var minimumDelay: Double { + switch self { + case .main: return 1.2 + case .fast: return 0.8 + } + } +} + final class SplashViewModel: BaseViewModel { @Published private(set) var appVersionInfo: String? - + private let launchType: LaunchType private let fetchUserUseCase: FetchUserUseCase private let checkAppVersionUseCase: CheckAppVersionUseCase private let updateAppVersionUseCase: UpdateAppVersionUseCase @@ -20,24 +32,34 @@ final class SplashViewModel: BaseViewModel { init(fetchUserUseCase: FetchUserUseCase, checkAppVersionUseCase: CheckAppVersionUseCase, updateAppVersionUseCase: UpdateAppVersionUseCase, - tokenStorage: TokenStorage) { + tokenStorage: TokenStorage, + launchType: LaunchType) { self.fetchUserUseCase = fetchUserUseCase self.checkAppVersionUseCase = checkAppVersionUseCase self.updateAppVersionUseCase = updateAppVersionUseCase self.tokenStorage = tokenStorage + self.launchType = launchType super.init() self.checkAppVersion() } func checkAppVersion() { - print(#function) Task { - setLoading(true) - defer { self.setLoading(false) } + let startTime = Date() + let minDelay = launchType.minimumDelay + do { let versionInfo = try await checkAppVersionUseCase.execute() - appVersionInfo = versionInfo + let elapsed = Date().timeIntervalSince(startTime) + if elapsed < minDelay { + let remaining = minDelay - elapsed + try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) + } + + await MainActor.run { + self.appVersionInfo = versionInfo + } } catch { handleError(error) } diff --git a/Atcha-iOS/Presentation/User/Home/HomeFindViewController.swift b/Atcha-iOS/Presentation/User/Home/HomeFindViewController.swift index 542f9f9c..ccc41a43 100644 --- a/Atcha-iOS/Presentation/User/Home/HomeFindViewController.swift +++ b/Atcha-iOS/Presentation/User/Home/HomeFindViewController.swift @@ -51,7 +51,7 @@ final class HomeFindViewController: BaseViewController, backButton) mapContainerView.delegate = self - flagImageView.image = UIImage.settingLocationMark + flagImageView.image = UIImage.settingHomeMark backButton.setImage(UIImage.chevronLeft, for: .normal) backButton.tintColor = .white backButton.backgroundColor = .black diff --git a/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift b/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift index b3be003b..5ef7f4c7 100644 --- a/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift +++ b/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift @@ -274,6 +274,12 @@ extension HomeFindViewModel { AmplitudeManager.shared.track( .signup ) + + DiscordWebhookManager.shared.sendAuthLog( + event: .signup, + userID: String(id), + provider: provider == 0 ? "카카오" : "애플" + ) } else { print("회원가입 응답에 lat/lon 없음") } diff --git a/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift b/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift index 212cd1af..53a0956c 100644 --- a/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift +++ b/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift @@ -27,10 +27,17 @@ final class MyAccountViewModel: BaseViewModel { Task { do { let _ = try await logoutUseCase.excute() + + let userId = UserDefaultsWrapper.shared.integer(forKey: UserDefaultsWrapper.Key.userId.rawValue) ?? 0 + DiscordWebhookManager.shared.sendAuthLog( + event: .logout, + userID: String(userId) + ) + AmplitudeManager.shared.track(.logout) AmplitudeManager.shared.reset() - tokenStorage.clearAllTokens() + tokenStorage.clearAllTokens() UserDefaultsWrapper.shared.removeAll() locationStateHolder.clear() diff --git a/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift b/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift index 9ea0f983..ca2f3816 100644 --- a/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift +++ b/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift @@ -28,6 +28,14 @@ final class WithdrawViewModel: BaseViewModel { let reason = request.reason? .trimmingCharacters(in: .whitespacesAndNewlines) + let userId = UserDefaultsWrapper.shared.integer(forKey: UserDefaultsWrapper.Key.userId.rawValue) ?? 0 + + DiscordWebhookManager.shared.sendAuthLog( + event: .withdraw, + userID: String(userId), + reason: reason + ) + AmplitudeManager.shared.track( .withdraw, [AmplitudePropertyKey.withdrawReason.rawValue: reason ?? "unknown"] @@ -37,6 +45,7 @@ final class WithdrawViewModel: BaseViewModel { tokenStorage.clearAllTokens() UserDefaultsWrapper.shared.removeAll() locationStateHolder.clear() + UserDefaultsWrapper.shared.set(true, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.hasSeenIntro.rawValue) signOutFinish?() } catch {