diff --git a/ComfortableMove/ComfortableMove/Core/Manager/AlertManager.swift b/ComfortableMove/ComfortableMove/Core/Manager/AlertManager.swift new file mode 100644 index 0000000..bfe48d8 --- /dev/null +++ b/ComfortableMove/ComfortableMove/Core/Manager/AlertManager.swift @@ -0,0 +1,106 @@ +// +// AlertManager.swift +// ComfortableMove +// +// Created by Claude on 11/15/25. +// + +import Foundation +import SwiftUI + +// MARK: - Alert Types +enum AlertType: Identifiable { + case outOfSeoul + case apiError + case bluetoothUnsupported + case bluetoothUnauthorized + case locationUnauthorized + + var id: String { + switch self { + case .outOfSeoul: return "outOfSeoul" + case .apiError: return "apiError" + case .bluetoothUnsupported: return "bluetoothUnsupported" + case .bluetoothUnauthorized: return "bluetoothUnauthorized" + case .locationUnauthorized: return "locationUnauthorized" + } + } + + var priority: Int { + switch self { + case .locationUnauthorized: return 3 + case .bluetoothUnsupported, .bluetoothUnauthorized: return 2 + case .outOfSeoul: return 1 + case .apiError: return 0 + } + } + + var title: String { + switch self { + case .outOfSeoul: + return "서울 외 지역" + case .apiError: + return "서버 오류" + case .bluetoothUnsupported: + return "블루투스 미지원" + case .bluetoothUnauthorized: + return "블루투스 권한 필요" + case .locationUnauthorized: + return "위치 권한 필요" + } + } + + var message: String { + switch self { + case .outOfSeoul: + return "현재 서울 지역에서만 서비스를 이용할 수 있습니다." + case .apiError: + return "서버에 문제가 발생했습니다.\n잠시 후 다시 시도해주세요." + case .bluetoothUnsupported: + return "이 기기는 블루투스를 지원하지 않습니다.\n배려석 알림 기능을 사용할 수 없습니다." + case .bluetoothUnauthorized: + return "블루투스 권한이 필요합니다.\n설정에서 블루투스 권한을 허용해주세요." + case .locationUnauthorized: + return "위치 권한이 필요합니다.\n설정에서 위치 권한을 허용해주세요." + } + } + + var shouldBlockApp: Bool { + switch self { + case .bluetoothUnsupported, .bluetoothUnauthorized, .locationUnauthorized: + return true + case .outOfSeoul, .apiError: + return false + } + } + + var primaryButtonText: String { + shouldBlockApp ? "설정으로 이동" : "확인" + } +} + +// MARK: - Alert Manager +class AlertManager: ObservableObject { + @Published var currentAlert: AlertType? + + func showAlert(_ type: AlertType) { + // 현재 alert가 없거나, 새로운 alert의 우선순위가 더 높은 경우에만 표시 + if let current = currentAlert { + if type.priority > current.priority { + currentAlert = type + } + } else { + currentAlert = type + } + } + + func dismissAlert() { + currentAlert = nil + } + + func openSettings() { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } +} diff --git a/ComfortableMove/ComfortableMove/Core/Manager/Bluetooth/BluetoothManager.swift b/ComfortableMove/ComfortableMove/Core/Manager/Bluetooth/BluetoothManager.swift index d435973..bf7414f 100644 --- a/ComfortableMove/ComfortableMove/Core/Manager/Bluetooth/BluetoothManager.swift +++ b/ComfortableMove/ComfortableMove/Core/Manager/Bluetooth/BluetoothManager.swift @@ -18,6 +18,9 @@ class BluetoothManager: NSObject, ObservableObject { private var onTransmitComplete: ((Bool) -> Void)? private var targetBusNumber: String? + var onBluetoothUnsupported: (() -> Void)? + var onBluetoothUnauthorized: (() -> Void)? + override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: nil) @@ -74,8 +77,10 @@ extension BluetoothManager: CBCentralManagerDelegate { Logger.log(message: "블루투스가 켜져있습니다.") case .unauthorized: Logger.log(message: "블루투스 권한이 없습니다.") + onBluetoothUnauthorized?() case .unsupported: Logger.log(message: "이 기기는 블루투스를 지원하지 않습니다.") + onBluetoothUnsupported?() default: break } diff --git a/ComfortableMove/ComfortableMove/Core/Manager/BusArrivalService.swift b/ComfortableMove/ComfortableMove/Core/Manager/BusArrivalService.swift index d4b707d..a400272 100644 --- a/ComfortableMove/ComfortableMove/Core/Manager/BusArrivalService.swift +++ b/ComfortableMove/ComfortableMove/Core/Manager/BusArrivalService.swift @@ -48,7 +48,7 @@ class BusArrivalService { guard result.msgHeader.isSuccess else { Logger.log(message: "❌ [API] API Error: \(result.msgHeader.headerMsg)") - return [] + throw NSError(domain: "APIError", code: -1, userInfo: [NSLocalizedDescriptionKey: result.msgHeader.headerMsg]) } let items = result.msgBody.itemList ?? [] diff --git a/ComfortableMove/ComfortableMove/Core/Manager/BusStopService.swift b/ComfortableMove/ComfortableMove/Core/Manager/BusStopService.swift index 508c5e6..b0803f4 100644 --- a/ComfortableMove/ComfortableMove/Core/Manager/BusStopService.swift +++ b/ComfortableMove/ComfortableMove/Core/Manager/BusStopService.swift @@ -49,9 +49,15 @@ class BusStopService { Logger.log(message: "🚏 [API] Header Message: \(result.msgHeader.headerMsg)") Logger.log(message: "🚏 [API] Item Count: \(result.msgHeader.itemCount)") + // 서울 외 지역 체크 + if result.msgHeader.isOutOfSeoul { + Logger.log(message: "❌ [API] Out of Seoul: \(result.msgHeader.headerMsg)") + throw NSError(domain: "OutOfSeoul", code: 4, userInfo: [NSLocalizedDescriptionKey: "서울 외 지역"]) + } + guard result.msgHeader.isSuccess else { Logger.log(message: "❌ [API] API Error: \(result.msgHeader.headerMsg)") - return [] + throw NSError(domain: "APIError", code: -1, userInfo: [NSLocalizedDescriptionKey: result.msgHeader.headerMsg]) } let stations = result.msgBody.itemList ?? [] diff --git a/ComfortableMove/ComfortableMove/Core/Manager/LocationManager.swift b/ComfortableMove/ComfortableMove/Core/Manager/LocationManager.swift index b4ca2ce..9da5b24 100644 --- a/ComfortableMove/ComfortableMove/Core/Manager/LocationManager.swift +++ b/ComfortableMove/ComfortableMove/Core/Manager/LocationManager.swift @@ -39,9 +39,13 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - if manager.authorizationStatus == .authorizedWhenInUse || - manager.authorizationStatus == .authorizedAlways { + switch manager.authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: manager.startUpdatingLocation() + case .denied, .restricted: + showPermissionAlert = true + default: + break } } diff --git a/ComfortableMove/ComfortableMove/Core/Model/BusArrivalResponse.swift b/ComfortableMove/ComfortableMove/Core/Model/BusArrivalResponse.swift index b5dd8ae..b221bd5 100644 --- a/ComfortableMove/ComfortableMove/Core/Model/BusArrivalResponse.swift +++ b/ComfortableMove/ComfortableMove/Core/Model/BusArrivalResponse.swift @@ -108,6 +108,10 @@ struct MsgHeader: Codable { var isSuccess: Bool { return headerCd == "0" } + + var isOutOfSeoul: Bool { + return headerCd == "4" && headerMsg == "결과가 없습니다." + } } // MARK: - 메시지 본문 diff --git a/ComfortableMove/ComfortableMove/Core/Presentation/Help/InfoView.swift b/ComfortableMove/ComfortableMove/Core/Presentation/Help/InfoView.swift index 453f017..c872568 100644 --- a/ComfortableMove/ComfortableMove/Core/Presentation/Help/InfoView.swift +++ b/ComfortableMove/ComfortableMove/Core/Presentation/Help/InfoView.swift @@ -8,9 +8,22 @@ import SwiftUI struct InfoView: View { - @Environment(\.dismiss) private var dismiss private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" - + + @Environment(\.presentationMode) var presentationMode: Binding + + var backButton : some View { // <-- 👀 커스텀 버튼 + Button{ + self.presentationMode.wrappedValue.dismiss() + } label: { + HStack { + Image(systemName: "chevron.left") // 화살표 Image + .aspectRatio(contentMode: .fit) + .foregroundStyle(Color.white) + } + } + } + var body: some View { VStack(spacing: 0) { Spacer() @@ -21,7 +34,7 @@ struct InfoView: View { .scaledToFit() .frame(width: 100, height: 170) .padding(.bottom, 100) - + // 하단 리스트 영역 VStack(spacing: 0) { // 버전 정보 @@ -37,10 +50,10 @@ struct InfoView: View { .padding(.horizontal, 20) .padding(.vertical, 16) .background(Color.white) - + Divider() .padding(.leading, 20) - + // 앱 문의 Button(action: { if let url = URL(string: "https://forms.gle/rnSD44sUEuy1nLaH6") { @@ -60,10 +73,10 @@ struct InfoView: View { .padding(.vertical, 16) .background(Color.white) } - + Divider() .padding(.leading, 20) - + // 개인정보 처리 방침 및 이용약관 Button(action: { if let url = URL(string: "https://important-hisser-903.notion.site/10-22-ver-29a65f12c44480b6b591e726c5c80f89?source=copy_link") { @@ -87,12 +100,13 @@ struct InfoView: View { .background(Color.white) .cornerRadius(20) .padding(.horizontal, 20) - + Spacer() } .background(Color("BFPrimaryColor")) .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(false) + .navigationBarBackButtonHidden(true) + .navigationBarItems(leading: backButton) .navigationTitle("앱정보") .toolbarBackground(Color("BFPrimaryColor"), for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) diff --git a/ComfortableMove/ComfortableMove/Core/Presentation/Home/HomeView.swift b/ComfortableMove/ComfortableMove/Core/Presentation/Home/HomeView.swift index 241d855..c458ab9 100644 --- a/ComfortableMove/ComfortableMove/Core/Presentation/Home/HomeView.swift +++ b/ComfortableMove/ComfortableMove/Core/Presentation/Home/HomeView.swift @@ -11,21 +11,22 @@ import CoreLocation struct HomeView: View { @StateObject private var locationManager = LocationManager() @StateObject private var bluetoothManager = BluetoothManager() + @StateObject private var alertManager = AlertManager() @State private var selectedRouteName: String? @State private var busArrivals: [String: BusArrivalItem] = [:] // routeName: 도착정보 @State private var isLoadingArrivals = false @State private var nearestStation: StationItem? // 가장 가까운 정류소 @State private var isLoadingStation = false - + // Alert 상태 @State private var showConfirmAlert = false @State private var showSuccessAlert = false @State private var showFailureAlert = false - + // 화면 표시 상태 @State private var showHelpPage = false - + // 버튼 상태 @State private var isButtonTapped = false @@ -109,7 +110,7 @@ struct HomeView: View { Spacer() Button(action: { - refreshBusArrivals() + refreshLocation() }) { Image(systemName: "arrow.clockwise") .font(.title2) @@ -206,6 +207,7 @@ struct HomeView: View { .background(Color("BFPrimaryColor")) .ignoresSafeArea(.all, edges: .top) .onAppear { + setupAlertCallbacks() locationManager.requestPermission() } .onChange(of: locationManager.currentLocation) { newLocation in @@ -213,6 +215,11 @@ struct HomeView: View { findNearestStation(location: location) } } + .onChange(of: locationManager.showPermissionAlert) { shouldShow in + if shouldShow { + alertManager.showAlert(.locationUnauthorized) + } + } .onChange(of: nearestStation) { newStation in if newStation != nil { refreshBusArrivals() @@ -237,12 +244,43 @@ struct HomeView: View { } message: { Text("다시 한번 시도해주세요.") } + .alert(item: $alertManager.currentAlert) { alertType in + if alertType.shouldBlockApp { + return Alert( + title: Text(alertType.title), + message: Text(alertType.message), + primaryButton: .default(Text(alertType.primaryButtonText)) { + alertManager.openSettings() + }, + secondaryButton: .cancel(Text("취소")) + ) + } else { + return Alert( + title: Text(alertType.title), + message: Text(alertType.message), + dismissButton: .default(Text(alertType.primaryButtonText)) { + alertManager.dismissAlert() + } + ) + } + } .overlay( showHelpPage ? HelpPageView(isPresented: $showHelpPage) : nil ) .navigationBarHidden(true) } } + + // MARK: - Setup Alert Callbacks + private func setupAlertCallbacks() { + bluetoothManager.onBluetoothUnsupported = { + alertManager.showAlert(.bluetoothUnsupported) + } + + bluetoothManager.onBluetoothUnauthorized = { + alertManager.showAlert(.bluetoothUnauthorized) + } + } // MARK: - Computed Properties private var selectedBusName: String { @@ -267,7 +305,16 @@ struct HomeView: View { isButtonTapped = false selectedRouteName = nil } - + + // MARK: - 위치 새로고침 + private func refreshLocation() { + guard let location = locationManager.currentLocation else { + locationManager.refreshLocation() + return + } + findNearestStation(location: location) + } + // MARK: - 가장 가까운 정류소 찾기 private func findNearestStation(location: CLLocation) { isLoadingStation = true @@ -284,8 +331,15 @@ struct HomeView: View { Logger.log(message: "⚠️ [HomeView] No stations found within radius") nearestStation = nil } - } catch { + } catch let error as NSError { Logger.log(message: "❌ [HomeView] Failed to find nearest station: \(error)") + + // 서울 외 지역 체크 + if error.domain == "OutOfSeoul" { + alertManager.showAlert(.outOfSeoul) + } else { + alertManager.showAlert(.apiError) + } nearestStation = nil } @@ -310,9 +364,14 @@ struct HomeView: View { } busArrivals = newArrivals - - } catch { + + } catch let error as NSError { Logger.log(message: "❌ [HomeView] Failed to fetch arrival info: \(error)") + + // API 에러 처리 + if error.domain == "APIError" { + alertManager.showAlert(.apiError) + } } isLoadingArrivals = false