diff --git a/.gitignore b/.gitignore index 0896441e..cc5cc875 100644 --- a/.gitignore +++ b/.gitignore @@ -114,4 +114,6 @@ StageConfig.xcconfig LiveConfig.xcconfig BaseConfig.xcconfig +.claude + Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/.DS_Store diff --git a/Atcha-iOS.xcodeproj/project.pbxproj b/Atcha-iOS.xcodeproj/project.pbxproj index 862e7052..353cec7a 100644 --- a/Atcha-iOS.xcodeproj/project.pbxproj +++ b/Atcha-iOS.xcodeproj/project.pbxproj @@ -2539,7 +2539,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9.6; + MARKETING_VERSION = 1.9.7; PRODUCT_BUNDLE_IDENTIFIER = com.atcha.iOS; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2586,7 +2586,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9.6; + MARKETING_VERSION = 1.9.7; OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.atcha.iOS; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2634,7 +2634,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9.6; + MARKETING_VERSION = 1.9.7; PRODUCT_BUNDLE_IDENTIFIER = com.atcha.iOS; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Lock/alarm_cancel.imageset/Contents.json b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Lock/alarm_cancel.imageset/Contents.json new file mode 100644 index 00000000..7ef96f32 --- /dev/null +++ b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Lock/alarm_cancel.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "alarm_cancel.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "alarm_cancel@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "alarm_cancel@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Lock/alarm_cancel.imageset/alarm_cancel.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Lock/alarm_cancel.imageset/alarm_cancel.png new file mode 100644 index 00000000..2c95d12d Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Lock/alarm_cancel.imageset/alarm_cancel.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Lock/alarm_cancel.imageset/alarm_cancel@2x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Lock/alarm_cancel.imageset/alarm_cancel@2x.png new file mode 100644 index 00000000..7549f6b6 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Lock/alarm_cancel.imageset/alarm_cancel@2x.png differ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Lock/alarm_cancel.imageset/alarm_cancel@3x.png b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Lock/alarm_cancel.imageset/alarm_cancel@3x.png new file mode 100644 index 00000000..f7316985 Binary files /dev/null and b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Lock/alarm_cancel.imageset/alarm_cancel@3x.png differ diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index d59b7c80..9ed9b862 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -79,6 +79,7 @@ final class MainViewController: BaseViewController, return viewModel.isGuideActiveInSession } private var guestTapCount = 0 + private var guestLoginWorkItem: DispatchWorkItem? // MARK: - Life Cycle @@ -778,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, @@ -966,100 +968,103 @@ extension MainViewController { } @objc private func handleBallonTap() { - safeStartJump() + safeStartJump() // 캐릭터 점프 + // 1. 게스트 모드일 때 if viewModel.isGuest { + // [요청사항 반영] 서비스 지역 외라면 즉시 로그인 시트 노출 (1-tap) if latestIsServiceRegion == false { presentLoginAlert() return } - // 서비스 지역 내라면 기존의 2-tap 로직(문구 노출 후 로그인) 유지 + // 서비스 지역 내라면 문구 노출 후 2초 뒤 자동 로그인 (handleGuestBallonTap으로 이동) handleGuestBallonTap() return } - // 알람 등록 후(departure 상태)일 때만 반응 + // 2. 회원 모드일 때 (알람 등록된 상태에서만 동작) guard viewModel.bottomType == .departure else { return } amp_track(.character_click) - - let cycle = postAlarmTapIndex % 3 - - // 추가: 현재 알람이 울린 상태인지 확인 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() { - if guestTapCount == 0 { - // 첫 번째 터치: "궁금하면 로그인 해봐요!" - guestTapCount = 1 - - ballonView.layer.removeAllAnimations() - ballonView.isHidden = false - ballonView.alpha = 1 - - ballonView.setupTitle(topMessage: nil, bottomMessage: "택시비가 궁금하면 로그인해봐요!") - ballonView.animateStaggered(secondaryDelay: 0, fade: 0.25) + // 기존에 예약된 타이머가 있다면 취소 (중복 실행 방지) + 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() + 1.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 { - // 두 번째 터치: 로그인 시트 노출 - guestTapCount = 0 // 카운트 리셋 - - // [중요] 말풍선을 즉시 숨김 상태로 만들어야 시트가 내려간 뒤 다시 Persistent(???원) 메시지가 나타납니다. - ballonView.layer.removeAllAnimations() - ballonView.isHidden = true - ballonView.alpha = 0 - - presentLoginAlert() + 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 + } + } + } } } } @@ -1281,10 +1286,6 @@ extension MainViewController { private func showOrUpdatePersistentBalloon(isFirstVisit: Bool, isServiceRegion: Bool?, fareStr: String?) { guard !isShowingToast else { return } - if viewModel.isGuest && guestTapCount == 1 { - return - } - // [수정] 우리가 정의한 로그인 기반 가이드 로직 적용 let showGuideLine = shouldShowMapGuide let topText: String? = showGuideLine ? "지도를 움직여 출발지를 설정해요" : nil diff --git a/Atcha-iOS/Presentation/Lock/LockViewController.swift b/Atcha-iOS/Presentation/Lock/LockViewController.swift index d90fbce8..927cfb46 100644 --- a/Atcha-iOS/Presentation/Lock/LockViewController.swift +++ b/Atcha-iOS/Presentation/Lock/LockViewController.swift @@ -15,6 +15,7 @@ final class LockViewController: BaseViewController { private let titleLabel: UILabel = UILabel() private let taxiFareLabel: UILabel = UILabel() private let startButton: AtchaButton = AtchaButton(text: "출발하기", size: .h52, style: .filled(.primary)) + private let cancelImageView: UIImageView = UIImageView() private let detailRouteButton: AtchaButton = AtchaButton(text: "더 늦은 경로 확인하기", size: .h52, style: .filled(.opacity)) private let bottomStack: UIStackView = UIStackView() private var lottieAnimationView: LottieAnimationView = LottieAnimationView(name: "Alarm") @@ -71,7 +72,7 @@ final class LockViewController: BaseViewController { // MARK: - Lock UI private func setupUI() { - view.addSubViews(backgroundImageView, lottieAnimationView, gradientView, logoImageView, titleLabel, taxiFareLabel, bottomStack) + view.addSubViews(backgroundImageView, lottieAnimationView, gradientView, logoImageView, titleLabel, taxiFareLabel, bottomStack, cancelImageView) backgroundImageView.image = UIImage.lockBackground gradient.colors = [ @@ -96,6 +97,9 @@ final class LockViewController: BaseViewController { startButton.addTarget(self, action: #selector(startTapped), for: .touchUpInside) + cancelImageView.image = UIImage.alarmCancel + cancelImageView.isUserInteractionEnabled = true + cancelImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelAlarmTapped))) detailRouteButton.addTarget(self, action: #selector(detailRouteTapped), @@ -137,6 +141,12 @@ final class LockViewController: BaseViewController { make.leading.equalToSuperview().offset(20) make.trailing.equalToSuperview().inset(20) } + + cancelImageView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide.snp.top).inset(18) + make.trailing.equalToSuperview().inset(16) + make.size.equalTo(24) + } } @objc private func startTapped() { @@ -175,6 +185,10 @@ final class LockViewController: BaseViewController { ) } + @objc private func cancelAlarmTapped() { + showAlarmCancelPopup() + } + private func observeAlarmTimeout() { NotificationCenter.default.publisher(for: NSNotification.Name("alarmDidTimeout")) .receive(on: RunLoop.main) @@ -184,4 +198,34 @@ final class LockViewController: BaseViewController { } .store(in: &cancellables) } + + private func showAlarmCancelPopup() { + AlarmManager.shared.stopAlarm() + + let popupVM = AtchaPopupViewModel(info: .alarm_cancel) + let popupVC = AtchaPopupViewController(viewModel: popupVM) + + popupVC.cancelButton.addAction(UIAction { [weak popupVC] _ in + popupVC?.dismiss(animated: false) + }, for: .touchUpInside) + + popupVC.confirmButton.addAction(UIAction { [weak self, weak popupVC] _ in + guard let self else { return } + popupVC?.dismiss(animated: false) + + self.viewModel.cancelLockScreenTimer() + AlarmManager.shared.stopAlarm() + AlarmManager.shared.removeAllAlarmNotificationsExceptAutoStop() + + UserDefaultsWrapper.shared.set( + false, + forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue + ) + + viewModel.routerHandler?(.dismissLockScreen) + }, for: .touchUpInside) + + popupVC.modalPresentationStyle = .overFullScreen + present(popupVC, animated: false) + } } diff --git a/Atcha-iOS/Presentation/Main/MainCoordinator.swift b/Atcha-iOS/Presentation/Main/MainCoordinator.swift index 8c5e379d..0bca15ec 100644 --- a/Atcha-iOS/Presentation/Main/MainCoordinator.swift +++ b/Atcha-iOS/Presentation/Main/MainCoordinator.swift @@ -320,7 +320,12 @@ final class MainCoordinator: NSObject { context: .afterReigster)) } case .dismissLockScreen: - self?.navigationController.dismiss(animated: true) + self?.mainViewModel?.stopAlarmTimeoutTimer() + self?.navigationController.dismiss(animated: true) { [weak self] in + self?.mainViewModel?.alarmDelete() + self?.mainViewModel?.bottomType = .search + self?.mainViewModel?.removeLegInfoAndAddress() + } default: do {} } } diff --git a/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift b/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift index 75c1a8e8..3c1c1ff1 100644 --- a/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift +++ b/Atcha-iOS/Presentation/Popup/AtchaPopupInfo.swift @@ -20,6 +20,7 @@ enum AtcahPopuInfo { case serverError case update_essential case update_recommended + case alarm_cancel var title: String { switch self { @@ -35,6 +36,7 @@ enum AtcahPopuInfo { case .serverError: return "잠시 후 다시 시도해주세요\n앗차팀에서 확인 및 대응 중입니다" case .update_essential: return "더 좋아진 앗차를 사용하기 위해\n업데이트가 필요해요" case .update_recommended: return "더 좋아진 앗차를 사용하기 위해\n업데이트를 권장해요" + case .alarm_cancel: return "알람을 종료할까요?" } } @@ -52,12 +54,13 @@ enum AtcahPopuInfo { case .serverError: return "확인" case .update_essential: return "업데이트" case .update_recommended: return "업데이트" + case .alarm_cancel: return "종료하기" } } var confrimBackgroundColor: UIColor { switch self { - case .alarm, .re_register, .course, .arrive: return .main + case .alarm, .re_register, .course, .arrive, .alarm_cancel: return .main case .alarmTimeout, .serverError, .scheduledArrive: return .gray910 default: return .white } @@ -73,7 +76,7 @@ enum AtcahPopuInfo { var cancelTitle: String { switch self { case .alarm, .re_register: return "돌아가기" - case .course: return "돌아가기" + case .course, .alarm_cancel: return "돌아가기" case .update_recommended: return "나중에" default: return "취소" } diff --git a/Atcha-iOS/Presentation/Splash/SplashViewController.swift b/Atcha-iOS/Presentation/Splash/SplashViewController.swift index f91f003b..8c6551f9 100644 --- a/Atcha-iOS/Presentation/Splash/SplashViewController.swift +++ b/Atcha-iOS/Presentation/Splash/SplashViewController.swift @@ -46,27 +46,20 @@ final class SplashViewController: BaseViewController { } private func setupBindings() { - viewModel.$appVersionInfo - .compactMap { $0 } - .receive(on: DispatchQueue.main) - .sink { [weak self] versionInfo in - guard let self else { return } - updateAppVersion(versionInfo) - } - .store(in: &cancellables) + fetchAppStoreVersion() } - private func updateAppVersion(_ version: String) { - let serverVersion = version - let appVersion = AppInfoProvider.versionWithV - - // 서버 버전이 앱 버전보다 높다면 업데이트가 필요한 상황 - if isVersion(appVersion, lessThan: serverVersion) { - showUpdatePopup(isEssential: false) - } else { - viewModel.makeInitialFlow() - } - } +// private func updateAppVersion(_ version: String) { +// 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 { @@ -84,31 +77,54 @@ final class SplashViewController: BaseViewController { } private func showUpdatePopup(isEssential: Bool) { - // 1. 팝업 뷰모델 생성 (info 타입은 프로젝트 정의에 맞게 조절하세요) - let popupVM = AtchaPopupViewModel(info: isEssential ? .update_essential : .update_recommended) - let popupVC = AtchaPopupViewController(viewModel: popupVM) + // 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) - // 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) + if isEssential { + print("필수 업데이트입니다. 진행할 수 없습니다.") + } else { + self?.viewModel.makeInitialFlow() + } + }, for: .touchUpInside) + + popupVC.modalPresentationStyle = .overFullScreen + present(popupVC, animated: false) + } + + private func fetchAppStoreVersion() { + guard let url = URL(string: "https://itunes.apple.com/lookup?id=6747877903&country=kr") else { return } + + URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let self, + let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let results = json["results"] as? [[String: Any]], + let appStoreVersion = results.first?["version"] as? String else { return } - // 3. [닫기/취소] 버튼 로직 - popupVC.cancelButton.addAction(UIAction { [weak self, weak popupVC] _ in - popupVC?.dismiss(animated: false) + DispatchQueue.main.async { + let appVersion = AppInfoProvider.versionWithV + let storeVersion = "v\(appStoreVersion)" - if isEssential { - print("필수 업데이트입니다. 진행할 수 없습니다.") + if self.isVersion(appVersion, lessThan: storeVersion) { + self.showUpdatePopup(isEssential: false) } else { - self?.viewModel.makeInitialFlow() + self.viewModel.makeInitialFlow() } - }, for: .touchUpInside) - - popupVC.modalPresentationStyle = .overFullScreen - present(popupVC, animated: false) - } + } + }.resume() + } }