diff --git a/.swiftlint.yml b/.swiftlint.yml index e274dc8b..8d3de847 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -20,6 +20,9 @@ identifier_name: - vc - vm - o + - v + - id + allowed_symbols: "_" # 언더바 허용 function_body_length: warning: 150 error: 300 @@ -30,7 +33,7 @@ file_length: warning: 1000 error: 2000 line_length: - warning: 90 + warning: 120 error: 400 disabled_rules: # 제외하고 싶은 룰 - trailing_whitespace diff --git a/Projects/App/Sources/AppDelegate+Register.swift b/Projects/App/Sources/AppDelegate+Register.swift deleted file mode 100644 index e2917c44..00000000 --- a/Projects/App/Sources/AppDelegate+Register.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// AppDelegate+Register.swift -// AppStore -// -// Created by gnksbm on 2023/11/23. -// Copyright © 2023 gnksbm All rights reserved. -// - -import Foundation - -import Core -import CoreDataService -import Data -import Domain -import NetworkService -import FirebaseModule - -extension AppDelegate { - func registerDependencies() { - let firebaseLogger = FirebaseLoggerImpl() - DIContainer.setLogger(firebaseLogger) - - DIContainer.register(type: ForceUpdateService.self, DefaultForceUpdateService()) - DIContainer.register(type: CoreDataService.self, DefaultCoreDataService()) - DIContainer.register(type: NetworkService.self, DefaultNetworkService()) - DIContainer.register(type: LocationService.self, DefaultLocationService()) - - DIContainer.register(type: FavoritesRepository.self, DefaultFavoritesRepository()) - DIContainer.register(type: BusStopArrivalInfoRepository.self, DefaultBusStopArrivalInfoRepository()) - DIContainer.register(type: StationListRepository.self, DefaultStationListRepository()) - DIContainer.register(type: RegularAlarmRepository.self, DefaultRegularAlarmRepository()) - DIContainer.register(type: LocalNotificationService.self, DefaultLocalNotificationService()) - DIContainer.register(type: RegularAlarmEditingService.self, DefaultRegularAlarmEditingService()) - DIContainer.register(type: VersionCheckRepository.self, DefaultVersionCheckRepository()) - - DIContainer.register(type: FavoritesUseCase.self, DefaultFavoritesUseCase()) - DIContainer.register(type: RegularAlarmUseCase.self, DefaultRegularAlarmUseCase()) - DIContainer.register(type: AddRegularAlarmUseCase.self, DefaultAddRegularAlarmUseCase()) - DIContainer.register(type: SearchUseCase.self, DefaultSearchUseCase()) - DIContainer.register(type: BusStopUseCase.self, DefaultBusStopUseCase()) - DIContainer.register(type: NearMapUseCase.self, DefaultNearMapUseCase()) - DIContainer.register(type: FirebaseLogger.self, firebaseLogger) - DIContainer.register(type: VersionCheckUseCase.self, DefaultVersionCheckUseCase()) - } -} diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index 6046c017..0c78d6d1 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -19,7 +19,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { : [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { setupAppearance() - registerDependencies() configureNotification(application: application) configureFirebase(application: application) diff --git a/Projects/App/Sources/Coordinator/AppCoordinator.swift b/Projects/App/Sources/Coordinator/AppCoordinator.swift index ee5ae638..fb2496bd 100644 --- a/Projects/App/Sources/Coordinator/AppCoordinator.swift +++ b/Projects/App/Sources/Coordinator/AppCoordinator.swift @@ -9,8 +9,15 @@ import UIKit import FeatureDependency -import MainFeature import BusStopFeature +import Domain + +protocol AppCoordinatorDependency: AnyObject, SplashViewModelDependency { + var sceneWillEnterForeground: AsyncStream { get } + var appVersion: AppVersionInfoResponse { get } + var appStoreID: String { get } + var domainURL: String { get } +} final class AppCoordinator: Coordinator { var parent: Coordinator? @@ -18,18 +25,25 @@ final class AppCoordinator: Coordinator { var navigationController: UINavigationController public var coordinatorType: CoordinatorType = .app private let coordinatorProvider = DefaultCoordinatorProvider() + private let dependency: AppCoordinatorDependency - init(navigationController: UINavigationController) { + init( + navigationController: UINavigationController, + dependency: AppCoordinatorDependency + ) { self.navigationController = navigationController + self.dependency = dependency } func start() { - let tabBarCoordinator = TabBarCoordinator( - navigationController: navigationController, - coordinatorProvider: coordinatorProvider + let splashCoordinator = SplashCoordinatorImpl( + parent: self, + navigationController: navigationController, + coordinatorProvider: coordinatorProvider, + viewModelDependency: dependency ) - childs.append(tabBarCoordinator) - tabBarCoordinator.start() + childs.append(splashCoordinator) + splashCoordinator.start() } func startBusStopFlow(busStopId: String) { @@ -43,4 +57,10 @@ final class AppCoordinator: Coordinator { childs.append(busStopCoordinator) busStopCoordinator.start() } + + func openURL(_ url: URL) { + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } } diff --git a/Projects/App/Sources/Coordinator/Splash/SplashCoordinator.swift b/Projects/App/Sources/Coordinator/Splash/SplashCoordinator.swift new file mode 100644 index 00000000..a24517f6 --- /dev/null +++ b/Projects/App/Sources/Coordinator/Splash/SplashCoordinator.swift @@ -0,0 +1,54 @@ +// +// SplashCoordinator.swift +// App +// +// Created by gnksbm on 6/30/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import UIKit + +import MainFeature +import FeatureDependency + +protocol SplashCoordinator: Coordinator { + func startTabFlow() + func openURL(_ url: URL) +} + +final class SplashCoordinatorImpl: SplashCoordinator { + var parent: Coordinator? + var childs: [Coordinator] = [] + var navigationController: UINavigationController + public var coordinatorType: CoordinatorType = .splash + private let coordinatorProvider: CoordinatorProvider + private let viewModelDependency: SplashViewModelDependency + + init( + parent: Coordinator, + navigationController: UINavigationController, + coordinatorProvider: CoordinatorProvider, + viewModelDependency: SplashViewModelDependency + ) { + self.parent = parent + self.navigationController = navigationController + self.coordinatorProvider = coordinatorProvider + self.viewModelDependency = viewModelDependency + } + + func start() { + let splashViewController = SplashViewController( + viewModel: SplashViewModel(coordinator: self, dependency: viewModelDependency) + ) + navigationController.setViewControllers([splashViewController], animated: false) + } + + func startTabFlow() { + let tabBarCoordinator = TabBarCoordinator( + navigationController: navigationController, + coordinatorProvider: coordinatorProvider + ) + childs.append(tabBarCoordinator) + tabBarCoordinator.start() + } +} diff --git a/Projects/App/Sources/Coordinator/Splash/SplashViewController.swift b/Projects/App/Sources/Coordinator/Splash/SplashViewController.swift new file mode 100644 index 00000000..82b9ddfb --- /dev/null +++ b/Projects/App/Sources/Coordinator/Splash/SplashViewController.swift @@ -0,0 +1,61 @@ +// +// SplashViewController.swift +// App +// +// Created by gnksbm on 6/30/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import UIKit +import DesignSystem + +import RxSwift +import RxCocoa + +final class SplashViewController: UIViewController { + private let viewModel: SplashViewModel + + private let disposeBag: DisposeBag = .init() + + init(viewModel: SplashViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = DesignSystemAsset.changeBlue.color + + let output = viewModel.transform( + input: .init( + viewDidLoad: .just(()) + ) + ) + + output.alert + .observe(on: MainScheduler.instance) + .bind(with: self) { owner, alert in + let alertController = UIAlertController( + title: alert.title, + message: alert.message, + preferredStyle: .alert + ) + alert.actions.forEach { alertAction in + let alertAction = UIAlertAction( + title: alertAction.title, + style: .default + ) { _ in + alertAction.handler() + } + alertController.addAction(alertAction) + } + owner.present(alertController, animated: true) + } + .disposed(by: disposeBag) + } +} diff --git a/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift b/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift new file mode 100644 index 00000000..3c6c4319 --- /dev/null +++ b/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift @@ -0,0 +1,123 @@ +// +// SplashViewModel.swift +// App +// +// Created by gnksbm on 6/30/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import FeatureDependency +import NetworkService +import CoreDataService +import Data +import Domain +import Core +import FirebaseModule + +import RxSwift +import RxRelay + +protocol SplashViewModelDependency { + var appVersion: AppVersionInfoResponse { get } + var appStoreID: String { get } + var domainURL: String { get } +} + +final class SplashViewModel: ViewModel { + private weak var coordinator: SplashCoordinator? + @Injected private var versionCheckUseCase: AppVersionCheckUseCase + @Injected private var firebaseLogger: FirebaseLogger + private let dependency: SplashViewModelDependency + + init( + coordinator: SplashCoordinator, + dependency: SplashViewModelDependency + ) { + self.coordinator = coordinator + self.dependency = dependency + } + + func transform(input: Input) -> Output { + let alertRelay = PublishRelay() + Task { + try await input.viewDidLoad.value + await registerDependency() + do { + let forceUpdate = try await versionCheckUseCase.checkForceUpdateNeeded() + switch forceUpdate { + case .notNeeded: + await MainActor.run { + coordinator?.startTabFlow() + } + case .needed(let appStoreURL): + let alert = Alert( + title: "업데이트 알림", + message: "더 나은 서비스를 위해 업데이트 되었어요 ! 업데이트 해주세요." + ) { + AlertAction(title: "업데이트") { [weak self] in + self?.coordinator?.openURL(appStoreURL) + } + } + alertRelay.accept(alert) + } + } catch { + firebaseLogger.log(name: "강제 업데이트 실패: \(error.localizedDescription)") + await MainActor.run { + coordinator?.startTabFlow() + } + } + } + return .init(alert: alertRelay.asObservable()) + } + + private func registerDependency() async { + let coreDataContainer = await CoreDataContainerBuilder().buildContainer() + let firebaseLogger = FirebaseLoggerImpl() + + DIContainer.setLogger(firebaseLogger) + + DIContainer.register(type: CoreDataStorage.self, CoreDataStorageImpl(container: coreDataContainer)) + DIContainer.register(type: CoreDataService.self, DefaultCoreDataService()) + DIContainer.register(type: NetworkService.self, DefaultNetworkService()) + DIContainer.register(type: LocationService.self, DefaultLocationService()) + + DIContainer.register(type: AsyncFavoritesRepository.self, AsyncFavoritesRepositoryImpl()) + DIContainer.register(type: FavoritesRepository.self, DefaultFavoritesRepository()) + DIContainer.register(type: BusStopArrivalInfoRepository.self, DefaultBusStopArrivalInfoRepository()) + DIContainer.register(type: StationListRepository.self, DefaultStationListRepository()) + DIContainer.register(type: RegularAlarmRepository.self, DefaultRegularAlarmRepository()) + DIContainer.register(type: LocalNotificationService.self, DefaultLocalNotificationService()) + DIContainer.register(type: RegularAlarmEditingService.self, DefaultRegularAlarmEditingService()) + DIContainer.register( + type: VersionCheckRepository.self, + VersionCheckRepositoryImpl( + appStoreID: dependency.appStoreID, + domainURL: dependency.domainURL + ) + ) + + DIContainer.register(type: FavoritesUseCase.self, DefaultFavoritesUseCase()) + DIContainer.register(type: RegularAlarmUseCase.self, DefaultRegularAlarmUseCase()) + DIContainer.register(type: AddRegularAlarmUseCase.self, DefaultAddRegularAlarmUseCase()) + DIContainer.register(type: SearchUseCase.self, DefaultSearchUseCase()) + DIContainer.register(type: BusStopUseCase.self, DefaultBusStopUseCase()) + DIContainer.register(type: NearMapUseCase.self, DefaultNearMapUseCase()) + DIContainer.register(type: FirebaseLogger.self, firebaseLogger) + DIContainer.register( + type: AppVersionCheckUseCase.self, + VersionCheckUseCaseImpl( + currentVersion: dependency.appVersion + ) + ) + } +} + +extension SplashViewModel { + struct Input { + let viewDidLoad: Single + } + + struct Output { + let alert: Observable + } +} diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index 31057209..22d217da 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -13,17 +13,31 @@ import NetworkService import Domain import Data -import RxSwift - -final class SceneDelegate: UIResponder, UIWindowSceneDelegate { - @Injected private var useCase: VersionCheckUseCase +final class SceneDelegate: UIResponder, + UIWindowSceneDelegate, + AppCoordinatorDependency { + @Injected private var useCase: AppVersionCheckUseCase var window: UIWindow? var appCoordinator: AppCoordinator? var deeplinkHandler: DeeplinkHandler? - let disposeBag = DisposeBag() + let _sceneWillEnterForeground = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + var sceneWillEnterForeground: AsyncStream { + _sceneWillEnterForeground.stream + } + + @InfoPlistWrapper( + key: "CFBundleShortVersionString", + defaultValue: .defaultVersion + ) + var appVersion: AppVersionInfoResponse + @InfoPlistWrapper(key: "APPSTORE_ID", defaultValue: "") + var appStoreID: String + + @InfoPlistWrapper(key: "DOMAIN_URL", defaultValue: "") + var domainURL: String func scene( _ scene: UIScene, @@ -39,7 +53,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { window?.rootViewController = navigationController appCoordinator = AppCoordinator( - navigationController: navigationController + navigationController: navigationController, + dependency: self ) appCoordinator?.start() window?.makeKeyAndVisible() @@ -61,7 +76,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { /// 앱이 Foreground로 전환될때 실행될 함수 func sceneWillEnterForeground(_ scene: UIScene) { - checkAndUpdateIfNeeded() + _sceneWillEnterForeground.continuation.yield(scene) } func sceneDidEnterBackground(_ scene: UIScene) { @@ -75,47 +90,5 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { deeplinkHandler?.handleUrl(url: url) } } - - private func checkAndUpdateIfNeeded() { - useCase.fetchAppStoreURL() - .subscribe(with: self) { owner, str in - guard let str else { return } - owner.showUpdateAlert(with: str) - } onFailure: { _, error in - print(error) - } - .disposed(by: disposeBag) - } - - private func showUpdateAlert(with urlString: String) { - let alert = UIAlertController( - title: "업데이트 알림", - message: "더 나은 서비스를 위해 업데이트 되었어요 ! 업데이트 해주세요.", - preferredStyle: .alert - ) - - let alertAction = UIAlertAction( - title: "업데이트", - style: .default - ) { [weak self] _ in - guard let self else { return } - - openAppStore(urlString) - } - - alert.addAction(alertAction) - - Task { @MainActor in - window?.rootViewController?.present(alert, animated: true) - } - } - - private func openAppStore(_ str: String) { - guard let url = URL(string: str) else { return } - - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - } } diff --git a/Projects/Core/Sources/Extension/String+.swift b/Projects/Core/Sources/Extension/String+.swift index e4162762..2846257b 100644 --- a/Projects/Core/Sources/Extension/String+.swift +++ b/Projects/Core/Sources/Extension/String+.swift @@ -24,34 +24,18 @@ public extension String { else { return .now } return date } + /// 공공 버스 API Key - static var serverKey: Self { - guard let any = Bundle.main.object( - forInfoDictionaryKey: "DATA_GO_KR_API_KEY" - ), - let serverKey = any as? String - else { fatalError("Can't Not Find Server Key") } - return serverKey - } + @InfoPlistWrapper(key: "DATA_GO_KR_API_KEY", defaultValue: "") + static var serverKey: Self /// domain url - static var domainURL: Self { - guard let any = Bundle.main.object( - forInfoDictionaryKey: "DOMAIN_URL" - ), - let domain = any as? String - else { return "" } - return domain - } + @InfoPlistWrapper(key: "DOMAIN_URL", defaultValue: "") + static var domainURL: Self /// 프로젝트 버전 - static func getCurrentVersion() -> Self { - guard let dictionary = Bundle.main.infoDictionary, - let version = dictionary["CFBundleShortVersionString"] as? String - else { return "1.0.0" } - - return version - } + @InfoPlistWrapper(key: "CFBundleShortVersionString", defaultValue: "1.0.0") + static var currentVersion: Self static func getDeviceIdentifier() -> String { var systemInfo = utsname() diff --git a/Projects/Core/Sources/Extension/TimeInterval+.swift b/Projects/Core/Sources/Extension/TimeInterval+.swift new file mode 100644 index 00000000..0a67d6b1 --- /dev/null +++ b/Projects/Core/Sources/Extension/TimeInterval+.swift @@ -0,0 +1,15 @@ +// +// TimeInterval+.swift +// Core +// +// Created by gnksbm on 7/5/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import Foundation + +public extension TimeInterval { + static func hour(_ value: Int) -> Self { + Self(value) * 60 * 60 + } +} diff --git a/Projects/Core/Sources/PropertyWrapper/InfoPlistWrapper.swift b/Projects/Core/Sources/PropertyWrapper/InfoPlistWrapper.swift new file mode 100644 index 00000000..c79a8255 --- /dev/null +++ b/Projects/Core/Sources/PropertyWrapper/InfoPlistWrapper.swift @@ -0,0 +1,134 @@ +// +// InfoPlistWrapper.swift +// Core +// +// Created by gnksbm on 7/6/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import Foundation + +@propertyWrapper +public struct InfoPlistWrapper { + private let key: String + private let defaultValue: T + + public var wrappedValue: T { + T.init(rawValue: Bundle.main.object(forInfoDictionaryKey: key)) ?? defaultValue + } + + public init( + key: String, + defaultValue: T + ) { + self.key = key + self.defaultValue = defaultValue + } +} + +extension Optional: InfoPlistLoadable where Wrapped: InfoPlistLoadable { + public init?(rawValue: Any?) { + if let value = Wrapped(rawValue: rawValue) { + self = .some(value) + } else { + self = .none + } + } +} + +public extension InfoPlistWrapper { + init(key: String) where T == Wrapped? { + self.init(key: key, defaultValue: nil) + } +} + +public protocol InfoPlistLoadable { + init?(rawValue: Any?) +} + +extension String: InfoPlistLoadable { + public init?(rawValue: Any?) { + if let string = rawValue as? String { + self = string + } else { + return nil + } + } +} + +extension Int: InfoPlistLoadable { + public init?(rawValue: Any?) { + if let number = rawValue as? NSNumber { + self = number.intValue + } else if let string = rawValue as? String, let intVal = Int(string) { + self = intVal + } else { + return nil + } + } +} + +extension Double: InfoPlistLoadable { + public init?(rawValue: Any?) { + if let number = rawValue as? NSNumber { + self = number.doubleValue + } else if let string = rawValue as? String, let doubleVal = Double(string) { + self = doubleVal + } else { + return nil + } + } +} + +extension Bool: InfoPlistLoadable { + public init?(rawValue: Any?) { + if let bool = rawValue as? Bool { + self = bool + } else if let string = rawValue as? String { + switch string.lowercased() { + case "true", "yes", "1": + self = true + case "false", "no", "0": + self = false + default: + return nil + } + } else { + return nil + } + } +} + +extension Array: InfoPlistLoadable where Element: InfoPlistLoadable { + public init?(rawValue: Any?) { + guard let rawArray = rawValue as? [Any] else { + return nil + } + var result: [Element] = [] + for element in rawArray { + if let value = Element(rawValue: element) { + result.append(value) + } else { + return nil + } + } + self = result + } +} + +extension Dictionary: InfoPlistLoadable where Key == String, Value: InfoPlistLoadable { + public init?(rawValue: Any?) { + guard let rawDict = rawValue as? [String: Any] else { + return nil + } + var result: [String: Value] = [:] + for (key, value) in rawDict { + if let v = Value(rawValue: value) { + result[key] = v + } else { + return nil + } + } + self = result + } +} diff --git a/Projects/Core/Sources/PropertyWrapper/UserDefaultsWrapper.swift b/Projects/Core/Sources/PropertyWrapper/UserDefaultsWrapper.swift index 4a8b49a0..c3affec8 100644 --- a/Projects/Core/Sources/PropertyWrapper/UserDefaultsWrapper.swift +++ b/Projects/Core/Sources/PropertyWrapper/UserDefaultsWrapper.swift @@ -46,6 +46,15 @@ public struct UserDefaultsWrapper { } } +public extension UserDefaultsWrapper { + init( + key: String, + kind: UserDefaultsKind = .appGroup + ) where T == Wrapped? { + self.init(key: key, defaultValue: nil, kind: kind) + } +} + public enum UserDefaultsKind { case appGroup, standard diff --git a/Projects/Core/Sources/TypeBuilder.swift b/Projects/Core/Sources/TypeBuilder.swift new file mode 100644 index 00000000..876c4468 --- /dev/null +++ b/Projects/Core/Sources/TypeBuilder.swift @@ -0,0 +1,16 @@ +// +// TypeBuilder.swift +// Core +// +// Created by gnksbm on 7/6/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import Foundation + +@resultBuilder +public enum TypeBuilder { + public static func buildBlock(_ components: T...) -> [T] { + components + } +} diff --git a/Projects/CoreDataService/Sources/CoreDataContainerBuilder.swift b/Projects/CoreDataService/Sources/CoreDataContainerBuilder.swift new file mode 100644 index 00000000..b3224db0 --- /dev/null +++ b/Projects/CoreDataService/Sources/CoreDataContainerBuilder.swift @@ -0,0 +1,57 @@ +// +// CoreDataContainerBuilder.swift +// CoreDataService +// +// Created by Logan on 6/21/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import CoreData +import CloudKit + +public final class CoreDataContainerBuilder { + private enum Constants { + static let fileName: String = "Model" + static let appGroupName: String = "group.Pepsi-Club.WhereMyBus" + static let containerIdentifier: String = "iCloud.Pepsi-Club.WhereMyBus" + } + + public init() { } + // 에러가 방출될 때 처리 방식을 고민해야 한다. + // 1. appGroupStoreUrl, 2. CKContainer.default().accountStatus(), 3. loadPersistentStores + public func buildContainer() async -> NSPersistentContainer { + let container: NSPersistentContainer = await { + let container: NSPersistentContainer + let appGroupStoreUrl = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: Constants.appGroupName)? + .appendingPathComponent("\(Constants.fileName).sqlite") ?? URL(filePath: "") + let persistentStoreDescription = NSPersistentStoreDescription(url: appGroupStoreUrl) + if await CKContainer.shouldUseCloudKit { + container = NSPersistentCloudKitContainer(name: Constants.fileName) + persistentStoreDescription.cloudKitContainerOptions = .init( + containerIdentifier: Constants.containerIdentifier + ) + } else { + container = NSPersistentContainer(name: Constants.fileName) + } + container.viewContext.automaticallyMergesChangesFromParent = true + container.persistentStoreDescriptions = [persistentStoreDescription] + return container + }() + let _: Void = await withCheckedContinuation { continuation in + container.loadPersistentStores { _, _ in + continuation.resume() + } + } + return container + } +} + +extension CKContainer { + static var shouldUseCloudKit: Bool { + get async { + let status = try? await CKContainer.default().accountStatus() + return status == .available + } + } +} diff --git a/Projects/CoreDataService/Sources/CoreDataRepresentable.swift b/Projects/CoreDataService/Sources/CoreDataRepresentable.swift new file mode 100644 index 00000000..c3cbf482 --- /dev/null +++ b/Projects/CoreDataService/Sources/CoreDataRepresentable.swift @@ -0,0 +1,17 @@ +// +// CoreDataRepresentable.swift +// CoreDataService +// +// Created by Logan on 6/21/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import CoreData + +public protocol CoreDataRepresentable: Identifiable where ID: CVarArg { + associatedtype ManagedObject: NSManagedObject + + init(_ managedObject: ManagedObject) throws + + func apply(to managedObject: ManagedObject) +} diff --git a/Projects/CoreDataService/Sources/CoreDataStorage.swift b/Projects/CoreDataService/Sources/CoreDataStorage.swift new file mode 100644 index 00000000..55e8b80b --- /dev/null +++ b/Projects/CoreDataService/Sources/CoreDataStorage.swift @@ -0,0 +1,14 @@ +// +// CoreDataStorage.swift +// CoreDataService +// +// Created by Logan on 6/21/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +public protocol CoreDataStorage { + func create(data: T) async throws + func read(type: T.Type) async throws -> [T] + func update(data: T) async throws + func delete(data: T) async throws +} diff --git a/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift b/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift new file mode 100644 index 00000000..ea3501c5 --- /dev/null +++ b/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift @@ -0,0 +1,100 @@ +// +// CoreDataStorageImpl.swift +// CoreDataService +// +// Created by Logan on 6/21/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import CoreData + +public final class CoreDataStorageImpl { + enum CoreDataStorageError: Error { + case invalidManagedObject(String) + } + + private let context: NSManagedObjectContext + private let batchSize: Int + + public init(container: NSPersistentContainer, batchSize: Int = 50) { + let taskContext = container.newBackgroundContext() + // 메모리·퍼포먼스 최적화: undo 스택을 쓰지 않으므로 불필요한 메모리 사용을 줄일 수 있습니다. + taskContext.undoManager = nil + // 충돌 해소 정책: mergePolicy를 명시해 두면 동시 변경 충돌 시 일관된 동작이 보장됩니다. + taskContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + self.context = taskContext + self.batchSize = batchSize + } + + private func readManagedObject(for data: T) async throws -> T.ManagedObject { + try await context.perform { [self] in + let request = NSFetchRequest(entityName: String(describing: T.ManagedObject.self)) + request.predicate = NSPredicate(format: "id == %@", data.id as CVarArg) + request.fetchLimit = 1 + guard let first = try context.fetch(request).first else { + throw CoreDataStorageError.invalidManagedObject( + "\(T.ManagedObject.self)를 찾을 수 없습니다. Data ID: \(data.id)" + ) + } + return first + } + } + + private func saveContext() async throws { + try await context.perform { [self] in + if context.hasChanges { + do { + try context.save() + } catch { + context.rollback() + throw error + } + } + } + } +} + +extension CoreDataStorageImpl: CoreDataStorage { + public func create(data: T) async throws { + try await context.perform { [self] in + let object = NSEntityDescription.insertNewObject( + forEntityName: String(describing: T.ManagedObject.self), + into: context + ) + guard let coreDataManagedObject = object as? T.ManagedObject else { + throw CoreDataStorageError.invalidManagedObject( + "타입 불일치: \(type(of: object)) != \(T.ManagedObject.self)" + ) + } + data.apply(to: coreDataManagedObject) + } + try await saveContext() + } + + public func read(type: T.Type) async throws -> [T] { + let managedObjects = try await context.perform { [self] in + let request = NSFetchRequest(entityName: String(describing: T.ManagedObject.self)) + request.fetchLimit = 0 + request.fetchBatchSize = batchSize + + return try context.fetch(request) + } + return try managedObjects.map { try T.init($0) } + } + + public func update(data: T) async throws { + let managedObject = try await readManagedObject(for: data) + await context.perform { + data.apply(to: managedObject) + } + try await saveContext() + } + + public func delete(data: T) async throws { + let managedObject = try await readManagedObject(for: data) + await context.perform { [self] in + context.delete(managedObject) + } + try await saveContext() + } +} diff --git a/Projects/CoreDataService/Sources/CoreDataDirectory.swift b/Projects/CoreDataService/Sources/Legacy/CoreDataDirectory.swift similarity index 100% rename from Projects/CoreDataService/Sources/CoreDataDirectory.swift rename to Projects/CoreDataService/Sources/Legacy/CoreDataDirectory.swift diff --git a/Projects/CoreDataService/Sources/CoreDataService.swift b/Projects/CoreDataService/Sources/Legacy/CoreDataService.swift similarity index 87% rename from Projects/CoreDataService/Sources/CoreDataService.swift rename to Projects/CoreDataService/Sources/Legacy/CoreDataService.swift index b592b881..13713c2a 100644 --- a/Projects/CoreDataService/Sources/CoreDataService.swift +++ b/Projects/CoreDataService/Sources/Legacy/CoreDataService.swift @@ -12,6 +12,7 @@ import Core import RxSwift +@available(*, deprecated, renamed: "CoreDataStorage", message: "이 객체는 제거될 예정입니다. CoreDataStorage를 사용하세요.") public protocol CoreDataService { var storeStatus: BehaviorSubject { get } diff --git a/Projects/CoreDataService/Sources/DefaultCoreDataService.swift b/Projects/CoreDataService/Sources/Legacy/DefaultCoreDataService.swift similarity index 100% rename from Projects/CoreDataService/Sources/DefaultCoreDataService.swift rename to Projects/CoreDataService/Sources/Legacy/DefaultCoreDataService.swift diff --git a/Projects/CoreDataService/Sources/StoreStatus.swift b/Projects/CoreDataService/Sources/Legacy/StoreStatus.swift similarity index 100% rename from Projects/CoreDataService/Sources/StoreStatus.swift rename to Projects/CoreDataService/Sources/Legacy/StoreStatus.swift diff --git a/Projects/Data/Sources/DTO/BusStopArrivalInfoDTO.swift b/Projects/Data/Sources/DTO/BusStopArrivalInfoDTO.swift index 20fa5f65..5ea108fc 100644 --- a/Projects/Data/Sources/DTO/BusStopArrivalInfoDTO.swift +++ b/Projects/Data/Sources/DTO/BusStopArrivalInfoDTO.swift @@ -16,6 +16,24 @@ public struct BusStopArrivalInfoDTO: Codable { } public extension BusStopArrivalInfoDTO { + enum BusStopArrivalInfoDTOError: Error { + case invalidResponse(message: String) + } + + var _toDomain: BusStopArrivalInfoResponse { + get throws { + guard msgHeader.headerCD == "0" else { + throw BusStopArrivalInfoDTOError.invalidResponse(message: msgHeader.headerCodeMessage) + } + return .init( + busStopId: getBusStopId ?? "정류장 ID 없음", + busStopName: getBusStopName ?? "정류장 이름 없음", + direction: getDirection ?? "정류장 방면 없음", + buses: getBuses + ) + } + } + var toDomain: BusStopArrivalInfoResponse? { guard msgHeader.headerCD == "0" else { return nil } diff --git a/Projects/Data/Sources/DTO/CoreDataModelObject/CoreDataModelObject+Error.swift b/Projects/Data/Sources/DTO/CoreDataModelObject/CoreDataModelObject+Error.swift new file mode 100644 index 00000000..2a3f6309 --- /dev/null +++ b/Projects/Data/Sources/DTO/CoreDataModelObject/CoreDataModelObject+Error.swift @@ -0,0 +1,34 @@ +// +// CoreDataModelObject+Error.swift +// Data +// +// Created by gnksbm on 7/28/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import CoreData + +import CoreDataService + +enum DTOParsingError: LocalizedError { + case missingAttribute(String) + + var errorDescription: String? { + switch self { + case .missingAttribute(let key): + return "DTO 파싱 오류: '\(key)' 속성이 누락되었습니다." + } + } +} + +protocol DTOParsable { } + +extension DTOParsable { + func unwrap(_ keyPath: KeyPath) throws -> T { + guard let value = self[keyPath: keyPath] else { + let key = keyPath._kvcKeyPathString ?? String(describing: keyPath) + throw DTOParsingError.missingAttribute(key) + } + return value + } +} diff --git a/Projects/Data/Sources/DTO/CoreDataModelObject/FavoritesBusResponseMO+CoreDataClass.swift b/Projects/Data/Sources/DTO/CoreDataModelObject/FavoritesBusResponseMO+CoreDataClass.swift index c0ca5862..054537ea 100644 --- a/Projects/Data/Sources/DTO/CoreDataModelObject/FavoritesBusResponseMO+CoreDataClass.swift +++ b/Projects/Data/Sources/DTO/CoreDataModelObject/FavoritesBusResponseMO+CoreDataClass.swift @@ -1,6 +1,6 @@ // // FavoritesBusResponseMO+CoreDataClass.swift -// +// // // Created by gnksbm on 4/16/24. // @@ -11,9 +11,10 @@ import CoreData import Core import Domain +import CoreDataService @objc(FavoritesBusResponseMO) -public class FavoritesBusResponseMO: NSManagedObject, CoreDataModelObject { +public class FavoritesBusResponseMO: NSManagedObject, CoreDataModelObject, DTOParsable { public var toDomain: CoreDataStorable { guard let busStopId, let busStopName, @@ -30,3 +31,38 @@ public class FavoritesBusResponseMO: NSManagedObject, CoreDataModelObject { ) } } + +extension FavoritesBusResponse: CoreDataRepresentable { + private func requiredValue(_ value: T?, forKey key: String) throws -> T { + guard let value = value else { + throw CocoaError(.validationMissingMandatoryProperty, userInfo: [NSValidationKeyErrorKey: key]) + } + return value + } + + public var id: String { identifier } + + public init(_ managedObject: FavoritesBusResponseMO) throws { + let busStopId = try managedObject.unwrap(\.busStopId) + let busStopName = try managedObject.unwrap(\.busStopName) + let busId = try managedObject.unwrap(\.busId) + let busName = try managedObject.unwrap(\.busName) + let adirection = try managedObject.unwrap(\.adirection) + self.init( + busStopId: busStopId, + busStopName: busStopName, + busId: busId, + busName: busName, + adirection: adirection + ) + } + + public func apply(to managedObject: FavoritesBusResponseMO) { + managedObject.identifier = identifier + managedObject.busStopId = busStopId + managedObject.busStopName = busStopName + managedObject.busId = busId + managedObject.busName = busName + managedObject.adirection = adirection + } +} diff --git a/Projects/Data/Sources/Repository/DefaultBusStopArrivalInfoRepository.swift b/Projects/Data/Sources/Repository/DefaultBusStopArrivalInfoRepository.swift index 07ed5cfa..83f16adf 100644 --- a/Projects/Data/Sources/Repository/DefaultBusStopArrivalInfoRepository.swift +++ b/Projects/Data/Sources/Repository/DefaultBusStopArrivalInfoRepository.swift @@ -19,6 +19,12 @@ public final class DefaultBusStopArrivalInfoRepository: NSObject, BusStopArrival @Injected private var networkService: NetworkService @Injected private var logger: FirebaseLogger + public func fetchArrivalList(busStopId: String) async throws -> BusStopArrivalInfoResponse { + try await networkService.request(endPoint: BusStopArrivalInfoEndPoint(arsId: busStopId)) + .decode(type: BusStopArrivalInfoDTO.self) + ._toDomain + } + public func fetchArrivalList(busStopId: String) -> Observable { logger.log(name: "fetchArrivalEvent") return networkService.request( diff --git a/Projects/Data/Sources/Repository/DefaultFavoritesRepository.swift b/Projects/Data/Sources/Repository/DefaultFavoritesRepository.swift index cf8bf96c..24adc808 100644 --- a/Projects/Data/Sources/Repository/DefaultFavoritesRepository.swift +++ b/Projects/Data/Sources/Repository/DefaultFavoritesRepository.swift @@ -15,6 +15,44 @@ import Core import RxSwift +public final actor AsyncFavoritesRepositoryImpl: AsyncFavoritesRepository { + @Injected private var coreDataStorage: CoreDataStorage + @Injected private var networkService: NetworkService + + public nonisolated let favoritesStream: AsyncStream<[FavoritesBusResponse]> + private let favoritesContinuation: AsyncStream<[FavoritesBusResponse]>.Continuation + + private var currentFavorites: [FavoritesBusResponse] = [] + + public init() { + let (stream, continuation) = AsyncStream<[FavoritesBusResponse]>.makeStream( + of: [FavoritesBusResponse].self, + bufferingPolicy: .bufferingNewest(1) + ) + self.favoritesStream = stream + self.favoritesContinuation = continuation + } + + public func fetchFavorites() async throws -> [FavoritesBusResponse] { + let favorites = try await coreDataStorage.read(type: FavoritesBusResponse.self) + currentFavorites = favorites + favoritesContinuation.yield(favorites) + return favorites + } + + public func addFavorites(favorite: FavoritesBusResponse) async throws { + try await coreDataStorage.create(data: favorite) + currentFavorites.append(favorite) + favoritesContinuation.yield(currentFavorites) + } + + public func removeFavorites(favorite: FavoritesBusResponse) async throws { + try await coreDataStorage.delete(data: favorite) + currentFavorites = currentFavorites.filter({ $0 != favorite }) + favoritesContinuation.yield(currentFavorites) + } +} + public final class DefaultFavoritesRepository: FavoritesRepository { @Injected private var coreDataService: CoreDataService @Injected private var networkService: NetworkService diff --git a/Projects/Data/Sources/Repository/DefaultVersionCheckRepository.swift b/Projects/Data/Sources/Repository/DefaultVersionCheckRepository.swift index c3b5e934..21ae9118 100644 --- a/Projects/Data/Sources/Repository/DefaultVersionCheckRepository.swift +++ b/Projects/Data/Sources/Repository/DefaultVersionCheckRepository.swift @@ -12,83 +12,37 @@ import Core import Domain import NetworkService -import RxSwift - -public final class DefaultVersionCheckRepository: VersionCheckRepository { +public final class VersionCheckRepositoryImpl: VersionCheckRepository { @Injected private var networkService: NetworkService - @UserDefaultsWrapper( - key: "ForceUpdate", - defaultValue: ForceUpdate( - version: AppVersionInfoResponse(major: 1, minor: 2, patch: 4), - date: Date(timeIntervalSince1970: 0) - ) - ) - private var forceUpdateInfo: ForceUpdate + @UserDefaultsWrapper(key: "ForceUpdate") + var versionCheckInfo: VersionCheckInfo? - public init() { } + private let appStoreID: String + private let domainURL: String - /// 서버로 부터 받은 App의 최소 지원 버전 - public func fetchRequiredVersion() - -> Single> { - return networkService.request( - endPoint: MinVersionEndpoint(domain: getDomainURL()), - responseType: RequiredVersionDTO.self - ) - .map { result in - switch result { - case .success(let value): - return .success(value.toDomain) - case .failure(let error): - return .failure(error) - } - } + public init(appStoreID: String, domainURL: String) { + self.appStoreID = appStoreID + self.domainURL = domainURL } - public func getStoreLink() -> String? { - return OpenStoreEndpoint(appStoreID: getAppStoreID()).toURLString + public func getCachedVersionCheckInfo() -> VersionCheckInfo? { + versionCheckInfo } - public func getAppStoreID() -> String { - guard let appId = Bundle.main.object( - forInfoDictionaryKey: "APPSTORE_ID" - ) as? String - else { return "" } - - return appId - } - - public func getUserAppVersion() -> AppVersionInfoResponse { - guard let dictionary = Bundle.main.infoDictionary, - let version = dictionary["CFBundleShortVersionString"] as? String - else { return AppVersionInfoResponse(major: 1, minor: 0, patch: 0) } - - let splitedVersion = version.split(separator: ".") - .compactMap { Int($0) } - - return AppVersionInfoResponse( - major: splitedVersion[0], - minor: splitedVersion[1], - patch: splitedVersion[2] + public func fetchRequiredVersion() async throws -> AppVersionInfoResponse { + try await networkService.request( + endPoint: MinVersionEndpoint(domain: domainURL) ) + .decode(type: RequiredVersionDTO.self) + .toDomain } - /// 최소 요구 버전, fetch 받은 날짜를 UserDefaults 저장 - public func saveForceUpdateInfo(_ newValue: ForceUpdate) { - forceUpdateInfo = newValue - } - - /// UserDefaults 저장된 최소 요구 버전, fetch 받은 날짜 - public func getForceUpdateInfo() -> ForceUpdate { - return forceUpdateInfo + public func saveVersionCheckInfoCache(_ versionCheckInfo: VersionCheckInfo) { + self.versionCheckInfo = versionCheckInfo } - private func getDomainURL() -> String { - guard let domainURL = Bundle.main.object( - forInfoDictionaryKey: "DOMAIN_URL" - ) as? String - else { return "" } - - return domainURL + public func getAppStoreURL() throws -> URL { + try OpenStoreEndpoint(appStoreID: appStoreID).toURL } } diff --git a/Projects/Data/Sources/Service/ForceUpdateService/DefaultForceUpdateService.swift b/Projects/Data/Sources/Service/ForceUpdateService/DefaultForceUpdateService.swift deleted file mode 100644 index 9db22d53..00000000 --- a/Projects/Data/Sources/Service/ForceUpdateService/DefaultForceUpdateService.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// DefaultForceUpdateService.swift -// Data -// -// Created by Jisoo Ham on 3/27/25. -// Copyright © 2025 Pepsi-Club. All rights reserved. -// - -import Foundation - -import Domain - -public final class DefaultForceUpdateService: ForceUpdateService { - public init() { } - - public func compareVersion( - user: AppVersionInfoResponse, - required: AppVersionInfoResponse - ) -> Bool { - return required > user - } -} diff --git a/Projects/DesignSystem/Project.swift b/Projects/DesignSystem/Project.swift index 768f03ff..2705e2f5 100644 --- a/Projects/DesignSystem/Project.swift +++ b/Projects/DesignSystem/Project.swift @@ -2,7 +2,7 @@ import ProjectDescription import ProjectDescriptionHelpers let project = Project(name: "DesignSystem") { - DesignSystem() { + DesignSystem { Lottie() FrameworkInfoPlist(marketingVersion: .marketingVersion) } diff --git a/Projects/Domain/Sources/Entity/Alert.swift b/Projects/Domain/Sources/Entity/Alert.swift new file mode 100644 index 00000000..84e45d59 --- /dev/null +++ b/Projects/Domain/Sources/Entity/Alert.swift @@ -0,0 +1,33 @@ +// +// Alert.swift +// Domain +// +// Created by gnksbm on 7/6/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import Foundation + +import Core + +public struct Alert { + public let title: String + public let message: String + public let actions: [AlertAction] + + public init(title: String, message: String, @TypeBuilder actions: () -> [AlertAction]) { + self.title = title + self.message = message + self.actions = actions() + } +} + +public struct AlertAction { + public let title: String + public let handler: () -> Void + + public init(title: String, handler: @escaping () -> Void) { + self.title = title + self.handler = handler + } +} diff --git a/Projects/Domain/Sources/Entity/ForceUpdate.swift b/Projects/Domain/Sources/Entity/ForceUpdate.swift index 56c2de5b..ec0229d6 100644 --- a/Projects/Domain/Sources/Entity/ForceUpdate.swift +++ b/Projects/Domain/Sources/Entity/ForceUpdate.swift @@ -8,15 +8,7 @@ import Foundation -public struct ForceUpdate: Codable { - let version: AppVersionInfoResponse - let date: Date - - public init( - version: AppVersionInfoResponse, - date: Date - ) { - self.version = version - self.date = date - } +public enum ForceUpdate { + case notNeeded + case needed(appStoreURL: URL) } diff --git a/Projects/Domain/Sources/Entity/Response/AppVersionInfoResponse.swift b/Projects/Domain/Sources/Entity/Response/AppVersionInfoResponse.swift index ecba196e..523bbc29 100644 --- a/Projects/Domain/Sources/Entity/Response/AppVersionInfoResponse.swift +++ b/Projects/Domain/Sources/Entity/Response/AppVersionInfoResponse.swift @@ -8,7 +8,11 @@ import Foundation +import Core + public struct AppVersionInfoResponse: Codable, Comparable { + public static let defaultVersion: Self = .init(major: 1, minor: 0, patch: 0) + let major: Int let minor: Int let patch: Int @@ -22,7 +26,7 @@ public struct AppVersionInfoResponse: Codable, Comparable { self.minor = minor self.patch = patch } - + public static func < ( lhs: AppVersionInfoResponse, rhs: AppVersionInfoResponse @@ -32,3 +36,19 @@ public struct AppVersionInfoResponse: Codable, Comparable { return lhs.patch < rhs.patch } } + +extension AppVersionInfoResponse: InfoPlistLoadable { + public init?(rawValue: Any?) { + guard let stringData = rawValue as? String else { return nil } + + let splitedVersion = stringData.split(separator: ".").compactMap { Int($0) } + + guard splitedVersion.count == 3 else { return nil } + + self = AppVersionInfoResponse( + major: splitedVersion[0], + minor: splitedVersion[1], + patch: splitedVersion[2] + ) + } +} diff --git a/Projects/Domain/Sources/Entity/Response/BusStopArrivalInfoResponse.swift b/Projects/Domain/Sources/Entity/Response/BusStopArrivalInfoResponse.swift index 2400430b..12ddcfd0 100644 --- a/Projects/Domain/Sources/Entity/Response/BusStopArrivalInfoResponse.swift +++ b/Projects/Domain/Sources/Entity/Response/BusStopArrivalInfoResponse.swift @@ -124,9 +124,8 @@ public extension Array { favoritesList: [FavoritesBusResponse] ) -> Self { let favoritesDic = Dictionary( - uniqueKeysWithValues: favoritesList.map { favorites in - (favorites.identifier, true) - } + favoritesList.map { ($0.identifier, true) }, + uniquingKeysWith: { first, _ in first } ) return map { busStop in var updatedBuses: [BusArrivalInfoResponse] = busStop.buses diff --git a/Projects/Domain/Sources/Entity/Response/FavoritesBusResponse.swift b/Projects/Domain/Sources/Entity/Response/FavoritesBusResponse.swift index ba83deec..3847c2f7 100644 --- a/Projects/Domain/Sources/Entity/Response/FavoritesBusResponse.swift +++ b/Projects/Domain/Sources/Entity/Response/FavoritesBusResponse.swift @@ -8,6 +8,8 @@ import Foundation +import CoreData + import Core public struct FavoritesBusResponse: CoreDataStorable, Equatable { diff --git a/Projects/Domain/Sources/Entity/VersionCheckInfo.swift b/Projects/Domain/Sources/Entity/VersionCheckInfo.swift new file mode 100644 index 00000000..ce89299e --- /dev/null +++ b/Projects/Domain/Sources/Entity/VersionCheckInfo.swift @@ -0,0 +1,19 @@ +// +// VersionCheckInfo.swift +// Domain +// +// Created by gnksbm on 7/5/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import Foundation + +public struct VersionCheckInfo: Codable { + public let requiredVersion: AppVersionInfoResponse + public let updatedAt: Date + + public init(requiredVersion: AppVersionInfoResponse, updatedAt: Date) { + self.requiredVersion = requiredVersion + self.updatedAt = updatedAt + } +} diff --git a/Projects/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift b/Projects/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift index c1c9704f..449cc406 100644 --- a/Projects/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift +++ b/Projects/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift @@ -11,6 +11,9 @@ import Foundation import RxSwift public protocol BusStopArrivalInfoRepository { + func fetchArrivalList(busStopId: String) async throws -> BusStopArrivalInfoResponse + + @available(*, deprecated, message: "이 메서드는 제거될 예정입니다.") func fetchArrivalList( busStopId: String ) -> Observable diff --git a/Projects/Domain/Sources/RepositoryInterface/FavoritesRepository.swift b/Projects/Domain/Sources/RepositoryInterface/FavoritesRepository.swift index 6504078d..9aed9a10 100644 --- a/Projects/Domain/Sources/RepositoryInterface/FavoritesRepository.swift +++ b/Projects/Domain/Sources/RepositoryInterface/FavoritesRepository.swift @@ -11,9 +11,30 @@ import Foundation import RxSwift public protocol FavoritesRepository { + @available(*, deprecated, message: "이 변수는 제거될 예정입니다.") var favorites: BehaviorSubject<[FavoritesBusResponse]> { get } + @available(*, deprecated, message: "이 메서드는 제거될 예정입니다.") func fetchFavorites() -> Observable<[FavoritesBusResponse]> + @available(*, deprecated, message: "이 메서드는 제거될 예정입니다.") func addFavorites(favorites: FavoritesBusResponse) throws + @available(*, deprecated, message: "이 메서드는 제거될 예정입니다.") func removeFavorites(favorites: FavoritesBusResponse) throws } + +public protocol AsyncFavoritesRepository { + var favoritesStream: AsyncStream<[FavoritesBusResponse]> { get } + + func fetchFavorites() async throws -> [FavoritesBusResponse] + func addFavorites(favorite: FavoritesBusResponse) async throws + func removeFavorites(favorite: FavoritesBusResponse) async throws +} + +extension AsyncFavoritesRepository { + func fetchFavorites() -> Observable<[FavoritesBusResponse]> { + Single.create { + try await fetchFavorites() + } + .asObservable() + } +} diff --git a/Projects/Domain/Sources/RepositoryInterface/RegularAlarmRepository.swift b/Projects/Domain/Sources/RepositoryInterface/RegularAlarmRepository.swift index 6f3d0b8f..c05408bd 100644 --- a/Projects/Domain/Sources/RepositoryInterface/RegularAlarmRepository.swift +++ b/Projects/Domain/Sources/RepositoryInterface/RegularAlarmRepository.swift @@ -11,16 +11,20 @@ import Foundation import RxSwift public protocol RegularAlarmRepository { + @available(*, deprecated, message: "이 변수는 제거될 예정입니다.") var currentRegularAlarm: BehaviorSubject<[RegularAlarmResponse]> { get } + @available(*, deprecated, message: "이 메서드는 제거될 예정입니다.") func createRegularAlarm( response: RegularAlarmResponse, completion: @escaping () -> Void ) + @available(*, deprecated, message: "이 메서드는 제거될 예정입니다.") func updateRegularAlarm( response: RegularAlarmResponse, completion: @escaping () -> Void ) + @available(*, deprecated, message: "이 메서드는 제거될 예정입니다.") func deleteRegularAlarm( response: RegularAlarmResponse, completion: @escaping () -> Void diff --git a/Projects/Domain/Sources/RepositoryInterface/VersionCheckRepository.swift b/Projects/Domain/Sources/RepositoryInterface/VersionCheckRepository.swift index a1f9cc0b..94387c9a 100644 --- a/Projects/Domain/Sources/RepositoryInterface/VersionCheckRepository.swift +++ b/Projects/Domain/Sources/RepositoryInterface/VersionCheckRepository.swift @@ -8,14 +8,10 @@ import Foundation -import RxSwift - public protocol VersionCheckRepository: AnyObject { - func fetchRequiredVersion() - -> Single> - func getStoreLink() -> String? - func getAppStoreID() -> String - func getUserAppVersion() -> AppVersionInfoResponse - func saveForceUpdateInfo(_ info: ForceUpdate) - func getForceUpdateInfo() -> ForceUpdate + func getCachedVersionCheckInfo() -> VersionCheckInfo? + func saveVersionCheckInfoCache(_ versionCheckInfo: VersionCheckInfo) + func getAppStoreURL() throws -> URL + + func fetchRequiredVersion() async throws -> AppVersionInfoResponse } diff --git a/Projects/Domain/Sources/Service/ForceUpdateService/ForceUpdateService.swift b/Projects/Domain/Sources/Service/ForceUpdateService/ForceUpdateService.swift deleted file mode 100644 index 066278aa..00000000 --- a/Projects/Domain/Sources/Service/ForceUpdateService/ForceUpdateService.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// ForceUpdateService.swift -// Domain -// -// Created by Jisoo Ham on 3/27/25. -// Copyright © 2025 Pepsi-Club. All rights reserved. -// - -import Foundation - -public protocol ForceUpdateService { - func compareVersion( - user: AppVersionInfoResponse, - required: AppVersionInfoResponse - ) -> Bool -} diff --git a/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift b/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift index 0f1228c2..8e9a7c81 100644 --- a/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift +++ b/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift @@ -10,78 +10,30 @@ import Foundation import Core -import RxSwift - -public final class DefaultVersionCheckUseCase: VersionCheckUseCase { +public final class VersionCheckUseCaseImpl: AppVersionCheckUseCase { @Injected private var versionCheckRepository: VersionCheckRepository - @Injected private var forceUpdateService: ForceUpdateService - - public init() { } - - public func fetchAppStoreURL() -> Single { - if hasToFetchVersion() { - return fetchAndUpdateVersion() - } else { - let forceUpdateInfo = versionCheckRepository.getForceUpdateInfo() - return .just(getStoreLink(forceUpdateInfo.version)) - } - } -} -extension DefaultVersionCheckUseCase { - /// 호출하는 시점과 UserDefaults에 저장된 Date를 기준으로 4시간이 넘는지를 확인하는 method - private func hasToFetchVersion() -> Bool { - let fourHour: TimeInterval = 4 * 60 * 60 - - return Date().timeIntervalSince( - versionCheckRepository.getForceUpdateInfo().date - ) >= fourHour + private let currentVersion: AppVersionInfoResponse + + public init(currentVersion: AppVersionInfoResponse) { + self.currentVersion = currentVersion } - private func fetchAndUpdateVersion() -> Single { - return versionCheckRepository.fetchRequiredVersion() - .do(onSuccess: { [weak self] result in - guard let self else { return } - saveForceVersionInfo(result) - }) - .map { [weak self] result in - guard let self else { return nil } - return handleFetchedResult(result) + public func checkForceUpdateNeeded() async throws -> ForceUpdate { + if let cachedInfo = versionCheckRepository.getCachedVersionCheckInfo() { + if cachedInfo.requiredVersion > currentVersion { + return .needed(appStoreURL: try versionCheckRepository.getAppStoreURL()) + } + if cachedInfo.updatedAt.distance(to: .now) < .hour(4) { + return .notNeeded } - } - - /// 서버 통신의 결과 상태를 기반으로 app store Link 반환 - private func handleFetchedResult( - _ result: Result - ) -> String? { - switch result { - case .success(let version): - return getStoreLink(version) - case .failure: - return nil } - } - - /// 결과값에 따라 ForceUpdate 타입의 info들을 UserDefaults에 저장 - private func saveForceVersionInfo( - _ result: Result - ) { - switch result { - case .success(let version): - let forceUpdate = ForceUpdate( - version: version, - date: Date() - ) - versionCheckRepository.saveForceUpdateInfo(forceUpdate) - case .failure(let error): - print(error) + let fetchedRequiredVersion = try await versionCheckRepository.fetchRequiredVersion() + versionCheckRepository.saveVersionCheckInfoCache( + VersionCheckInfo(requiredVersion: fetchedRequiredVersion, updatedAt: .now) + ) + if fetchedRequiredVersion > currentVersion { + return .needed(appStoreURL: try versionCheckRepository.getAppStoreURL()) } - } - - /// Get app store url after comparing version - private func getStoreLink(_ required: AppVersionInfoResponse) -> String? { - return forceUpdateService.compareVersion( - user: versionCheckRepository.getUserAppVersion(), - required: required - ) ? versionCheckRepository.getStoreLink() : nil + return .notNeeded } } diff --git a/Projects/Domain/Sources/UseCase/Protocol/AppVersionCheckUseCase.swift b/Projects/Domain/Sources/UseCase/Protocol/AppVersionCheckUseCase.swift new file mode 100644 index 00000000..801a2fbb --- /dev/null +++ b/Projects/Domain/Sources/UseCase/Protocol/AppVersionCheckUseCase.swift @@ -0,0 +1,13 @@ +// +// AppVersionCheckUseCase.swift +// Domain +// +// Created by Jisoo Ham on 3/25/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import Foundation + +public protocol AppVersionCheckUseCase { + func checkForceUpdateNeeded() async throws -> ForceUpdate +} diff --git a/Projects/Domain/Sources/UseCase/Protocol/VersionCheckUseCase.swift b/Projects/Domain/Sources/UseCase/Protocol/VersionCheckUseCase.swift deleted file mode 100644 index a78afacb..00000000 --- a/Projects/Domain/Sources/UseCase/Protocol/VersionCheckUseCase.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// VersionCheckUseCase.swift -// Domain -// -// Created by Jisoo Ham on 3/25/25. -// Copyright © 2025 Pepsi-Club. All rights reserved. -// - -import Foundation - -import RxSwift - -public protocol VersionCheckUseCase { - func fetchAppStoreURL() -> Single -} diff --git a/Projects/Feature/AlarmFeature/Project.swift b/Projects/Feature/AlarmFeature/Project.swift index f022bc2b..5e825c74 100644 --- a/Projects/Feature/AlarmFeature/Project.swift +++ b/Projects/Feature/AlarmFeature/Project.swift @@ -14,7 +14,11 @@ let project = Project( SampleApp(name: "AlarmFeature") { Feature(name: "AlarmFeature") UIKitInfoPlist() - AppInfoPlist(displayName: "AlarmFeatureSampleApp", marketingVersion: .marketingVersion, buildVersion: .buildVersion) + AppInfoPlist( + displayName: "AlarmFeatureSampleApp", + marketingVersion: .marketingVersion, + buildVersion: .buildVersion + ) SecretInfoPlist() } SampleAppScheme(name: "AlarmFeature") diff --git a/Projects/Feature/BusStopFeature/Project.swift b/Projects/Feature/BusStopFeature/Project.swift index d74e0340..05731516 100644 --- a/Projects/Feature/BusStopFeature/Project.swift +++ b/Projects/Feature/BusStopFeature/Project.swift @@ -14,7 +14,11 @@ let project = Project( SampleApp(name: "BusStopFeature") { Feature(name: "BusStopFeature") UIKitInfoPlist() - AppInfoPlist(displayName: "BusStopFeatureSampleApp", marketingVersion: .marketingVersion, buildVersion: .buildVersion) + AppInfoPlist( + displayName: "BusStopFeatureSampleApp", + marketingVersion: .marketingVersion, + buildVersion: .buildVersion + ) SecretInfoPlist() } SampleAppScheme(name: "BusStopFeature") diff --git a/Projects/Feature/HomeFeature/Project.swift b/Projects/Feature/HomeFeature/Project.swift index 12a07337..fa28c993 100644 --- a/Projects/Feature/HomeFeature/Project.swift +++ b/Projects/Feature/HomeFeature/Project.swift @@ -14,7 +14,11 @@ let project = Project( SampleApp(name: "HomeFeature") { Feature(name: "HomeFeature") UIKitInfoPlist() - AppInfoPlist(displayName: "HomeFeatureSampleApp", marketingVersion: .marketingVersion, buildVersion: .buildVersion) + AppInfoPlist( + displayName: "HomeFeatureSampleApp", + marketingVersion: .marketingVersion, + buildVersion: .buildVersion + ) SecretInfoPlist() } SampleAppScheme(name: "HomeFeature") diff --git a/Projects/Feature/HomeFeature/Sources/ViewModel/FavoritesViewModel.swift b/Projects/Feature/HomeFeature/Sources/ViewModel/FavoritesViewModel.swift index ebe00f43..241a8115 100644 --- a/Projects/Feature/HomeFeature/Sources/ViewModel/FavoritesViewModel.swift +++ b/Projects/Feature/HomeFeature/Sources/ViewModel/FavoritesViewModel.swift @@ -168,7 +168,7 @@ public final class FavoritesViewModel: ViewModel { (timerValue, responses) } .map { tuple in - let (timerValue, responses) = tuple + let (_, responses) = tuple return responses.map { return $0.replaceTime() } diff --git a/Projects/Feature/NearMapFeature/Project.swift b/Projects/Feature/NearMapFeature/Project.swift index f665aac7..986a04dc 100644 --- a/Projects/Feature/NearMapFeature/Project.swift +++ b/Projects/Feature/NearMapFeature/Project.swift @@ -14,7 +14,11 @@ let project = Project( SampleApp(name: "NearMapFeature") { Feature(name: "NearMapFeature") UIKitInfoPlist() - AppInfoPlist(displayName: "NearMapFeatureSampleApp", marketingVersion: .marketingVersion, buildVersion: .buildVersion) + AppInfoPlist( + displayName: "NearMapFeatureSampleApp", + marketingVersion: .marketingVersion, + buildVersion: .buildVersion + ) SecretInfoPlist() } SampleAppScheme(name: "NearMapFeature") diff --git a/Projects/Feature/NearMapFeature/Sources/ViewController/NearMapViewController.swift b/Projects/Feature/NearMapFeature/Sources/ViewController/NearMapViewController.swift index c39ecc6a..6ef56506 100644 --- a/Projects/Feature/NearMapFeature/Sources/ViewController/NearMapViewController.swift +++ b/Projects/Feature/NearMapFeature/Sources/ViewController/NearMapViewController.swift @@ -181,7 +181,7 @@ public final class NearMapViewController: UIViewController { .withUnretained(self) .subscribe( onNext: { vc, tuple in - var (response, distance) = tuple + let (response, distance) = tuple vc.busStopInformationView.updateUI( response: response, distance: distance diff --git a/Projects/Feature/SearchFeature/Project.swift b/Projects/Feature/SearchFeature/Project.swift index afa50e6f..3cbaa1df 100644 --- a/Projects/Feature/SearchFeature/Project.swift +++ b/Projects/Feature/SearchFeature/Project.swift @@ -14,7 +14,11 @@ let project = Project( SampleApp(name: "SearchFeature") { Feature(name: "SearchFeature") UIKitInfoPlist() - AppInfoPlist(displayName: "SearchFeatureSampleApp", marketingVersion: .marketingVersion, buildVersion: .buildVersion) + AppInfoPlist( + displayName: "SearchFeatureSampleApp", + marketingVersion: .marketingVersion, + buildVersion: .buildVersion + ) SecretInfoPlist() } SampleAppScheme(name: "SearchFeature") diff --git a/Projects/Feature/SettingsFeature/Project.swift b/Projects/Feature/SettingsFeature/Project.swift index 63413b5b..dcb90059 100644 --- a/Projects/Feature/SettingsFeature/Project.swift +++ b/Projects/Feature/SettingsFeature/Project.swift @@ -14,7 +14,11 @@ let project = Project( SampleApp(name: "SettingsFeature") { Feature(name: "SettingsFeature") UIKitInfoPlist() - AppInfoPlist(displayName: "SettingsFeatureSampleApp", marketingVersion: .marketingVersion, buildVersion: .buildVersion) + AppInfoPlist( + displayName: "SettingsFeatureSampleApp", + marketingVersion: .marketingVersion, + buildVersion: .buildVersion + ) SecretInfoPlist() } SampleAppScheme(name: "SettingsFeature") diff --git a/Projects/Feature/SettingsFeature/Sources/View/SettingButtonView.swift b/Projects/Feature/SettingsFeature/Sources/View/SettingButtonView.swift index eb026d86..04664f2f 100644 --- a/Projects/Feature/SettingsFeature/Sources/View/SettingButtonView.swift +++ b/Projects/Feature/SettingsFeature/Sources/View/SettingButtonView.swift @@ -9,7 +9,6 @@ import UIKit class SettingButtonView: UIView { - public let basicAlarmSetting: SettingButton = { let view = SettingButton( iconName: "alarm", @@ -19,16 +18,18 @@ class SettingButtonView: UIView { ) return view }() - public lazy var developVersion: SettingButton = { + + public let developVersion: SettingButton = { let view = SettingButton( iconName: "exclamationmark.circle", title: "프로그램 정보", - rightTitle: "v \(String.getCurrentVersion())", + rightTitle: "v \(String.currentVersion)", isHiddenArrowRight: true ) return view }() - public lazy var termsPrivacyBtn: SettingButton = { + + public let termsPrivacyBtn: SettingButton = { let view = SettingButton( iconName: "lock.shield", title: "서비스 이용약관", @@ -37,7 +38,8 @@ class SettingButtonView: UIView { ) return view }() - public lazy var locationPrivacyBtn: SettingButton = { + + public let locationPrivacyBtn: SettingButton = { let btn = SettingButton( iconName: "location.circle", title: "개인정보처리방침", @@ -46,7 +48,8 @@ class SettingButtonView: UIView { ) return btn }() - public lazy var inquryBtn: SettingButton = { + + public let inquryBtn: SettingButton = { let btn = SettingButton( iconName: "questionmark.circle", title: "문의하기", diff --git a/Projects/Feature/SettingsFeature/Sources/ViewModel/SettingsViewModel.swift b/Projects/Feature/SettingsFeature/Sources/ViewModel/SettingsViewModel.swift index c6c42ddd..3e5929c0 100644 --- a/Projects/Feature/SettingsFeature/Sources/ViewModel/SettingsViewModel.swift +++ b/Projects/Feature/SettingsFeature/Sources/ViewModel/SettingsViewModel.swift @@ -1,5 +1,6 @@ import Foundation +import Core import Domain import FeatureDependency @@ -31,24 +32,22 @@ public final class SettingsViewModel input.termsTapEvent .withUnretained(self) .subscribe(onNext: { viewModel, _ in - guard let termsPrivacyURL - = Bundle.main.object( - forInfoDictionaryKey: "TERMS_OF_PRIVACY_URL" - ) as? String - else { return } - viewModel.coordinator.presentPrivacy(url: termsPrivacyURL) + @InfoPlistWrapper(key: "TERMS_OF_PRIVACY_URL") + var termsPrivacyURL: String? + if let termsPrivacyURL { + viewModel.coordinator.presentPrivacy(url: termsPrivacyURL) + } }) .disposed(by: disposeBag) input.locationTapEvent .withUnretained(self) .subscribe(onNext: { viewModel, _ in - guard let locationURL = Bundle.main.object( - forInfoDictionaryKey: "LOCATION_PRIVACY_URL" - ) as? String - else { return } - viewModel.coordinator.presentPrivacy(url: locationURL) - + @InfoPlistWrapper(key: "LOCATION_PRIVACY_URL") + var locationURL: String? + if let locationURL { + viewModel.coordinator.presentPrivacy(url: locationURL) + } }) .disposed(by: disposeBag) @@ -68,7 +67,7 @@ public final class SettingsViewModel Device Model : \(String.getDeviceIdentifier()) Device OS : \(UIDevice.current.systemVersion) - App Version : \(String.getCurrentVersion()) + App Version : \(String.currentVersion) ------------ """ @@ -79,10 +78,8 @@ public final class SettingsViewModel viewModel.coordinator.presentMail(vc: mailViewController) } else { - guard let inquryURL = Bundle.main.object( - forInfoDictionaryKey: "INQURY_URL" - ) as? String - else { return } + @InfoPlistWrapper(key: "INQURY_URL", defaultValue: "") + var inquryURL: String viewModel.coordinator.presentPrivacy(url: inquryURL) } }) diff --git a/Projects/FeatureDependency/Project.swift b/Projects/FeatureDependency/Project.swift index fd2eb611..1ffe1dcb 100644 --- a/Projects/FeatureDependency/Project.swift +++ b/Projects/FeatureDependency/Project.swift @@ -2,7 +2,7 @@ import ProjectDescription import ProjectDescriptionHelpers let project = Project(name: "FeatureDependency") { - FeatureDependency() { + FeatureDependency { DesignSystem() Domain() FrameworkInfoPlist(marketingVersion: .marketingVersion) diff --git a/Projects/FeatureDependency/Sources/Coordinator/Coordinator.swift b/Projects/FeatureDependency/Sources/Coordinator/Coordinator.swift index b26e0573..cb2fd62c 100644 --- a/Projects/FeatureDependency/Sources/Coordinator/Coordinator.swift +++ b/Projects/FeatureDependency/Sources/Coordinator/Coordinator.swift @@ -18,6 +18,7 @@ public protocol Coordinator: AnyObject { func start() func finish() + func openURL(_ url: URL) } public extension Coordinator { @@ -49,4 +50,8 @@ public extension Coordinator { (currentCoordinator as? AddRegularAlarmCoordinator)? .removeChildViewController() } + + func openURL(_ url: URL) { + parent?.openURL(url) + } } diff --git a/Projects/FeatureDependency/Sources/Coordinator/CoordinatorType.swift b/Projects/FeatureDependency/Sources/Coordinator/CoordinatorType.swift index d2b1a7d1..38097bea 100644 --- a/Projects/FeatureDependency/Sources/Coordinator/CoordinatorType.swift +++ b/Projects/FeatureDependency/Sources/Coordinator/CoordinatorType.swift @@ -10,6 +10,7 @@ import UIKit public enum CoordinatorType { case app + case splash case tab case addAlarm case home diff --git a/Projects/FeatureDependency/Sources/Mock/MockBusStopArrivalInfoRepository.swift b/Projects/FeatureDependency/Sources/Mock/MockBusStopArrivalInfoRepository.swift index 3e3cfc58..70d16ff8 100644 --- a/Projects/FeatureDependency/Sources/Mock/MockBusStopArrivalInfoRepository.swift +++ b/Projects/FeatureDependency/Sources/Mock/MockBusStopArrivalInfoRepository.swift @@ -15,6 +15,192 @@ import RxSwift #if DEBUG public final class MockBusStopArrivalInfoRepository : BusStopArrivalInfoRepository { + public func fetchArrivalList(busStopId: String) async throws -> Domain.BusStopArrivalInfoResponse { + BusStopArrivalInfoResponse( + busStopId: "23290", + busStopName: "강남구보건소", + direction: "강남구청역", + buses: [ + BusArrivalInfoResponse( + busId: "124000038", + busName: "342", + busType: BusType.trunkLine.rawValue, + nextStation: "강남구청역", + firstArrivalState: ArrivalState + .arrivalTime(time: 62), + firstArrivalRemaining: "3번째 전", + secondArrivalState: ArrivalState + .arrivalTime(time: 913), + secondArrivalRemaining: "6번째 전", + adirection: "", + isFavorites: false, + isAlarmOn: false + ), + BusArrivalInfoResponse( + busId: "100100075", + busName: "472", + busType: BusType.trunkLine.rawValue, + nextStation: "강남구청역", + firstArrivalState: ArrivalState + .arrivalTime(time: 198), + firstArrivalRemaining: "1번째 전", + secondArrivalState: ArrivalState + .arrivalTime(time: 566), + secondArrivalRemaining: "5번째 전", + adirection: "", + isFavorites: false, + isAlarmOn: false + ), + BusArrivalInfoResponse( + busId: "100100226", + busName: "3414", + busType: BusType.branchLine.rawValue, + nextStation: "삼성동서광아파트", + firstArrivalState: ArrivalState.soon, + firstArrivalRemaining: "", + secondArrivalState: ArrivalState + .arrivalTime(time: 1086), + secondArrivalRemaining: "9번째 전", + adirection: "", + isFavorites: false, + isAlarmOn: false + ), + BusArrivalInfoResponse( + busId: "100100612", + busName: "3426", + busType: BusType.branchLine.rawValue, + nextStation: "삼성동서광아파트", + firstArrivalState: ArrivalState.soon, + firstArrivalRemaining: "", + secondArrivalState: ArrivalState + .arrivalTime(time: 689), + secondArrivalRemaining: "6번째 전", + adirection: "", + isFavorites: false, + isAlarmOn: false + ), + BusArrivalInfoResponse( + busId: "100100500", + busName: "4312", + busType: BusType.branchLine.rawValue, + nextStation: "강남구청역", + firstArrivalState: ArrivalState + .arrivalTime(time: 490), + firstArrivalRemaining: "4번째 전", + secondArrivalState: ArrivalState + .arrivalTime(time: 916), + secondArrivalRemaining: "9번째 전", + adirection: "", + isFavorites: false, + isAlarmOn: false + ), + BusArrivalInfoResponse( + busId: "100100226", + busName: "3414", + busType: BusType.airport.rawValue, + nextStation: "삼성동서광아파트", + firstArrivalState: ArrivalState.soon, + firstArrivalRemaining: "", + secondArrivalState: ArrivalState + .arrivalTime(time: 1086), + secondArrivalRemaining: "9번째 전", + adirection: "", + isFavorites: false, + isAlarmOn: false + ), + BusArrivalInfoResponse( + busId: "100100612", + busName: "3426", + busType: BusType.airport.rawValue, + nextStation: "삼성동서광아파트", + firstArrivalState: ArrivalState.soon, + firstArrivalRemaining: "", + secondArrivalState: ArrivalState + .arrivalTime(time: 689), + secondArrivalRemaining: "6번째 전", + adirection: "", + isFavorites: false, + isAlarmOn: false + ), + BusArrivalInfoResponse( + busId: "100100500", + busName: "4312", + busType: BusType.airport.rawValue, + nextStation: "강남구청역", + firstArrivalState: ArrivalState + .arrivalTime(time: 490), + firstArrivalRemaining: "4번째 전", + secondArrivalState: ArrivalState + .arrivalTime(time: 916), + secondArrivalRemaining: "9번째 전", + adirection: "", + isFavorites: false, + isAlarmOn: false + ), + BusArrivalInfoResponse( + busId: "1001005001", + busName: "4312", + busType: BusType.branchLine.rawValue, + nextStation: "강남구청역", + firstArrivalState: ArrivalState + .arrivalTime(time: 490), + firstArrivalRemaining: "4번째 전", + secondArrivalState: ArrivalState + .arrivalTime(time: 916), + secondArrivalRemaining: "9번째 전", + adirection: "", + isFavorites: false, + isAlarmOn: false + ), + BusArrivalInfoResponse( + busId: "1001005002", + busName: "4312", + busType: BusType.branchLine.rawValue, + nextStation: "강남구청역", + firstArrivalState: ArrivalState + .arrivalTime(time: 490), + firstArrivalRemaining: "4번째 전", + secondArrivalState: ArrivalState + .arrivalTime(time: 916), + secondArrivalRemaining: "9번째 전", + adirection: "", + isFavorites: false, + isAlarmOn: false + ), + BusArrivalInfoResponse( + busId: "1001005003", + busName: "4312", + busType: BusType.branchLine.rawValue, + nextStation: "강남구청역", + firstArrivalState: ArrivalState + .arrivalTime(time: 490), + firstArrivalRemaining: "4번째 전", + secondArrivalState: ArrivalState + .arrivalTime(time: 916), + secondArrivalRemaining: "9번째 전", + adirection: "", + isFavorites: false, + isAlarmOn: false + ), + BusArrivalInfoResponse( + busId: "1001005004", + busName: "4312", + busType: BusType.branchLine.rawValue, + nextStation: "강남구청역", + firstArrivalState: ArrivalState + .arrivalTime(time: 490), + firstArrivalRemaining: "4번째 전", + secondArrivalState: ArrivalState + .arrivalTime(time: 916), + secondArrivalRemaining: "9번째 전", + adirection: "", + isFavorites: false, + isAlarmOn: false + ) + ] + ) + } + public init() { } public func fetchArrivalList( diff --git a/Projects/FirebaseModule/Sources/FirebaseLoggerImpl.swift b/Projects/FirebaseModule/Sources/FirebaseLoggerImpl.swift index cf924c13..d4b8b79f 100644 --- a/Projects/FirebaseModule/Sources/FirebaseLoggerImpl.swift +++ b/Projects/FirebaseModule/Sources/FirebaseLoggerImpl.swift @@ -15,7 +15,7 @@ public final class FirebaseLoggerImpl: FirebaseLogger { Analytics.logEvent(name, parameters: nil) } - public func log(name: String, parameter: [String : String]) { + public func log(name: String, parameter: [String: String]) { Analytics.logEvent(name, parameters: parameter) } } diff --git a/Projects/MainFeature/Sources/TabBarCoordinator.swift b/Projects/MainFeature/Sources/TabBarCoordinator.swift index 812d3f82..497983bc 100644 --- a/Projects/MainFeature/Sources/TabBarCoordinator.swift +++ b/Projects/MainFeature/Sources/TabBarCoordinator.swift @@ -35,14 +35,16 @@ public final class TabBarCoordinator: Coordinator { } private func setupTabBarController() { - let tabBarController = TabBarViewController() - navigationController.setViewControllers( - [tabBarController], animated: true - ) - let viewControllers = MainTab.allCases.map { - makeNavigationController(tabKind: $0) + Task { @MainActor in + let tabBarController = TabBarViewController() + navigationController.setViewControllers( + [tabBarController], animated: true + ) + let viewControllers = MainTab.allCases.map { + makeNavigationController(tabKind: $0) + } + tabBarController.viewControllers = viewControllers } - tabBarController.viewControllers = viewControllers } private func makeNavigationController( diff --git a/Projects/NetworkService/Sources/EndPoint/EndPoint.swift b/Projects/NetworkService/Sources/EndPoint/EndPoint.swift index 1f7d1a39..f29ee383 100644 --- a/Projects/NetworkService/Sources/EndPoint/EndPoint.swift +++ b/Projects/NetworkService/Sources/EndPoint/EndPoint.swift @@ -62,20 +62,22 @@ extension EndPoint { return urlRequest } - public var toURLString: String? { - var urlComponent = URLComponents() - urlComponent.scheme = scheme.toString - urlComponent.host = host - urlComponent.port = Int(port) - urlComponent.path = path - if !query.isEmpty { - urlComponent.queryItems = query.map { - .init(name: $0.key, value: $0.value) + public var toURL: URL { + get throws { + var urlComponent = URLComponents() + urlComponent.scheme = scheme.toString + urlComponent.host = host + urlComponent.port = Int(port) + urlComponent.path = path + if !query.isEmpty { + urlComponent.queryItems = query.map { + .init(name: $0.key, value: $0.value) + } + } + guard let url = urlComponent.url else { + throw URLError(.badURL) } + return url } - let urlStr = urlComponent.url?.absoluteString - .replacingOccurrences(of: "%25", with: "%") - - return urlStr } } diff --git a/Projects/NetworkService/Sources/NetworkService/DefaultNetworkService.swift b/Projects/NetworkService/Sources/NetworkService/DefaultNetworkService.swift index 1c7ab385..f5d009a4 100644 --- a/Projects/NetworkService/Sources/NetworkService/DefaultNetworkService.swift +++ b/Projects/NetworkService/Sources/NetworkService/DefaultNetworkService.swift @@ -13,6 +13,18 @@ import RxSwift public final class DefaultNetworkService: NetworkService { public init() { } + public func request(endPoint: any EndPoint) async throws -> Data { + let urlRequest = try endPoint.toURLRequest() + let (data, response) = try await URLSession.shared.data(for: urlRequest) + guard let httpURLResponse = response as? HTTPURLResponse else { + throw NetworkError.invalidResponse + } + guard 200..<300 ~= httpURLResponse.statusCode else { + throw NetworkError.invalidStatusCode(httpURLResponse.statusCode) + } + return data + } + public func request(endPoint: EndPoint) -> Observable { Observable.create { observer in do { diff --git a/Projects/NetworkService/Sources/NetworkService/NetworkService.swift b/Projects/NetworkService/Sources/NetworkService/NetworkService.swift index 98e4ce0a..570c2a77 100644 --- a/Projects/NetworkService/Sources/NetworkService/NetworkService.swift +++ b/Projects/NetworkService/Sources/NetworkService/NetworkService.swift @@ -11,7 +11,21 @@ import Foundation import RxSwift public protocol NetworkService { + func request(endPoint: EndPoint) async throws -> Data + + @available( + *, + deprecated, + renamed: "request(endPoint:)", + message: "이 메서드는 제거될 예정입니다. async 함수 request(endPoint:)를 사용하세요." + ) func request(endPoint: EndPoint) -> Observable + @available( + *, + deprecated, + renamed: "request(endPoint:)", + message: "이 메서드는 제거될 예정입니다. async 함수 request(endPoint:)를 사용하세요." + ) func request( endPoint: EndPoint, responseType: T.Type diff --git a/Projects/ThirdPartyLibs/Project.swift b/Projects/ThirdPartyLibs/Project.swift index 8a5bc6b4..d1672c9b 100644 --- a/Projects/ThirdPartyLibs/Project.swift +++ b/Projects/ThirdPartyLibs/Project.swift @@ -2,7 +2,7 @@ import ProjectDescription import ProjectDescriptionHelpers let project = Project(name: "ThirdPartyLibs") { - ThirdPartyLibs() { + ThirdPartyLibs { // TODO: NaverMap을 사용하지 않는 모듈에서 의존성하지 않도록 NMapsGeometry() NMapsMap()