From 5b43749170e0ab744999ec341565620846ff6e10 Mon Sep 17 00:00:00 2001 From: gnksbm Date: Tue, 24 Jun 2025 22:55:03 +0900 Subject: [PATCH 01/18] =?UTF-8?q?[Refact]=20NetworkService=20async=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NetworkService/DefaultNetworkService.swift | 12 ++++++++++++ .../Sources/NetworkService/NetworkService.swift | 2 ++ 2 files changed, 14 insertions(+) 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..a5e992f0 100644 --- a/Projects/NetworkService/Sources/NetworkService/NetworkService.swift +++ b/Projects/NetworkService/Sources/NetworkService/NetworkService.swift @@ -11,6 +11,8 @@ import Foundation import RxSwift public protocol NetworkService { + func request(endPoint: EndPoint) async throws -> Data + func request(endPoint: EndPoint) -> Observable func request( endPoint: EndPoint, From 186cc8c070a55f525e9e30f61ca80f9ef9c2e67c Mon Sep 17 00:00:00 2001 From: gnksbm Date: Tue, 24 Jun 2025 22:56:19 +0900 Subject: [PATCH 02/18] =?UTF-8?q?[Refact]=20CoreData=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20Swift=20Concurrency=20=EA=B0=9D=EC=B2=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/CoreDataContainerFactory.swift | 54 +++++++++ .../Sources/CoreDataModel.swift | 16 +++ .../Sources/CoreDataStorage.swift | 21 ++++ .../Sources/CoreDataStorageImpl.swift | 109 ++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 Projects/CoreDataService/Sources/CoreDataContainerFactory.swift create mode 100644 Projects/CoreDataService/Sources/CoreDataModel.swift create mode 100644 Projects/CoreDataService/Sources/CoreDataStorage.swift create mode 100644 Projects/CoreDataService/Sources/CoreDataStorageImpl.swift diff --git a/Projects/CoreDataService/Sources/CoreDataContainerFactory.swift b/Projects/CoreDataService/Sources/CoreDataContainerFactory.swift new file mode 100644 index 00000000..0b9e6a2e --- /dev/null +++ b/Projects/CoreDataService/Sources/CoreDataContainerFactory.swift @@ -0,0 +1,54 @@ +// +// CoreDataContainerFactory.swift +// CoreDataService +// +// Created by Logan on 6/21/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import CoreData +import CloudKit + +public final class CoreDataContainerFactory { + private enum Constants { + static let fileName: String = "Model" + static let appGroupName: String = "group.Pepsi-Club.WhereMyBus" + static let containerIdentifier: String = "iCloud.Pepsi-Club.WhereMyBus" + } + + // 에러가 방출될 때 처리 방식을 고민해야 한다. + // 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 { _, error 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/CoreDataModel.swift b/Projects/CoreDataService/Sources/CoreDataModel.swift new file mode 100644 index 00000000..95c12a61 --- /dev/null +++ b/Projects/CoreDataService/Sources/CoreDataModel.swift @@ -0,0 +1,16 @@ +// +// CoreDataModel.swift +// CoreDataService +// +// Created by Logan on 6/21/25. +// Copyright © 2025 Pepsi-Club. All rights reserved. +// + +import CoreData + +public protocol CoreDataModel: Identifiable where ID: CVarArg { + associatedtype ManagedObject: NSManagedObject + static func toDataModel(_ object: ManagedObject) -> Self + + func sync(for managedObject: ManagedObject) +} diff --git a/Projects/CoreDataService/Sources/CoreDataStorage.swift b/Projects/CoreDataService/Sources/CoreDataStorage.swift new file mode 100644 index 00000000..45e0b94a --- /dev/null +++ b/Projects/CoreDataService/Sources/CoreDataStorage.swift @@ -0,0 +1,21 @@ +// +// 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, by sortOrder: CoreDataSortOrder) async throws -> [T] + func update(data: T) async throws + func delete(data: T) async throws +} + +public enum CoreDataSortOrder { + case idAscending + case idDescending + case dateAscending + case dateDescending +} diff --git a/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift b/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift new file mode 100644 index 00000000..bed8ca1d --- /dev/null +++ b/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift @@ -0,0 +1,109 @@ +// +// 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.sync(for: coreDataManagedObject) + } + try await saveContext() + } + + public func read(type: T.Type, by sortOrder: CoreDataSortOrder) 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 + let sortDescriptor = switch sortOrder { + case .idAscending: + NSSortDescriptor(key: "id", ascending: false) + case .idDescending: + NSSortDescriptor(key: "id", ascending: true) + case .dateAscending: + NSSortDescriptor(key: "date", ascending: false) + case .dateDescending: + NSSortDescriptor(key: "date", ascending: true) + } + request.sortDescriptors = [sortDescriptor] + + return try context.fetch(request) + } + return managedObjects.map { T.toDataModel($0) } + } + + public func update(data: T) async throws { + let managedObject = try await readManagedObject(for: data) + await context.perform { + data.sync(for: 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() + } +} From 1cee3cd5e286e517af22b7a5377097f5de95367c Mon Sep 17 00:00:00 2001 From: gnksbm Date: Tue, 24 Jun 2025 22:56:52 +0900 Subject: [PATCH 03/18] =?UTF-8?q?[Fix]=20Swift=20Concurrency=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EC=A4=91(=EC=9E=84=EC=8B=9C=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/DTO/BusStopArrivalInfoDTO.swift | 18 ++ ...FavoritesBusResponseMO+CoreDataClass.swift | 28 +++ .../DefaultBusStopArrivalInfoRepository.swift | 6 + .../Response/FavoritesBusResponse.swift | 2 + .../BusStopArrivalInfoRepository.swift | 2 + .../MockBusStopArrivalInfoRepository.swift | 186 ++++++++++++++++++ 6 files changed, 242 insertions(+) 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/FavoritesBusResponseMO+CoreDataClass.swift b/Projects/Data/Sources/DTO/CoreDataModelObject/FavoritesBusResponseMO+CoreDataClass.swift index c0ca5862..7a066898 100644 --- a/Projects/Data/Sources/DTO/CoreDataModelObject/FavoritesBusResponseMO+CoreDataClass.swift +++ b/Projects/Data/Sources/DTO/CoreDataModelObject/FavoritesBusResponseMO+CoreDataClass.swift @@ -11,6 +11,7 @@ import CoreData import Core import Domain +import CoreDataService @objc(FavoritesBusResponseMO) public class FavoritesBusResponseMO: NSManagedObject, CoreDataModelObject { @@ -30,3 +31,30 @@ public class FavoritesBusResponseMO: NSManagedObject, CoreDataModelObject { ) } } + +extension FavoritesBusResponse { + static func toDataModel(_ object: FavoritesBusResponseMO) -> FavoritesBusResponse { + guard let busStopId = object.busStopId, + let busStopName = object.busStopName, + let busId = object.busId, + let busName = object.busName, + let adirection = object.adirection + else { fatalError() } + return FavoritesBusResponse( + busStopId: busStopId, + busStopName: busStopName, + busId: busId, + busName: busName, + adirection: adirection + ) + } + + func sync(for 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 6da0b77b..23537552 100644 --- a/Projects/Data/Sources/Repository/DefaultBusStopArrivalInfoRepository.swift +++ b/Projects/Data/Sources/Repository/DefaultBusStopArrivalInfoRepository.swift @@ -24,6 +24,12 @@ public final class DefaultBusStopArrivalInfoRepository: self.networkService = networkService } + 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 { Analytics.logEvent("fetchArrivalEvent", parameters: nil) 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/RepositoryInterface/BusStopArrivalInfoRepository.swift b/Projects/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift index c1c9704f..3b318e48 100644 --- a/Projects/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift +++ b/Projects/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift @@ -11,6 +11,8 @@ import Foundation import RxSwift public protocol BusStopArrivalInfoRepository { + func fetchArrivalList(busStopId: String) async throws -> BusStopArrivalInfoResponse + func fetchArrivalList( busStopId: String ) -> Observable 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( From 5fd12fa2a2b794417300d6c14be47a02f5a3b97f Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Thu, 26 Jun 2025 22:40:46 +0900 Subject: [PATCH 04/18] =?UTF-8?q?fix:=20LegacyCoreDataService=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CoreDataService/Sources/{ => Legacy}/CoreDataDirectory.swift | 0 .../CoreDataService/Sources/{ => Legacy}/CoreDataService.swift | 0 .../Sources/{ => Legacy}/DefaultCoreDataService.swift | 0 Projects/CoreDataService/Sources/{ => Legacy}/StoreStatus.swift | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename Projects/CoreDataService/Sources/{ => Legacy}/CoreDataDirectory.swift (100%) rename Projects/CoreDataService/Sources/{ => Legacy}/CoreDataService.swift (100%) rename Projects/CoreDataService/Sources/{ => Legacy}/DefaultCoreDataService.swift (100%) rename Projects/CoreDataService/Sources/{ => Legacy}/StoreStatus.swift (100%) 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 100% rename from Projects/CoreDataService/Sources/CoreDataService.swift rename to Projects/CoreDataService/Sources/Legacy/CoreDataService.swift 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 From b6d111b032174494ca54a8f91d29cb2a4b87d68d Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Thu, 26 Jun 2025 22:40:58 +0900 Subject: [PATCH 05/18] =?UTF-8?q?fix:=20fetchRequiredVersion=20async=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Repository/DefaultVersionCheckRepository.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Projects/Data/Sources/Repository/DefaultVersionCheckRepository.swift b/Projects/Data/Sources/Repository/DefaultVersionCheckRepository.swift index 3575999b..87d8f6ce 100644 --- a/Projects/Data/Sources/Repository/DefaultVersionCheckRepository.swift +++ b/Projects/Data/Sources/Repository/DefaultVersionCheckRepository.swift @@ -31,6 +31,12 @@ public final class DefaultVersionCheckRepository: VersionCheckRepository { } /// 서버로 부터 받은 App의 최소 지원 버전 + public func fetchRequiredVersion() async throws -> AppVersionInfoResponse { + try await networkService.request(endPoint: MinVersionEndpoint(domain: getDomainURL())) + .decode(type: RequiredVersionDTO.self) + .toDomain + } + public func fetchRequiredVersion() -> Single> { return networkService.request( From 58133f92a56bc9adbffc7e0d661970be97b98c6f Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Mon, 30 Jun 2025 00:07:08 +0900 Subject: [PATCH 06/18] =?UTF-8?q?fix:=20CoreData=20read=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CoreDataService/Sources/CoreDataStorage.swift | 9 +-------- .../Sources/CoreDataStorageImpl.swift | 13 +------------ 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/Projects/CoreDataService/Sources/CoreDataStorage.swift b/Projects/CoreDataService/Sources/CoreDataStorage.swift index 45e0b94a..f6b6024f 100644 --- a/Projects/CoreDataService/Sources/CoreDataStorage.swift +++ b/Projects/CoreDataService/Sources/CoreDataStorage.swift @@ -8,14 +8,7 @@ public protocol CoreDataStorage { func create(data: T) async throws - func read(type: T.Type, by sortOrder: CoreDataSortOrder) async throws -> [T] + func read(type: T.Type) async throws -> [T] func update(data: T) async throws func delete(data: T) async throws } - -public enum CoreDataSortOrder { - case idAscending - case idDescending - case dateAscending - case dateDescending -} diff --git a/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift b/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift index bed8ca1d..797d57ee 100644 --- a/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift +++ b/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift @@ -69,22 +69,11 @@ extension CoreDataStorageImpl: CoreDataStorage { try await saveContext() } - public func read(type: T.Type, by sortOrder: CoreDataSortOrder) async throws -> [T] { + 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 - let sortDescriptor = switch sortOrder { - case .idAscending: - NSSortDescriptor(key: "id", ascending: false) - case .idDescending: - NSSortDescriptor(key: "id", ascending: true) - case .dateAscending: - NSSortDescriptor(key: "date", ascending: false) - case .dateDescending: - NSSortDescriptor(key: "date", ascending: true) - } - request.sortDescriptors = [sortDescriptor] return try context.fetch(request) } From 2e7c06028725f1cf42aee3c9f6c320781dbf747d Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Sun, 6 Jul 2025 01:08:33 +0900 Subject: [PATCH 07/18] =?UTF-8?q?fix:=20Splash=EB=A5=BC=20=ED=86=B5?= =?UTF-8?q?=ED=95=B4=20DI=20=EC=88=98=ED=96=89=20=EB=B0=8F=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=ED=81=AC=ED=95=98=EB=8F=84=EB=A1=9D,=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=EC=B2=B4=ED=81=AC=20UseCase=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .swiftlint.yml | 3 +- .../App/Sources/AppDelegate+Register.swift | 45 ------- Projects/App/Sources/AppDelegate.swift | 1 - .../Sources/Coordinator/AppCoordinator.swift | 34 +++-- .../Splash/SplashCoordinator.swift | 54 ++++++++ .../Splash/SplashViewController.swift | 64 ++++++++++ .../Coordinator/Splash/SplashViewModel.swift | 116 ++++++++++++++++++ Projects/App/Sources/SceneDelegate.swift | 77 +++++------- .../Sources/Extension/TimeInterval+.swift | 15 +++ .../PropertyWrapper/UserDefaultsWrapper.swift | 9 ++ Projects/Core/Sources/TypeBuilder.swift | 16 +++ .../Sources/CoreDataContainerFactory.swift | 1 + .../Sources/CoreDataStorageImpl.swift | 12 ++ ...FavoritesBusResponseMO+CoreDataClass.swift | 8 +- .../DefaultFavoritesRepository.swift | 38 ++++++ .../DefaultVersionCheckRepository.swift | 90 +++----------- Projects/Domain/Sources/Entity/Alert.swift | 33 +++++ .../Domain/Sources/Entity/ForceUpdate.swift | 14 +-- .../Response/AppVersionInfoResponse.swift | 2 + .../Sources/Entity/VersionCheckInfo.swift | 19 +++ .../FavoritesRepository.swift | 17 +++ .../VersionCheckRepository.swift | 13 +- .../UseCase/DefaultVersionCheckUseCase.swift | 84 +++---------- .../Protocol/VersionCheckUseCase.swift | 2 +- .../Sources/Coordinator/Coordinator.swift | 5 + .../Sources/Coordinator/CoordinatorType.swift | 1 + .../Sources/TabBarCoordinator.swift | 16 +-- .../Sources/EndPoint/EndPoint.swift | 28 +++-- 28 files changed, 537 insertions(+), 280 deletions(-) delete mode 100644 Projects/App/Sources/AppDelegate+Register.swift create mode 100644 Projects/App/Sources/Coordinator/Splash/SplashCoordinator.swift create mode 100644 Projects/App/Sources/Coordinator/Splash/SplashViewController.swift create mode 100644 Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift create mode 100644 Projects/Core/Sources/Extension/TimeInterval+.swift create mode 100644 Projects/Core/Sources/TypeBuilder.swift create mode 100644 Projects/Domain/Sources/Entity/Alert.swift create mode 100644 Projects/Domain/Sources/Entity/VersionCheckInfo.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index e274dc8b..5a771647 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -30,12 +30,13 @@ file_length: warning: 1000 error: 2000 line_length: - warning: 90 + warning: 120 error: 400 disabled_rules: # 제외하고 싶은 룰 - trailing_whitespace - type_name # 타입명에 _가 들어가면 경고 - trailing_comma # 배열 마지막 아이템에 ,가 붙으면 경고 - nesting # 중첩타입 + - identifier_name opt_in_rules: - empty_string 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..4495f3ac --- /dev/null +++ b/Projects/App/Sources/Coordinator/Splash/SplashViewController.swift @@ -0,0 +1,64 @@ +// +// 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() + + private let iconImageView: UIImageView = .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 + + iconImageView.translatesAutoresizingMaskIntoConstraints = false + 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..06947814 --- /dev/null +++ b/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift @@ -0,0 +1,116 @@ +// +// 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: VersionCheckUseCase + 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() + + 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) + } + } + return .init(alert: alertRelay.asObservable()) + } + + private func registerDependency() async { + let coreDataContainer = await CoreDataContainerFactory().buildContainer() + let firebaseLogger = FirebaseLoggerImpl() + + DIContainer.setLogger(firebaseLogger) + + DIContainer.register(type: CoreDataStorage.self, CoreDataStorageImpl(container: coreDataContainer)) + 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, + 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: VersionCheckUseCase.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..a96d11c8 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -15,15 +15,41 @@ import Data import RxSwift -final class SceneDelegate: UIResponder, UIWindowSceneDelegate { +final class SceneDelegate: UIResponder, + UIWindowSceneDelegate, + AppCoordinatorDependency { @Injected private var useCase: VersionCheckUseCase var window: UIWindow? var appCoordinator: AppCoordinator? var deeplinkHandler: DeeplinkHandler? - let disposeBag = DisposeBag() + let _sceneWillEnterForeground = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + var sceneWillEnterForeground: AsyncStream { + _sceneWillEnterForeground.stream + } + + var appVersion: AppVersionInfoResponse { + guard let dictionary = Bundle.main.infoDictionary, + let version = dictionary["CFBundleShortVersionString"] as? String + else { return .defaultVersion } + + let splitedVersion = version.split(separator: ".").compactMap { Int($0) } + + return AppVersionInfoResponse( + major: splitedVersion[0], + minor: splitedVersion[1], + patch: splitedVersion[2] + ) + } + var appStoreID: String { + Bundle.main.object(forInfoDictionaryKey: "APPSTORE_ID") as? String ?? "" + } + + var domainURL: String { + Bundle.main.object(forInfoDictionaryKey: "DOMAIN_URL") as? String ?? "" + } func scene( _ scene: UIScene, @@ -39,7 +65,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { window?.rootViewController = navigationController appCoordinator = AppCoordinator( - navigationController: navigationController + navigationController: navigationController, + dependency: self ) appCoordinator?.start() window?.makeKeyAndVisible() @@ -61,7 +88,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { /// 앱이 Foreground로 전환될때 실행될 함수 func sceneWillEnterForeground(_ scene: UIScene) { - checkAndUpdateIfNeeded() + _sceneWillEnterForeground.continuation.yield(scene) } func sceneDidEnterBackground(_ scene: UIScene) { @@ -75,47 +102,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/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/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/CoreDataContainerFactory.swift b/Projects/CoreDataService/Sources/CoreDataContainerFactory.swift index 0b9e6a2e..3fa04a53 100644 --- a/Projects/CoreDataService/Sources/CoreDataContainerFactory.swift +++ b/Projects/CoreDataService/Sources/CoreDataContainerFactory.swift @@ -16,6 +16,7 @@ public final class CoreDataContainerFactory { static let containerIdentifier: String = "iCloud.Pepsi-Club.WhereMyBus" } + public init() { } // 에러가 방출될 때 처리 방식을 고민해야 한다. // 1. appGroupStoreUrl, 2. CKContainer.default().accountStatus(), 3. loadPersistentStores public func buildContainer() async -> NSPersistentContainer { diff --git a/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift b/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift index 797d57ee..e551a79a 100644 --- a/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift +++ b/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift @@ -96,3 +96,15 @@ extension CoreDataStorageImpl: CoreDataStorage { try await saveContext() } } + +//let apiConfig: Data = { +// "busStopList": { +// "version": { // currentVersion? requiredVersion? +// "major": 1, +// "minor": 0, +// "patch": 0 +// "updateAt": // Date 자료형, +// "seoulUpdateAt": // Date 자료형 +// } +// } +//} diff --git a/Projects/Data/Sources/DTO/CoreDataModelObject/FavoritesBusResponseMO+CoreDataClass.swift b/Projects/Data/Sources/DTO/CoreDataModelObject/FavoritesBusResponseMO+CoreDataClass.swift index 7a066898..855eda3a 100644 --- a/Projects/Data/Sources/DTO/CoreDataModelObject/FavoritesBusResponseMO+CoreDataClass.swift +++ b/Projects/Data/Sources/DTO/CoreDataModelObject/FavoritesBusResponseMO+CoreDataClass.swift @@ -32,8 +32,10 @@ public class FavoritesBusResponseMO: NSManagedObject, CoreDataModelObject { } } -extension FavoritesBusResponse { - static func toDataModel(_ object: FavoritesBusResponseMO) -> FavoritesBusResponse { +extension FavoritesBusResponse: CoreDataModel { + public var id: String { identifier } + + public static func toDataModel(_ object: FavoritesBusResponseMO) -> FavoritesBusResponse { guard let busStopId = object.busStopId, let busStopName = object.busStopName, let busId = object.busId, @@ -49,7 +51,7 @@ extension FavoritesBusResponse { ) } - func sync(for managedObject: FavoritesBusResponseMO) { + public func sync(for managedObject: FavoritesBusResponseMO) { managedObject.identifier = identifier managedObject.busStopId = busStopId managedObject.busStopName = busStopName 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 9df5e8fb..21ae9118 100644 --- a/Projects/Data/Sources/Repository/DefaultVersionCheckRepository.swift +++ b/Projects/Data/Sources/Repository/DefaultVersionCheckRepository.swift @@ -12,89 +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 - - public init() { } + @UserDefaultsWrapper(key: "ForceUpdate") + var versionCheckInfo: VersionCheckInfo? - /// 서버로 부터 받은 App의 최소 지원 버전 - public func fetchRequiredVersion() async throws -> AppVersionInfoResponse { - try await networkService.request(endPoint: MinVersionEndpoint(domain: getDomainURL())) - .decode(type: RequiredVersionDTO.self) - .toDomain - } + private let appStoreID: String + private let domainURL: String - 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/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..40cf1fd4 100644 --- a/Projects/Domain/Sources/Entity/Response/AppVersionInfoResponse.swift +++ b/Projects/Domain/Sources/Entity/Response/AppVersionInfoResponse.swift @@ -9,6 +9,8 @@ import Foundation 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 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/FavoritesRepository.swift b/Projects/Domain/Sources/RepositoryInterface/FavoritesRepository.swift index 6504078d..2f410d48 100644 --- a/Projects/Domain/Sources/RepositoryInterface/FavoritesRepository.swift +++ b/Projects/Domain/Sources/RepositoryInterface/FavoritesRepository.swift @@ -17,3 +17,20 @@ public protocol FavoritesRepository { func addFavorites(favorites: FavoritesBusResponse) throws 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/VersionCheckRepository.swift b/Projects/Domain/Sources/RepositoryInterface/VersionCheckRepository.swift index a1f9cc0b..417a6c89 100644 --- a/Projects/Domain/Sources/RepositoryInterface/VersionCheckRepository.swift +++ b/Projects/Domain/Sources/RepositoryInterface/VersionCheckRepository.swift @@ -8,14 +8,9 @@ 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 fetchRequiredVersion() async throws -> AppVersionInfoResponse + func saveVersionCheckInfoCache(_ versionCheckInfo: VersionCheckInfo) + func getAppStoreURL() throws -> URL } diff --git a/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift b/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift index 0f1228c2..0a79e0b0 100644 --- a/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift +++ b/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift @@ -12,76 +12,30 @@ import Core import RxSwift -public final class DefaultVersionCheckUseCase: VersionCheckUseCase { +public final class VersionCheckUseCaseImpl: VersionCheckUseCase { @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/VersionCheckUseCase.swift b/Projects/Domain/Sources/UseCase/Protocol/VersionCheckUseCase.swift index a78afacb..aba4551c 100644 --- a/Projects/Domain/Sources/UseCase/Protocol/VersionCheckUseCase.swift +++ b/Projects/Domain/Sources/UseCase/Protocol/VersionCheckUseCase.swift @@ -11,5 +11,5 @@ import Foundation import RxSwift public protocol VersionCheckUseCase { - func fetchAppStoreURL() -> Single + func checkForceUpdateNeeded() async throws -> ForceUpdate } 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/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 } } From 498bf820f1c8e632894badb9cce3d6208ec494b6 Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Mon, 7 Jul 2025 20:37:56 +0900 Subject: [PATCH 08/18] =?UTF-8?q?fix:=20=EB=B2=84=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4=EB=A7=81=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Coordinator/Splash/SplashViewModel.swift | 36 +++++++++++-------- ...y.swift => CoreDataContainerBuilder.swift} | 4 +-- 2 files changed, 23 insertions(+), 17 deletions(-) rename Projects/CoreDataService/Sources/{CoreDataContainerFactory.swift => CoreDataContainerBuilder.swift} (96%) diff --git a/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift b/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift index 06947814..8baeaab4 100644 --- a/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift +++ b/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift @@ -41,30 +41,36 @@ final class SplashViewModel: ViewModel { Task { try await input.viewDidLoad.value await registerDependency() - - let forceUpdate = try await versionCheckUseCase.checkForceUpdateNeeded() - switch forceUpdate { - case .notNeeded: + 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 { + // TODO: 에러 케이스의 처리 고민 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) } } return .init(alert: alertRelay.asObservable()) } private func registerDependency() async { - let coreDataContainer = await CoreDataContainerFactory().buildContainer() + let coreDataContainer = await CoreDataContainerBuilder().buildContainer() let firebaseLogger = FirebaseLoggerImpl() DIContainer.setLogger(firebaseLogger) diff --git a/Projects/CoreDataService/Sources/CoreDataContainerFactory.swift b/Projects/CoreDataService/Sources/CoreDataContainerBuilder.swift similarity index 96% rename from Projects/CoreDataService/Sources/CoreDataContainerFactory.swift rename to Projects/CoreDataService/Sources/CoreDataContainerBuilder.swift index 3fa04a53..7cdf4613 100644 --- a/Projects/CoreDataService/Sources/CoreDataContainerFactory.swift +++ b/Projects/CoreDataService/Sources/CoreDataContainerBuilder.swift @@ -1,5 +1,5 @@ // -// CoreDataContainerFactory.swift +// CoreDataContainerBuilder.swift // CoreDataService // // Created by Logan on 6/21/25. @@ -9,7 +9,7 @@ import CoreData import CloudKit -public final class CoreDataContainerFactory { +public final class CoreDataContainerBuilder { private enum Constants { static let fileName: String = "Model" static let appGroupName: String = "group.Pepsi-Club.WhereMyBus" From 97bc5e238e2596d2fdbd2b6d252cb036df03ed62 Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Mon, 7 Jul 2025 20:38:19 +0900 Subject: [PATCH 09/18] =?UTF-8?q?feat:=20InfoPlistWrapper=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/SceneDelegate.swift | 28 ++-- Projects/Core/Sources/Extension/String+.swift | 30 +--- .../PropertyWrapper/InfoPlistWrapper.swift | 134 ++++++++++++++++++ .../Response/AppVersionInfoResponse.swift | 18 ++- .../Sources/View/SettingButtonView.swift | 15 +- .../Sources/ViewModel/SettingsViewModel.swift | 31 ++-- 6 files changed, 190 insertions(+), 66 deletions(-) create mode 100644 Projects/Core/Sources/PropertyWrapper/InfoPlistWrapper.swift diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index a96d11c8..8aec9c2d 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -29,27 +29,17 @@ final class SceneDelegate: UIResponder, _sceneWillEnterForeground.stream } - var appVersion: AppVersionInfoResponse { - guard let dictionary = Bundle.main.infoDictionary, - let version = dictionary["CFBundleShortVersionString"] as? String - else { return .defaultVersion } - - let splitedVersion = version.split(separator: ".").compactMap { Int($0) } - - return AppVersionInfoResponse( - major: splitedVersion[0], - minor: splitedVersion[1], - patch: splitedVersion[2] - ) - } + @InfoPlistWrapper( + key: "CFBundleShortVersionString", + defaultValue: .defaultVersion + ) + var appVersion: AppVersionInfoResponse - var appStoreID: String { - Bundle.main.object(forInfoDictionaryKey: "APPSTORE_ID") as? String ?? "" - } + @InfoPlistWrapper(key: "APPSTORE_ID", defaultValue: "") + var appStoreID: String - var domainURL: String { - Bundle.main.object(forInfoDictionaryKey: "DOMAIN_URL") as? String ?? "" - } + @InfoPlistWrapper(key: "DOMAIN_URL", defaultValue: "") + var domainURL: String func scene( _ scene: UIScene, 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/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/Domain/Sources/Entity/Response/AppVersionInfoResponse.swift b/Projects/Domain/Sources/Entity/Response/AppVersionInfoResponse.swift index 40cf1fd4..e47c7aa6 100644 --- a/Projects/Domain/Sources/Entity/Response/AppVersionInfoResponse.swift +++ b/Projects/Domain/Sources/Entity/Response/AppVersionInfoResponse.swift @@ -8,6 +8,8 @@ import Foundation +import Core + public struct AppVersionInfoResponse: Codable, Comparable { public static let defaultVersion: Self = .init(major: 1, minor: 0, patch: 0) @@ -24,7 +26,7 @@ public struct AppVersionInfoResponse: Codable, Comparable { self.minor = minor self.patch = patch } - + public static func < ( lhs: AppVersionInfoResponse, rhs: AppVersionInfoResponse @@ -34,3 +36,17 @@ 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) } + + self = AppVersionInfoResponse( + major: splitedVersion[0], + minor: splitedVersion[1], + patch: splitedVersion[2] + ) + } +} 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) } }) From 45078808dd88581243322d6fdb2cfefd4c5f953a Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:33:07 +0900 Subject: [PATCH 10/18] =?UTF-8?q?fix:=20=EB=A6=B0=ED=8A=B8=20=EB=A3=B0=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EA=B2=BD=EA=B3=A0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .swiftlint.yml | 4 +++- .../Splash/SplashViewController.swift | 3 --- .../Sources/CoreDataContainerBuilder.swift | 6 ++++-- .../Sources/CoreDataStorageImpl.swift | 16 +++------------- Projects/DesignSystem/Project.swift | 2 +- Projects/Feature/AlarmFeature/Project.swift | 6 +++++- Projects/Feature/BusStopFeature/Project.swift | 6 +++++- Projects/Feature/HomeFeature/Project.swift | 6 +++++- .../Sources/ViewModel/FavoritesViewModel.swift | 2 +- Projects/Feature/NearMapFeature/Project.swift | 6 +++++- .../ViewController/NearMapViewController.swift | 2 +- Projects/Feature/SearchFeature/Project.swift | 6 +++++- Projects/Feature/SettingsFeature/Project.swift | 6 +++++- Projects/FeatureDependency/Project.swift | 2 +- .../Sources/FirebaseLoggerImpl.swift | 2 +- Projects/ThirdPartyLibs/Project.swift | 2 +- 16 files changed, 46 insertions(+), 31 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 5a771647..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 @@ -37,6 +40,5 @@ disabled_rules: # 제외하고 싶은 룰 - type_name # 타입명에 _가 들어가면 경고 - trailing_comma # 배열 마지막 아이템에 ,가 붙으면 경고 - nesting # 중첩타입 - - identifier_name opt_in_rules: - empty_string diff --git a/Projects/App/Sources/Coordinator/Splash/SplashViewController.swift b/Projects/App/Sources/Coordinator/Splash/SplashViewController.swift index 4495f3ac..82b9ddfb 100644 --- a/Projects/App/Sources/Coordinator/Splash/SplashViewController.swift +++ b/Projects/App/Sources/Coordinator/Splash/SplashViewController.swift @@ -17,8 +17,6 @@ final class SplashViewController: UIViewController { private let disposeBag: DisposeBag = .init() - private let iconImageView: UIImageView = .init() - init(viewModel: SplashViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) @@ -33,7 +31,6 @@ final class SplashViewController: UIViewController { view.backgroundColor = DesignSystemAsset.changeBlue.color - iconImageView.translatesAutoresizingMaskIntoConstraints = false let output = viewModel.transform( input: .init( viewDidLoad: .just(()) diff --git a/Projects/CoreDataService/Sources/CoreDataContainerBuilder.swift b/Projects/CoreDataService/Sources/CoreDataContainerBuilder.swift index 7cdf4613..b3224db0 100644 --- a/Projects/CoreDataService/Sources/CoreDataContainerBuilder.swift +++ b/Projects/CoreDataService/Sources/CoreDataContainerBuilder.swift @@ -28,7 +28,9 @@ public final class CoreDataContainerBuilder { let persistentStoreDescription = NSPersistentStoreDescription(url: appGroupStoreUrl) if await CKContainer.shouldUseCloudKit { container = NSPersistentCloudKitContainer(name: Constants.fileName) - persistentStoreDescription.cloudKitContainerOptions = .init(containerIdentifier: Constants.containerIdentifier) + persistentStoreDescription.cloudKitContainerOptions = .init( + containerIdentifier: Constants.containerIdentifier + ) } else { container = NSPersistentContainer(name: Constants.fileName) } @@ -37,7 +39,7 @@ public final class CoreDataContainerBuilder { return container }() let _: Void = await withCheckedContinuation { continuation in - container.loadPersistentStores { _, error in + container.loadPersistentStores { _, _ in continuation.resume() } } diff --git a/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift b/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift index e551a79a..cea4474e 100644 --- a/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift +++ b/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift @@ -62,7 +62,9 @@ extension CoreDataStorageImpl: CoreDataStorage { into: context ) guard let coreDataManagedObject = object as? T.ManagedObject else { - throw CoreDataStorageError.invalidManagedObject("타입 불일치: \(type(of: object)) != \(T.ManagedObject.self)") + throw CoreDataStorageError.invalidManagedObject( + "타입 불일치: \(type(of: object)) != \(T.ManagedObject.self)" + ) } data.sync(for: coreDataManagedObject) } @@ -96,15 +98,3 @@ extension CoreDataStorageImpl: CoreDataStorage { try await saveContext() } } - -//let apiConfig: Data = { -// "busStopList": { -// "version": { // currentVersion? requiredVersion? -// "major": 1, -// "minor": 0, -// "patch": 0 -// "updateAt": // Date 자료형, -// "seoulUpdateAt": // Date 자료형 -// } -// } -//} 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/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/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/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/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() From a95a1613d0a379e95d6a55391062bb07c59172f5 Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:43:28 +0900 Subject: [PATCH 11/18] =?UTF-8?q?fix:=20=EA=B0=95=EC=97=85=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=97=90=EB=9F=AC=EC=8B=9C=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift b/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift index 8baeaab4..f04ebea6 100644 --- a/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift +++ b/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift @@ -26,6 +26,7 @@ protocol SplashViewModelDependency { final class SplashViewModel: ViewModel { private weak var coordinator: SplashCoordinator? @Injected private var versionCheckUseCase: VersionCheckUseCase + @Injected private var firebaseLogger: FirebaseLogger private let dependency: SplashViewModelDependency init( @@ -60,7 +61,7 @@ final class SplashViewModel: ViewModel { alertRelay.accept(alert) } } catch { - // TODO: 에러 케이스의 처리 고민 + firebaseLogger.log(name: "강제 업데이트 실패: \(error.localizedDescription)") await MainActor.run { coordinator?.startTabFlow() } @@ -81,6 +82,7 @@ final class SplashViewModel: ViewModel { 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()) From 3401116d2a1fd6b20befcc00022658dd3baa89e3 Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:44:05 +0900 Subject: [PATCH 12/18] =?UTF-8?q?fix:=20deprecated=20=EC=96=B4=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Legacy/CoreDataService.swift | 1 + .../BusStopArrivalInfoRepository.swift | 1 + .../Sources/NetworkService/NetworkService.swift | 12 ++++++++++++ 3 files changed, 14 insertions(+) diff --git a/Projects/CoreDataService/Sources/Legacy/CoreDataService.swift b/Projects/CoreDataService/Sources/Legacy/CoreDataService.swift index b592b881..13713c2a 100644 --- a/Projects/CoreDataService/Sources/Legacy/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/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift b/Projects/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift index 3b318e48..d3279ed0 100644 --- a/Projects/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift +++ b/Projects/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift @@ -13,6 +13,7 @@ import RxSwift public protocol BusStopArrivalInfoRepository { func fetchArrivalList(busStopId: String) async throws -> BusStopArrivalInfoResponse + @available(*, deprecated, message: "") func fetchArrivalList( busStopId: String ) -> Observable diff --git a/Projects/NetworkService/Sources/NetworkService/NetworkService.swift b/Projects/NetworkService/Sources/NetworkService/NetworkService.swift index a5e992f0..570c2a77 100644 --- a/Projects/NetworkService/Sources/NetworkService/NetworkService.swift +++ b/Projects/NetworkService/Sources/NetworkService/NetworkService.swift @@ -13,7 +13,19 @@ 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 From 13d5fb41dabc0d7965ea6e0d0041b7df421b5f46 Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Mon, 7 Jul 2025 22:06:19 +0900 Subject: [PATCH 13/18] =?UTF-8?q?fix:=20Dictionary=EC=9D=98=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=EA=B0=92=20=EA=B4=80=EB=A0=A8=20=EB=9F=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=20=ED=81=AC=EB=9E=98=EC=8B=9C=20=EC=88=98=EC=A0=95=1C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Entity/Response/BusStopArrivalInfoResponse.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 From 60364fc19e7ebd5d6d957aae6ba375c02cd59550 Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Mon, 7 Jul 2025 22:25:46 +0900 Subject: [PATCH 14/18] =?UTF-8?q?fix:=20deprecated=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Coordinator/Splash/SplashViewModel.swift | 1 - .../DefaultForceUpdateService.swift | 22 ------------------- .../BusStopArrivalInfoRepository.swift | 2 +- .../FavoritesRepository.swift | 4 ++++ .../RegularAlarmRepository.swift | 4 ++++ .../VersionCheckRepository.swift | 3 ++- .../ForceUpdateService.swift | 16 -------------- 7 files changed, 11 insertions(+), 41 deletions(-) delete mode 100644 Projects/Data/Sources/Service/ForceUpdateService/DefaultForceUpdateService.swift delete mode 100644 Projects/Domain/Sources/Service/ForceUpdateService/ForceUpdateService.swift diff --git a/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift b/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift index f04ebea6..0ddc1911 100644 --- a/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift +++ b/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift @@ -77,7 +77,6 @@ final class SplashViewModel: ViewModel { DIContainer.setLogger(firebaseLogger) DIContainer.register(type: CoreDataStorage.self, CoreDataStorageImpl(container: coreDataContainer)) - DIContainer.register(type: ForceUpdateService.self, DefaultForceUpdateService()) DIContainer.register(type: CoreDataService.self, DefaultCoreDataService()) DIContainer.register(type: NetworkService.self, DefaultNetworkService()) DIContainer.register(type: LocationService.self, DefaultLocationService()) 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/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift b/Projects/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift index d3279ed0..449cc406 100644 --- a/Projects/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift +++ b/Projects/Domain/Sources/RepositoryInterface/BusStopArrivalInfoRepository.swift @@ -13,7 +13,7 @@ import RxSwift public protocol BusStopArrivalInfoRepository { func fetchArrivalList(busStopId: String) async throws -> BusStopArrivalInfoResponse - @available(*, deprecated, message: "") + @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 2f410d48..9aed9a10 100644 --- a/Projects/Domain/Sources/RepositoryInterface/FavoritesRepository.swift +++ b/Projects/Domain/Sources/RepositoryInterface/FavoritesRepository.swift @@ -11,10 +11,14 @@ 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 } 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 417a6c89..94387c9a 100644 --- a/Projects/Domain/Sources/RepositoryInterface/VersionCheckRepository.swift +++ b/Projects/Domain/Sources/RepositoryInterface/VersionCheckRepository.swift @@ -10,7 +10,8 @@ import Foundation public protocol VersionCheckRepository: AnyObject { func getCachedVersionCheckInfo() -> VersionCheckInfo? - func fetchRequiredVersion() async throws -> AppVersionInfoResponse 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 -} From 41590cf3e62f15fb1f8f5983962ae04120859ab4 Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Sat, 12 Jul 2025 14:31:23 +0900 Subject: [PATCH 15/18] =?UTF-8?q?fix:=20AppVersionInfoResponse=20=ED=81=AC?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=20=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=A1=B0=EA=B1=B4=EB=AC=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Sources/Entity/Response/AppVersionInfoResponse.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Projects/Domain/Sources/Entity/Response/AppVersionInfoResponse.swift b/Projects/Domain/Sources/Entity/Response/AppVersionInfoResponse.swift index e47c7aa6..523bbc29 100644 --- a/Projects/Domain/Sources/Entity/Response/AppVersionInfoResponse.swift +++ b/Projects/Domain/Sources/Entity/Response/AppVersionInfoResponse.swift @@ -43,6 +43,8 @@ extension AppVersionInfoResponse: InfoPlistLoadable { let splitedVersion = stringData.split(separator: ".").compactMap { Int($0) } + guard splitedVersion.count == 3 else { return nil } + self = AppVersionInfoResponse( major: splitedVersion[0], minor: splitedVersion[1], From b724255fecaff8836056cb5f0a83c942377f8a51 Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:30:58 +0900 Subject: [PATCH 16/18] =?UTF-8?q?fix:=20CoreData=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=EC=8B=9C=20=EB=9F=B0=ED=83=80=EC=9E=84=20?= =?UTF-8?q?=ED=81=AC=EB=9E=98=EC=8B=9C=20=EB=B0=9C=EC=83=9D=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/CoreDataModel.swift | 16 --------- .../Sources/CoreDataRepresentable.swift | 17 ++++++++++ .../Sources/CoreDataStorage.swift | 8 ++--- .../Sources/CoreDataStorageImpl.swift | 16 ++++----- .../CoreDataModelObject+Error.swift | 34 +++++++++++++++++++ ...FavoritesBusResponseMO+CoreDataClass.swift | 30 +++++++++------- 6 files changed, 81 insertions(+), 40 deletions(-) delete mode 100644 Projects/CoreDataService/Sources/CoreDataModel.swift create mode 100644 Projects/CoreDataService/Sources/CoreDataRepresentable.swift create mode 100644 Projects/Data/Sources/DTO/CoreDataModelObject/CoreDataModelObject+Error.swift diff --git a/Projects/CoreDataService/Sources/CoreDataModel.swift b/Projects/CoreDataService/Sources/CoreDataModel.swift deleted file mode 100644 index 95c12a61..00000000 --- a/Projects/CoreDataService/Sources/CoreDataModel.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// CoreDataModel.swift -// CoreDataService -// -// Created by Logan on 6/21/25. -// Copyright © 2025 Pepsi-Club. All rights reserved. -// - -import CoreData - -public protocol CoreDataModel: Identifiable where ID: CVarArg { - associatedtype ManagedObject: NSManagedObject - static func toDataModel(_ object: ManagedObject) -> Self - - func sync(for managedObject: ManagedObject) -} 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 index f6b6024f..55e8b80b 100644 --- a/Projects/CoreDataService/Sources/CoreDataStorage.swift +++ b/Projects/CoreDataService/Sources/CoreDataStorage.swift @@ -7,8 +7,8 @@ // 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 + 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 index cea4474e..ea3501c5 100644 --- a/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift +++ b/Projects/CoreDataService/Sources/CoreDataStorageImpl.swift @@ -26,7 +26,7 @@ public final class CoreDataStorageImpl { self.batchSize = batchSize } - private func readManagedObject(for data: T) async throws -> T.ManagedObject { + 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) @@ -55,7 +55,7 @@ public final class CoreDataStorageImpl { } extension CoreDataStorageImpl: CoreDataStorage { - public func create(data: T) async throws { + public func create(data: T) async throws { try await context.perform { [self] in let object = NSEntityDescription.insertNewObject( forEntityName: String(describing: T.ManagedObject.self), @@ -66,12 +66,12 @@ extension CoreDataStorageImpl: CoreDataStorage { "타입 불일치: \(type(of: object)) != \(T.ManagedObject.self)" ) } - data.sync(for: coreDataManagedObject) + data.apply(to: coreDataManagedObject) } try await saveContext() } - public func read(type: T.Type) async throws -> [T] { + 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 @@ -79,18 +79,18 @@ extension CoreDataStorageImpl: CoreDataStorage { return try context.fetch(request) } - return managedObjects.map { T.toDataModel($0) } + return try managedObjects.map { try T.init($0) } } - public func update(data: T) async throws { + public func update(data: T) async throws { let managedObject = try await readManagedObject(for: data) await context.perform { - data.sync(for: managedObject) + data.apply(to: managedObject) } try await saveContext() } - public func delete(data: T) async throws { + public func delete(data: T) async throws { let managedObject = try await readManagedObject(for: data) await context.perform { [self] in context.delete(managedObject) 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 855eda3a..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. // @@ -14,7 +14,7 @@ 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, @@ -32,17 +32,23 @@ public class FavoritesBusResponseMO: NSManagedObject, CoreDataModelObject { } } -extension FavoritesBusResponse: CoreDataModel { +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 static func toDataModel(_ object: FavoritesBusResponseMO) -> FavoritesBusResponse { - guard let busStopId = object.busStopId, - let busStopName = object.busStopName, - let busId = object.busId, - let busName = object.busName, - let adirection = object.adirection - else { fatalError() } - return FavoritesBusResponse( + 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, @@ -51,7 +57,7 @@ extension FavoritesBusResponse: CoreDataModel { ) } - public func sync(for managedObject: FavoritesBusResponseMO) { + public func apply(to managedObject: FavoritesBusResponseMO) { managedObject.identifier = identifier managedObject.busStopId = busStopId managedObject.busStopName = busStopName From c806a5edfc216b3ca2e157178fcc8c6105529dbd Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:37:17 +0900 Subject: [PATCH 17/18] =?UTF-8?q?fix:=20AppVersionCheckUseCase=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift | 4 ++-- Projects/App/Sources/SceneDelegate.swift | 2 +- .../Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift | 2 +- ...VersionCheckUseCase.swift => AppVersionCheckUseCase.swift} | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) rename Projects/Domain/Sources/UseCase/Protocol/{VersionCheckUseCase.swift => AppVersionCheckUseCase.swift} (74%) diff --git a/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift b/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift index 0ddc1911..3c6c4319 100644 --- a/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift +++ b/Projects/App/Sources/Coordinator/Splash/SplashViewModel.swift @@ -25,7 +25,7 @@ protocol SplashViewModelDependency { final class SplashViewModel: ViewModel { private weak var coordinator: SplashCoordinator? - @Injected private var versionCheckUseCase: VersionCheckUseCase + @Injected private var versionCheckUseCase: AppVersionCheckUseCase @Injected private var firebaseLogger: FirebaseLogger private let dependency: SplashViewModelDependency @@ -104,7 +104,7 @@ final class SplashViewModel: ViewModel { DIContainer.register(type: NearMapUseCase.self, DefaultNearMapUseCase()) DIContainer.register(type: FirebaseLogger.self, firebaseLogger) DIContainer.register( - type: VersionCheckUseCase.self, + type: AppVersionCheckUseCase.self, VersionCheckUseCaseImpl( currentVersion: dependency.appVersion ) diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index 8aec9c2d..0966f4cd 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -18,7 +18,7 @@ import RxSwift final class SceneDelegate: UIResponder, UIWindowSceneDelegate, AppCoordinatorDependency { - @Injected private var useCase: VersionCheckUseCase + @Injected private var useCase: AppVersionCheckUseCase var window: UIWindow? var appCoordinator: AppCoordinator? diff --git a/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift b/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift index 0a79e0b0..7d5ebc37 100644 --- a/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift +++ b/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift @@ -12,7 +12,7 @@ import Core import RxSwift -public final class VersionCheckUseCaseImpl: VersionCheckUseCase { +public final class VersionCheckUseCaseImpl: AppVersionCheckUseCase { @Injected private var versionCheckRepository: VersionCheckRepository private let currentVersion: AppVersionInfoResponse diff --git a/Projects/Domain/Sources/UseCase/Protocol/VersionCheckUseCase.swift b/Projects/Domain/Sources/UseCase/Protocol/AppVersionCheckUseCase.swift similarity index 74% rename from Projects/Domain/Sources/UseCase/Protocol/VersionCheckUseCase.swift rename to Projects/Domain/Sources/UseCase/Protocol/AppVersionCheckUseCase.swift index aba4551c..73eb4ab1 100644 --- a/Projects/Domain/Sources/UseCase/Protocol/VersionCheckUseCase.swift +++ b/Projects/Domain/Sources/UseCase/Protocol/AppVersionCheckUseCase.swift @@ -1,5 +1,5 @@ // -// VersionCheckUseCase.swift +// AppVersionCheckUseCase.swift // Domain // // Created by Jisoo Ham on 3/25/25. @@ -10,6 +10,6 @@ import Foundation import RxSwift -public protocol VersionCheckUseCase { +public protocol AppVersionCheckUseCase { func checkForceUpdateNeeded() async throws -> ForceUpdate } From 85f0e0f92555f647ef2531a0ee726755f953f6db Mon Sep 17 00:00:00 2001 From: Geonseob Kim <109283556+gnksbm@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:45:10 +0900 Subject: [PATCH 18/18] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20Rx=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/SceneDelegate.swift | 2 -- .../Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift | 2 -- .../Sources/UseCase/Protocol/AppVersionCheckUseCase.swift | 2 -- 3 files changed, 6 deletions(-) diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index 0966f4cd..22d217da 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -13,8 +13,6 @@ import NetworkService import Domain import Data -import RxSwift - final class SceneDelegate: UIResponder, UIWindowSceneDelegate, AppCoordinatorDependency { diff --git a/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift b/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift index 7d5ebc37..8e9a7c81 100644 --- a/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift +++ b/Projects/Domain/Sources/UseCase/DefaultVersionCheckUseCase.swift @@ -10,8 +10,6 @@ import Foundation import Core -import RxSwift - public final class VersionCheckUseCaseImpl: AppVersionCheckUseCase { @Injected private var versionCheckRepository: VersionCheckRepository private let currentVersion: AppVersionInfoResponse diff --git a/Projects/Domain/Sources/UseCase/Protocol/AppVersionCheckUseCase.swift b/Projects/Domain/Sources/UseCase/Protocol/AppVersionCheckUseCase.swift index 73eb4ab1..801a2fbb 100644 --- a/Projects/Domain/Sources/UseCase/Protocol/AppVersionCheckUseCase.swift +++ b/Projects/Domain/Sources/UseCase/Protocol/AppVersionCheckUseCase.swift @@ -8,8 +8,6 @@ import Foundation -import RxSwift - public protocol AppVersionCheckUseCase { func checkForceUpdateNeeded() async throws -> ForceUpdate }