Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
5 changes: 3 additions & 2 deletions Atcha-iOS/App/DIContainer/Course/CourseDIContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ final class CourseDIContainer {
self.tokenStorage = tokenStorage
}

func makeCourseSearchViewModel(startLat: String, startLon: String, startAddress: String) -> CourseSearchViewModel {
func makeCourseSearchViewModel(startLat: String, startLon: String, startAddress: String, context: CourseSearchContext) -> CourseSearchViewModel {
let courseUseCase = CourseUseCaseImpl(
repository: CourseRepositoryImpl(apiService: apiService, tokenStorage: tokenStorage)
)
Expand All @@ -35,7 +35,8 @@ final class CourseDIContainer {
alarmUseCase: alarmUseCase,
startLat: startLat,
startLon: startLon,
startAddress: startAddress)
startAddress: startAddress,
context: context)
}

func makeCourseSearchViewController(viewModel: CourseSearchViewModel) -> UIViewController {
Expand Down
20 changes: 17 additions & 3 deletions Atcha-iOS/App/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,23 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// This occurs shortly after the scene enters the background, or when its session is discarded.
// Release any resources associated with this scene that can be re-created the next time the scene connects.
// The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
if let _: LegInfo = UserDefaultsWrapper.shared.object(forKey: UserDefaultsWrapper.Key.legInfo.rawValue, of: LegInfo.self) {
AlarmManager.shared.sendBackgroundPush(title: "앗차를 다시 켜주세요",
body: "제 시간에 출발 시간을 알려드릴 수 있도록 앱을 다시 실행해 주세요.")

let wrapper = UserDefaultsWrapper.shared

if let _: LegInfo = wrapper.object(forKey: UserDefaultsWrapper.Key.legInfo.rawValue, of: LegInfo.self) {
let didFire = wrapper.bool(forKey: UserDefaultsWrapper.Key.departureAlarmDidFire.rawValue) ?? false

if didFire {
AlarmManager.shared.sendBackgroundPush(
title: "앗차를 다시 켜주세요",
body: "목적지까지 안내할 수 있도록 앱을 다시 실행해 주세요"
)
} else {
AlarmManager.shared.sendBackgroundPush(
title: "앗차를 다시 켜주세요",
body: "제 시간에 출발 시간을 알려드릴 수 있도록 앱을 다시 실행해 주세요."
)
}
}
}

Expand Down
50 changes: 50 additions & 0 deletions Atcha-iOS/Core/Manager/Alarm/AlarmManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ final class AlarmManager {
private var isPreviewing = false
private var hapticEngine: CHHapticEngine?
private var autoStopWorkItem: DispatchWorkItem?
private var arrivalTimeoutWorkItem: DispatchWorkItem?

// MARK: - Init
private init() {
Expand Down Expand Up @@ -454,6 +455,7 @@ extension AlarmManager {
enum AlarmNotificationID {
static let autoStopInfo = "atcha.alarm.autostop"
static let tenMinutesBefore = "atcha.alarm.tenMinutesBefore"
static let scheduledArrivalTimeout = "atcha.arrival.timeout"
}

extension AlarmManager {
Expand Down Expand Up @@ -640,3 +642,51 @@ extension AlarmManager {
DispatchQueue.main.asyncAfter(deadline: .now() + 120.0, execute: workItem)
}
}

extension AlarmManager {

// 도착 10분 후 자동 종료 예약 함수
func scheduleArrivalTimeout(at arrivalDate: Date) {
// 기존에 예약된 게 있다면 먼저 취소
cancelArrivalTimeout()

let timeoutDate = arrivalDate.addingTimeInterval(10 * 60) // 도착 시간 + 10분
let timeInterval = timeoutDate.timeIntervalSinceNow

// 만약 이미 시간이 지났다면 예약하지 않음
guard timeInterval > 0 else { return }

// 1. 백그라운드용 로컬 푸시 예약 (시스템이 정확한 시간에 띄워줌)
let content = UNMutableNotificationContent()
content.title = "막차 안내 종료"
content.body = "예정된 도착 시간이 지나 알람이 자동으로 종료됐어요"
content.sound = .default

let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: false)
let request = UNNotificationRequest(
identifier: AlarmNotificationID.scheduledArrivalTimeout,
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)

// 2. 포그라운드(앱이 켜져 있을 때) 로직 처리를 위한 WorkItem
let workItem = DispatchWorkItem {
print(" 도착 10분 초과: 자동 종료 실행")
// 메인 화면 등에 신호를 보내서 팝업을 띄우고 상태를 정리함
NotificationCenter.default.post(name: NSNotification.Name("scheduledArrivalDidTimeout"), object: nil)
}

// 변수를 따로 저장해두어야 나중에 취소(reset)가 가능합니다.
// (클래스 상단에 private var arrivalTimeoutWorkItem: DispatchWorkItem? 를 선언해두세요)
self.arrivalTimeoutWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + timeInterval, execute: workItem)
}

// 예약 취소 함수 (집에 도착하거나 수동 종료했을 때 호출 필수!)
func cancelArrivalTimeout() {
arrivalTimeoutWorkItem?.cancel()
arrivalTimeoutWorkItem = nil
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [AlarmNotificationID.scheduledArrivalTimeout])
}
}
21 changes: 10 additions & 11 deletions Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import Foundation
final class DiscordWebhookManager {
static let shared = DiscordWebhookManager()
private init() {}

private let webhookURLString = "https://discord.com/api/webhooks/1483870710018474066/qyzNBI1Bwr7J5tQDrPx2-mOcej_9yLSOk5Bmlmza2D-4nSWqvWgcMd4CZDziG4vkpKrm"

func sendErrorLog(
statusCode: Int,
method: String,
Expand All @@ -24,13 +24,12 @@ final class DiscordWebhookManager {
requestParameters: [String: Any]? = nil
) {
guard let url = URL(string: webhookURLString) else { return }

// Authorization 토큰 앞 30자만 노출
let headersText = requestHeaders.map { key, value in
let safeValue = key == "Authorization" ? String(value.prefix(30)) + "..." : value
return "\(key): \(safeValue)"
return "\(key): \(value)"
}.joined(separator: "\n")

// body JSON 변환
let bodyText: String
if let body = requestBody,
Expand All @@ -40,7 +39,7 @@ final class DiscordWebhookManager {
} else {
bodyText = "None"
}

let paramsText: String
if let params = requestParameters,
let data = try? JSONSerialization.data(withJSONObject: params, options: .prettyPrinted),
Expand All @@ -49,7 +48,7 @@ final class DiscordWebhookManager {
} else {
paramsText = "None"
}

let payload: [String: Any] = [
"content": "🚨 [Atcha-iOS] API 에러 발생!",
"embeds": [[
Expand All @@ -62,18 +61,18 @@ final class DiscordWebhookManager {
["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 Parameters", "value": paramsText, "inline": false],
["name": "Request Body", "value": bodyText, "inline": false]
],
"footer": ["text": "발생 시각: \(Date().kstString)"]
]]
]

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: payload)

URLSession.shared.dataTask(with: request).resume()
}
}
Expand Down
3 changes: 3 additions & 0 deletions Atcha-iOS/Core/Manager/Location/HomeArrivalManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ final class HomeArrivalManager {
if distance <= 50 {
isArrivalSignalSent = true

AlarmManager.shared.cancelArrivalTimeout()

AlarmManager.shared.sendImmediateLocalPush(
title: "막차 안내 종료",
body: "목적지 부근에 도착했어요",
Expand All @@ -51,5 +53,6 @@ final class HomeArrivalManager {

func reset() {
isArrivalSignalSent = false
AlarmManager.shared.cancelArrivalTimeout()
}
}
2 changes: 1 addition & 1 deletion Atcha-iOS/Core/Network/API/APIError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Foundation
enum APIError: Error {
case invalidURL
case decodingError
case serverError(statusCode: Int)
case serverError(statusCode: Int, responseCode: String? = nil)
case unknown(error: Error)
case noData
}
11 changes: 6 additions & 5 deletions Atcha-iOS/Core/Network/API/APIServiceImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ final class APIServiceImpl: APIService, @unchecked Sendable {
} else {
self.handleFailure(response: response, endpoint: endpoint, continuation: continuation)
}
case .failure(let error):
case .failure(_):
self.handleFailure(response: response, endpoint: endpoint, continuation: continuation)
}
}
Expand Down Expand Up @@ -92,7 +92,7 @@ extension APIServiceImpl {
self.handleFailure(response: response, endpoint: endpoint, requestBody: body.toDictionary(), continuation: continuation)
}

case .failure(let error):
case .failure(_):
self.handleFailure(response: response, endpoint: endpoint, requestBody: body.toDictionary(), continuation: continuation)
}
}
Expand All @@ -110,7 +110,7 @@ extension APIServiceImpl {
let statusCode = response.response?.statusCode ?? -1
let method = endpoint.method.rawValue.uppercased()
let path = endpoint.path
let requestHeaders = endpoint.headers?.dictionary ?? [:]
let actualSentHeaders = response.request?.allHTTPHeaderFields ?? [:]

var responseCode = "UNKNOWN"
var serverMessage = "(메시지 없음)"
Expand All @@ -129,12 +129,13 @@ extension APIServiceImpl {
path: serverPath,
responseCode: responseCode,
message: serverMessage,
requestHeaders: requestHeaders,
requestHeaders: actualSentHeaders,
requestBody: requestBody, // POST/PUT body
requestParameters: endpoint.parameters // GET query params
)

let apiError = APIError.serverError(statusCode: statusCode)
let apiError = APIError.serverError(statusCode: statusCode, responseCode: responseCode)

NotificationCenter.default.post(name: .apiErrorOccurred, object: apiError)
continuation.resume(throwing: apiError)
}
Expand Down
3 changes: 2 additions & 1 deletion Atcha-iOS/Core/Network/Token/TokenInterceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ final class TokenInterceptor: RequestInterceptor, @unchecked Sendable {
"/auth/login",
"/app/version",
"/locations/is-service-region",
"/api/locations/rgeo"
"/api/locations/rgeo",
"/auth/reissue"
]

if publicPaths.contains(where: { path.hasSuffix($0) }) {
Expand Down
30 changes: 25 additions & 5 deletions Atcha-iOS/Presentation/Common/BaseViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import UIKit
import Combine
import CoreLocation

private struct ErrorState {
static var isShowing500Error = false
}

class BaseViewController<VM: BaseViewModel>: UIViewController {
var activePermissionToast: AtchaActionToast?
var activeAlarmPermissionToast: AtchaActionToast?
Expand Down Expand Up @@ -220,21 +224,37 @@ class BaseViewController<VM: BaseViewModel>: UIViewController {
object: nil
)
}

@objc private func handleServerError(_ notification: Notification) {
guard self.presentedViewController == nil else { return }

DispatchQueue.main.async { [weak self] in
self?.showAtchaErrorPopup()
@objc private func handleServerError(_ notification: Notification) {
// 1. 전달된 오브젝트가 APIError인지 확인
guard let apiError = notification.object as? APIError else { return }

// 2. 에러 케이스와 상태 코드 추출 (APIError가 statusCode를 가지고 있다고 가정)
if case .serverError(let statusCode, _) = apiError {

// 3. 500번대 에러인 경우에만 팝업 노출
if (500...599).contains(statusCode) && !ErrorState.isShowing500Error {
guard self.presentedViewController == nil else { return }

DispatchQueue.main.async { [weak self] in
self?.showAtchaErrorPopup()
}
} else {
// 400번대 등 기타 에러는 팝업을 띄우지 않고 로그만 남기거나 별도 처리
print("UI 팝업 제외 대상 에러: \(statusCode)")
}
}
}

private func showAtchaErrorPopup() {
ErrorState.isShowing500Error = true

// 이전에 만드신 앗차팝업 호출 (에러 케이스용)
let popupVM = AtchaPopupViewModel(info: .serverError) // Enum에 .serverError 추가 필요
let popupVC = AtchaPopupViewController(viewModel: popupVM)

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ final class CourseSearchViewController: BaseViewController<CourseSearchViewModel

private lazy var topNavigationBar: CloseOnlyNavigationBar = AtchaNavigationBar.CloseOnly(onClose: {
[weak self] in
self?.navigationController?.popToRootViewController(animated: true)
self?.navigateBackByContext()
})
private let courseView: UIView = UIView()
private let routeLabelStack: UIStackView = UIStackView()
Expand Down Expand Up @@ -135,6 +135,18 @@ final class CourseSearchViewController: BaseViewController<CourseSearchViewModel
}
.store(in: &cancellables)
}

private func navigateBackByContext() {
switch viewModel.context {
case .beforeRegister:
// 등록 전: 홈(지도)으로 완전히 나감
self.navigationController?.popToRootViewController(animated: true)
case .afterReigster: // 오타 주의: afterReigster (i 누락된 유저님 코드 기준)
// 락스크린에서 옴: 바로 아래에 깔린 DetailRoute로 돌아감
self.navigationController?.popViewController(animated: true)
}
}

// MARK: - 경로탐색 UI
private func setupUI() {
view.backgroundColor = AtchaColor.gray950
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ struct RouteRanks {
let transferCount: Int // 환승 횟수
}

enum CourseSearchContext {
case beforeRegister
case afterReigster
}

final class CourseSearchViewModel: BaseViewModel {
@Published var courses: [CourseUIModel] = []
private var allCourses: [CourseUIModel] = []
Expand All @@ -53,18 +58,22 @@ final class CourseSearchViewModel: BaseViewModel {
private let cutoffHour = 3 // 새벽 3시까지 검색
private var anchorDate: Date? // 검색 시작 시 고정

@Published private(set) var context: CourseSearchContext

init(
courseUseCase: CourseUseCase,
alarmUseCase: AlarmUseCase,
startLat: String,
startLon: String,
startAddress: String
startAddress: String,
context: CourseSearchContext
) {
self.courseUseCase = courseUseCase
self.alarmUseCase = alarmUseCase
self.startLat = startLat
self.startLon = startLon
self.startAddress = startAddress
self.context = context
super.init()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Foundation

enum MainRoute {
case myPage
case courseSearch(startLat: String, startLon: String, startAddress: String)
case courseSearch(startLat: String, startLon: String, startAddress: String, context: CourseSearchContext)
case changeCourse(location: Location)
case detailRoute(address: String, infos: LegInfo, context: DetailRouteContext)
case lockScreen(info: LegInfo?, address: String?) // 잠금화면
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ extension DetailRouteSubwayCell {
return
}

if let destination = matched.destination {
if let destination = matched.destination, destination != "" {
subwayDirectionLabel.attributedText = AtchaFont.B6_R_14("\(destination)행", color: .white)
} else {
subwayDirectionLabel.attributedText = AtchaFont.B6_R_14("", color: .white)
Expand Down
Loading
Loading