Skip to content

Commit f2f80fe

Browse files
authored
Merge pull request #318 from Atcha-Project/bugfix/#317
[BUGFIX] QA 반영(v1.9.1)
2 parents 9bcc510 + fd006b7 commit f2f80fe

21 files changed

Lines changed: 458 additions & 169 deletions

Atcha-iOS/App/DIContainer/Course/CourseDIContainer.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ final class CourseDIContainer {
2424
self.tokenStorage = tokenStorage
2525
}
2626

27-
func makeCourseSearchViewModel(startLat: String, startLon: String, startAddress: String) -> CourseSearchViewModel {
27+
func makeCourseSearchViewModel(startLat: String, startLon: String, startAddress: String, context: CourseSearchContext) -> CourseSearchViewModel {
2828
let courseUseCase = CourseUseCaseImpl(
2929
repository: CourseRepositoryImpl(apiService: apiService, tokenStorage: tokenStorage)
3030
)
@@ -35,7 +35,8 @@ final class CourseDIContainer {
3535
alarmUseCase: alarmUseCase,
3636
startLat: startLat,
3737
startLon: startLon,
38-
startAddress: startAddress)
38+
startAddress: startAddress,
39+
context: context)
3940
}
4041

4142
func makeCourseSearchViewController(viewModel: CourseSearchViewModel) -> UIViewController {

Atcha-iOS/App/SceneDelegate.swift

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,23 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
3737
// This occurs shortly after the scene enters the background, or when its session is discarded.
3838
// Release any resources associated with this scene that can be re-created the next time the scene connects.
3939
// The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
40-
if let _: LegInfo = UserDefaultsWrapper.shared.object(forKey: UserDefaultsWrapper.Key.legInfo.rawValue, of: LegInfo.self) {
41-
AlarmManager.shared.sendBackgroundPush(title: "앗차를 다시 켜주세요",
42-
body: "제 시간에 출발 시간을 알려드릴 수 있도록 앱을 다시 실행해 주세요.")
40+
41+
let wrapper = UserDefaultsWrapper.shared
42+
43+
if let _: LegInfo = wrapper.object(forKey: UserDefaultsWrapper.Key.legInfo.rawValue, of: LegInfo.self) {
44+
let didFire = wrapper.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false
45+
46+
if didFire {
47+
AlarmManager.shared.sendBackgroundPush(
48+
title: "앗차를 다시 켜주세요",
49+
body: "목적지까지 안내할 수 있도록 앱을 다시 실행해 주세요"
50+
)
51+
} else {
52+
AlarmManager.shared.sendBackgroundPush(
53+
title: "앗차를 다시 켜주세요",
54+
body: "제 시간에 출발 시간을 알려드릴 수 있도록 앱을 다시 실행해 주세요."
55+
)
56+
}
4357
}
4458
}
4559

Atcha-iOS/Core/Manager/Alarm/AlarmManager.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ final class AlarmManager {
3434
private var isPreviewing = false
3535
private var hapticEngine: CHHapticEngine?
3636
private var autoStopWorkItem: DispatchWorkItem?
37+
private var arrivalTimeoutWorkItem: DispatchWorkItem?
3738

3839
// MARK: - Init
3940
private init() {
@@ -454,6 +455,7 @@ extension AlarmManager {
454455
enum AlarmNotificationID {
455456
static let autoStopInfo = "atcha.alarm.autostop"
456457
static let tenMinutesBefore = "atcha.alarm.tenMinutesBefore"
458+
static let scheduledArrivalTimeout = "atcha.arrival.timeout"
457459
}
458460

459461
extension AlarmManager {
@@ -640,3 +642,51 @@ extension AlarmManager {
640642
DispatchQueue.main.asyncAfter(deadline: .now() + 120.0, execute: workItem)
641643
}
642644
}
645+
646+
extension AlarmManager {
647+
648+
// 도착 10분 후 자동 종료 예약 함수
649+
func scheduleArrivalTimeout(at arrivalDate: Date) {
650+
// 기존에 예약된 게 있다면 먼저 취소
651+
cancelArrivalTimeout()
652+
653+
let timeoutDate = arrivalDate.addingTimeInterval(10 * 60) // 도착 시간 + 10분
654+
let timeInterval = timeoutDate.timeIntervalSinceNow
655+
656+
// 만약 이미 시간이 지났다면 예약하지 않음
657+
guard timeInterval > 0 else { return }
658+
659+
// 1. 백그라운드용 로컬 푸시 예약 (시스템이 정확한 시간에 띄워줌)
660+
let content = UNMutableNotificationContent()
661+
content.title = "막차 안내 종료"
662+
content.body = "예정된 도착 시간이 지나 알람이 자동으로 종료됐어요"
663+
content.sound = .default
664+
665+
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: false)
666+
let request = UNNotificationRequest(
667+
identifier: AlarmNotificationID.scheduledArrivalTimeout,
668+
content: content,
669+
trigger: trigger
670+
)
671+
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
672+
673+
// 2. 포그라운드(앱이 켜져 있을 때) 로직 처리를 위한 WorkItem
674+
let workItem = DispatchWorkItem {
675+
print(" 도착 10분 초과: 자동 종료 실행")
676+
// 메인 화면 등에 신호를 보내서 팝업을 띄우고 상태를 정리함
677+
NotificationCenter.default.post(name: NSNotification.Name("scheduledArrivalDidTimeout"), object: nil)
678+
}
679+
680+
// 변수를 따로 저장해두어야 나중에 취소(reset)가 가능합니다.
681+
// (클래스 상단에 private var arrivalTimeoutWorkItem: DispatchWorkItem? 를 선언해두세요)
682+
self.arrivalTimeoutWorkItem = workItem
683+
DispatchQueue.main.asyncAfter(deadline: .now() + timeInterval, execute: workItem)
684+
}
685+
686+
// 예약 취소 함수 (집에 도착하거나 수동 종료했을 때 호출 필수!)
687+
func cancelArrivalTimeout() {
688+
arrivalTimeoutWorkItem?.cancel()
689+
arrivalTimeoutWorkItem = nil
690+
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [AlarmNotificationID.scheduledArrivalTimeout])
691+
}
692+
}

Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import Foundation
1010
final class DiscordWebhookManager {
1111
static let shared = DiscordWebhookManager()
1212
private init() {}
13-
13+
1414
private let webhookURLString = "https://discord.com/api/webhooks/1483870710018474066/qyzNBI1Bwr7J5tQDrPx2-mOcej_9yLSOk5Bmlmza2D-4nSWqvWgcMd4CZDziG4vkpKrm"
15-
15+
1616
func sendErrorLog(
1717
statusCode: Int,
1818
method: String,
@@ -24,13 +24,12 @@ final class DiscordWebhookManager {
2424
requestParameters: [String: Any]? = nil
2525
) {
2626
guard let url = URL(string: webhookURLString) else { return }
27-
27+
2828
// Authorization 토큰 앞 30자만 노출
2929
let headersText = requestHeaders.map { key, value in
30-
let safeValue = key == "Authorization" ? String(value.prefix(30)) + "..." : value
31-
return "\(key): \(safeValue)"
30+
return "\(key): \(value)"
3231
}.joined(separator: "\n")
33-
32+
3433
// body JSON 변환
3534
let bodyText: String
3635
if let body = requestBody,
@@ -40,7 +39,7 @@ final class DiscordWebhookManager {
4039
} else {
4140
bodyText = "None"
4241
}
43-
42+
4443
let paramsText: String
4544
if let params = requestParameters,
4645
let data = try? JSONSerialization.data(withJSONObject: params, options: .prettyPrinted),
@@ -49,7 +48,7 @@ final class DiscordWebhookManager {
4948
} else {
5049
paramsText = "None"
5150
}
52-
51+
5352
let payload: [String: Any] = [
5453
"content": "🚨 [Atcha-iOS] API 에러 발생!",
5554
"embeds": [[
@@ -62,18 +61,18 @@ final class DiscordWebhookManager {
6261
["name": "App Version", "value": AppInfoProvider.currentVersion, "inline": true],
6362
["name": "Error Message", "value": message, "inline": false],
6463
["name": "Request Headers", "value": "```\n\(headersText)\n```", "inline": false],
65-
["name": "Request Parameters", "value": paramsText, "inline": false],
64+
["name": "Request Parameters", "value": paramsText, "inline": false],
6665
["name": "Request Body", "value": bodyText, "inline": false]
6766
],
6867
"footer": ["text": "발생 시각: \(Date().kstString)"]
6968
]]
7069
]
71-
70+
7271
var request = URLRequest(url: url)
7372
request.httpMethod = "POST"
7473
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
7574
request.httpBody = try? JSONSerialization.data(withJSONObject: payload)
76-
75+
7776
URLSession.shared.dataTask(with: request).resume()
7877
}
7978
}

Atcha-iOS/Core/Manager/Location/HomeArrivalManager.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ final class HomeArrivalManager {
3939
if distance <= 50 {
4040
isArrivalSignalSent = true
4141

42+
AlarmManager.shared.cancelArrivalTimeout()
43+
4244
AlarmManager.shared.sendImmediateLocalPush(
4345
title: "막차 안내 종료",
4446
body: "목적지 부근에 도착했어요",
@@ -51,5 +53,6 @@ final class HomeArrivalManager {
5153

5254
func reset() {
5355
isArrivalSignalSent = false
56+
AlarmManager.shared.cancelArrivalTimeout()
5457
}
5558
}

Atcha-iOS/Core/Network/API/APIError.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import Foundation
1010
enum APIError: Error {
1111
case invalidURL
1212
case decodingError
13-
case serverError(statusCode: Int)
13+
case serverError(statusCode: Int, responseCode: String? = nil)
1414
case unknown(error: Error)
1515
case noData
1616
}

Atcha-iOS/Core/Network/API/APIServiceImpl.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ final class APIServiceImpl: APIService, @unchecked Sendable {
4444
} else {
4545
self.handleFailure(response: response, endpoint: endpoint, continuation: continuation)
4646
}
47-
case .failure(let error):
47+
case .failure(_):
4848
self.handleFailure(response: response, endpoint: endpoint, continuation: continuation)
4949
}
5050
}
@@ -92,7 +92,7 @@ extension APIServiceImpl {
9292
self.handleFailure(response: response, endpoint: endpoint, requestBody: body.toDictionary(), continuation: continuation)
9393
}
9494

95-
case .failure(let error):
95+
case .failure(_):
9696
self.handleFailure(response: response, endpoint: endpoint, requestBody: body.toDictionary(), continuation: continuation)
9797
}
9898
}
@@ -110,7 +110,7 @@ extension APIServiceImpl {
110110
let statusCode = response.response?.statusCode ?? -1
111111
let method = endpoint.method.rawValue.uppercased()
112112
let path = endpoint.path
113-
let requestHeaders = endpoint.headers?.dictionary ?? [:]
113+
let actualSentHeaders = response.request?.allHTTPHeaderFields ?? [:]
114114

115115
var responseCode = "UNKNOWN"
116116
var serverMessage = "(메시지 없음)"
@@ -129,12 +129,13 @@ extension APIServiceImpl {
129129
path: serverPath,
130130
responseCode: responseCode,
131131
message: serverMessage,
132-
requestHeaders: requestHeaders,
132+
requestHeaders: actualSentHeaders,
133133
requestBody: requestBody, // POST/PUT body
134134
requestParameters: endpoint.parameters // GET query params
135135
)
136136

137-
let apiError = APIError.serverError(statusCode: statusCode)
137+
let apiError = APIError.serverError(statusCode: statusCode, responseCode: responseCode)
138+
138139
NotificationCenter.default.post(name: .apiErrorOccurred, object: apiError)
139140
continuation.resume(throwing: apiError)
140141
}

Atcha-iOS/Core/Network/Token/TokenInterceptor.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ final class TokenInterceptor: RequestInterceptor, @unchecked Sendable {
3333
"/auth/login",
3434
"/app/version",
3535
"/locations/is-service-region",
36-
"/api/locations/rgeo"
36+
"/api/locations/rgeo",
37+
"/auth/reissue"
3738
]
3839

3940
if publicPaths.contains(where: { path.hasSuffix($0) }) {

Atcha-iOS/Presentation/Common/BaseViewController.swift

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import UIKit
99
import Combine
1010
import CoreLocation
1111

12+
private struct ErrorState {
13+
static var isShowing500Error = false
14+
}
15+
1216
class BaseViewController<VM: BaseViewModel>: UIViewController {
1317
var activePermissionToast: AtchaActionToast?
1418
var activeAlarmPermissionToast: AtchaActionToast?
@@ -220,21 +224,37 @@ class BaseViewController<VM: BaseViewModel>: UIViewController {
220224
object: nil
221225
)
222226
}
223-
224-
@objc private func handleServerError(_ notification: Notification) {
225-
guard self.presentedViewController == nil else { return }
226227

227-
DispatchQueue.main.async { [weak self] in
228-
self?.showAtchaErrorPopup()
228+
@objc private func handleServerError(_ notification: Notification) {
229+
// 1. 전달된 오브젝트가 APIError인지 확인
230+
guard let apiError = notification.object as? APIError else { return }
231+
232+
// 2. 에러 케이스와 상태 코드 추출 (APIError가 statusCode를 가지고 있다고 가정)
233+
if case .serverError(let statusCode, _) = apiError {
234+
235+
// 3. 500번대 에러인 경우에만 팝업 노출
236+
if (500...599).contains(statusCode) && !ErrorState.isShowing500Error {
237+
guard self.presentedViewController == nil else { return }
238+
239+
DispatchQueue.main.async { [weak self] in
240+
self?.showAtchaErrorPopup()
241+
}
242+
} else {
243+
// 400번대 등 기타 에러는 팝업을 띄우지 않고 로그만 남기거나 별도 처리
244+
print("UI 팝업 제외 대상 에러: \(statusCode)")
245+
}
229246
}
230247
}
231248

232249
private func showAtchaErrorPopup() {
250+
ErrorState.isShowing500Error = true
251+
233252
// 이전에 만드신 앗차팝업 호출 (에러 케이스용)
234253
let popupVM = AtchaPopupViewModel(info: .serverError) // Enum에 .serverError 추가 필요
235254
let popupVC = AtchaPopupViewController(viewModel: popupVM)
236255

237256
popupVC.confirmButton.addAction(UIAction { [weak popupVC] _ in
257+
ErrorState.isShowing500Error = false
238258
popupVC?.dismiss(animated: false)
239259
}, for: .touchUpInside)
240260

Atcha-iOS/Presentation/Course/CourseSearch/CourseSearchViewController.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ final class CourseSearchViewController: BaseViewController<CourseSearchViewModel
1313

1414
private lazy var topNavigationBar: CloseOnlyNavigationBar = AtchaNavigationBar.CloseOnly(onClose: {
1515
[weak self] in
16-
self?.navigationController?.popToRootViewController(animated: true)
16+
self?.navigateBackByContext()
1717
})
1818
private let courseView: UIView = UIView()
1919
private let routeLabelStack: UIStackView = UIStackView()
@@ -135,6 +135,18 @@ final class CourseSearchViewController: BaseViewController<CourseSearchViewModel
135135
}
136136
.store(in: &cancellables)
137137
}
138+
139+
private func navigateBackByContext() {
140+
switch viewModel.context {
141+
case .beforeRegister:
142+
// 등록 전: 홈(지도)으로 완전히 나감
143+
self.navigationController?.popToRootViewController(animated: true)
144+
case .afterReigster: // 오타 주의: afterReigster (i 누락된 유저님 코드 기준)
145+
// 락스크린에서 옴: 바로 아래에 깔린 DetailRoute로 돌아감
146+
self.navigationController?.popViewController(animated: true)
147+
}
148+
}
149+
138150
// MARK: - 경로탐색 UI
139151
private func setupUI() {
140152
view.backgroundColor = AtchaColor.gray950

0 commit comments

Comments
 (0)