Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,6 @@ StageConfig.xcconfig
LiveConfig.xcconfig
BaseConfig.xcconfig

.claude

Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/.DS_Store
6 changes: 3 additions & 3 deletions Atcha-iOS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down Expand Up @@ -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)";
Expand Down Expand Up @@ -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 = "";
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
155 changes: 78 additions & 77 deletions Atcha-iOS/Presentation/Location/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ final class MainViewController: BaseViewController<MainViewModel>,
return viewModel.isGuideActiveInSession
}
private var guestTapCount = 0
private var guestLoginWorkItem: DispatchWorkItem?

// MARK: - Life Cycle

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}
}
}
}
}
Expand Down Expand Up @@ -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
Expand Down
46 changes: 45 additions & 1 deletion Atcha-iOS/Presentation/Lock/LockViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class LockViewController: BaseViewController<LockViewModel> {
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")
Expand Down Expand Up @@ -71,7 +72,7 @@ final class LockViewController: BaseViewController<LockViewModel> {

// 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 = [
Expand All @@ -96,6 +97,9 @@ final class LockViewController: BaseViewController<LockViewModel> {
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),
Expand Down Expand Up @@ -137,6 +141,12 @@ final class LockViewController: BaseViewController<LockViewModel> {
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() {
Expand Down Expand Up @@ -175,6 +185,10 @@ final class LockViewController: BaseViewController<LockViewModel> {
)
}

@objc private func cancelAlarmTapped() {
showAlarmCancelPopup()
}

private func observeAlarmTimeout() {
NotificationCenter.default.publisher(for: NSNotification.Name("alarmDidTimeout"))
.receive(on: RunLoop.main)
Expand All @@ -184,4 +198,34 @@ final class LockViewController: BaseViewController<LockViewModel> {
}
.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)
}
}
7 changes: 6 additions & 1 deletion Atcha-iOS/Presentation/Main/MainCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
}
}
Expand Down
Loading
Loading