diff --git a/MLS/Core/Core/Logger/Loggable.swift b/MLS/Core/Core/Logger/Loggable.swift new file mode 100644 index 00000000..03b0f3ad --- /dev/null +++ b/MLS/Core/Core/Logger/Loggable.swift @@ -0,0 +1,56 @@ +import OSLog + +public protocol Loggable {} + +public extension Loggable { + /// 모듈 이름을 자동으로 추출 + /// - Returns: 타입이 정의된 모듈 이름 (예: "AuthFeature", "BookmarkFeature") + static var subsystem: String { + let fullName = String(reflecting: Self.self) + // "AuthFeature.LoginViewModel" -> "AuthFeature" + return fullName.components(separatedBy: ".").first ?? "Unknown" + } + + /// 타입 이름을 카테고리로 사용 + /// - Returns: 타입 이름 (예: "LoginViewModel", "BookmarkManager") + static var category: String { + String(describing: Self.self) + } + + /// 타입별로 캐싱된 Logger 인스턴스 + private static var logger: Logger { + LoggerStorage.logger(subsystem: subsystem, category: category) + } + + /// 디버그 로그 (Debug 빌드에서만 출력) + /// - Parameter message: 로그 메시지 (.private로 보호되어 민감 정보도 안전하게 로깅) + func logDebug(_ message: String) { + #if DEBUG + Self.logger.debug("\(message, privacy: .private)") + #endif + } + + /// 정보성 로그 (Notice 레벨) + /// - Parameter message: 로그 메시지 + func logNotice(_ message: String) { + Self.logger.notice("\(message)") + } + + /// 경고 로그 (비정상적이지만 처리 가능한 상황) + /// - Parameter message: 로그 메시지 + func logWarning(_ message: String) { + Self.logger.warning("\(message)") + } + + /// 에러 로그 + /// - Parameter message: 로그 메시지 + func logError(_ message: String) { + Self.logger.error("\(message)") + } + + /// 치명적 에러 로그 (앱 크래시 직전 상황) + /// - Parameter message: 로그 메시지 + func logCritical(_ message: String) { + Self.logger.critical("\(message)") + } +} diff --git a/MLS/Core/Core/Logger/LoggerStorage.swift b/MLS/Core/Core/Logger/LoggerStorage.swift new file mode 100644 index 00000000..701a7b43 --- /dev/null +++ b/MLS/Core/Core/Logger/LoggerStorage.swift @@ -0,0 +1,47 @@ +import OSLog + +/// Logger를 NSCache에 저장하기 위한 Wrapper 클래스 +/// Logger는 struct이므로 class로 감싸야 NSCache에 저장 가능 +private final class LoggerBox { + let logger: Logger + + init(logger: Logger) { + self.logger = logger + } +} + +/// Logger 인스턴스를 캐싱하여 재사용하는 저장소 +/// NSCache를 사용하여 메모리 압박 시 자동으로 해제되며, Thread-safe하게 동작 +final class LoggerStorage { + static let shared = LoggerStorage() + + /// 캐싱된 Logger 인스턴스들 + /// NSCache는 메모리 부족 시 자동으로 오래된 항목을 제거 + private let cache = NSCache() + + private init() {} + + /// Logger 인스턴스를 가져오거나 생성 + /// - Parameters: + /// - subsystem: 모듈 이름 (예: "AuthFeature") + /// - category: 카테고리 이름 (예: "LoginViewModel") + /// - Returns: 캐싱되거나 새로 생성된 Logger 인스턴스 + static func logger(subsystem: String, category: String) -> Logger { + shared.getOrCreateLogger(subsystem: subsystem, category: category) + } + + private func getOrCreateLogger(subsystem: String, category: String) -> Logger { + let key = "\(subsystem).\(category)" as NSString + + // 캐시에서 확인 + if let box = cache.object(forKey: key) { + return box.logger + } + + // 없으면 새로 생성 (NSCache는 thread-safe) + let newLogger = Logger(subsystem: subsystem, category: category) + let box = LoggerBox(logger: newLogger) + cache.setObject(box, forKey: key) + return newLogger + } +} diff --git a/MLS/Core/DIContainerTests/DIContainerTests.swift b/MLS/Core/DIContainerTests/DIContainerTests.swift index ee92acf8..d8988c2c 100644 --- a/MLS/Core/DIContainerTests/DIContainerTests.swift +++ b/MLS/Core/DIContainerTests/DIContainerTests.swift @@ -1,19 +1,8 @@ import XCTest @testable import Core -@testable import Data -@testable import DomainInterface class DIContainerTests: XCTestCase { - override func setUp() { - super.setUp() - DIContainer.resetForTesting() - } - - override func tearDown() { - DIContainer.resetForTesting() - super.tearDown() - } /// 객체 등록 및 가져오기 테스트 func testRegisterAndResolve() { @@ -122,16 +111,3 @@ extension DIContainerTests { } } } - -// 테스트를 위한 서비스 초기화 함수 -extension DIContainer { - public static func resetForTesting() { - shared.resetForTesting() - } - - private func resetForTesting() { - serviceQueue.sync { - services.removeAll() - } - } -} diff --git a/MLS/Core/DIContainerTests/LoggerTests.swift b/MLS/Core/DIContainerTests/LoggerTests.swift new file mode 100644 index 00000000..1201aeb1 --- /dev/null +++ b/MLS/Core/DIContainerTests/LoggerTests.swift @@ -0,0 +1,119 @@ +@testable import Core +import Testing + +struct TestViewModel: Loggable { + func testLogs() { + logDebug("Debug 로그 테스트") + logNotice("Notice 로그 테스트") + logWarning("Warning 로그 테스트") + logError("Error 로그 테스트") + logCritical("Critical 로그 테스트") + } +} + +class TestManager: Loggable { + func testLogs() { + logDebug("Debug 로그 (class)") + logNotice("Notice 로그 (class)") + logWarning("Warning 로그 (class)") + logError("Error 로그 (class)") + logCritical("Critical 로그 (class)") + } +} + +struct LoggerTests { + + @Test("Subsystem 자동 추출 검증 - Struct") + func testSubsystemExtractionStruct() async throws { + // subsystem은 타입이 정의된 모듈 이름이어야 함 + let subsystem = TestViewModel.subsystem + let category = TestViewModel.category + + // DIContainerTests 모듈에 정의되어 있으므로 + #expect(subsystem == "DIContainerTests") + #expect(category == "TestViewModel") + } + + @Test("Subsystem 자동 추출 검증 - Class") + func testSubsystemExtractionClass() async throws { + let subsystem = TestManager.subsystem + let category = TestManager.category + + #expect(subsystem == "DIContainerTests") + #expect(category == "TestManager") + } + + @Test("Struct에서 Loggable 사용 테스트") + func testLoggableWithStruct() async throws { + let viewModel = TestViewModel() + + // 로그 출력 (Console.app에서 확인 가능) + // subsystem: "DIContainerTests", category: "TestViewModel" + viewModel.testLogs() + + // 에러 없이 실행되면 성공 + #expect(true) + } + + @Test("Class에서 Loggable 사용 테스트") + func testLoggableWithClass() async throws { + let manager = TestManager() + + // 로그 출력 (Console.app에서 확인 가능) + // subsystem: "DIContainerTests", category: "TestManager" + manager.testLogs() + + // 에러 없이 실행되면 성공 + #expect(true) + } + + @Test("동시성 안전성 테스트 - 같은 Logger 동시 접근") + func testConcurrentLoggerAccess() async throws { + // 100개 스레드가 동시에 같은 Logger 사용 + await withTaskGroup(of: Void.self) { group in + for i in 0..<100 { + group.addTask { + let vm = TestViewModel() + vm.logNotice("동시 로그 \(i)") + } + } + } + + // 크래시 없이 완료되면 성공 + #expect(true) + } + + @Test("여러 타입에서 동시에 로깅") + func testMultipleTypesConcurrent() async throws { + let viewModel = TestViewModel() + let manager = TestManager() + + // 동시에 로그 출력 + await withTaskGroup(of: Void.self) { group in + group.addTask { + viewModel.testLogs() + } + group.addTask { + manager.testLogs() + } + } + + // 에러 없이 실행되면 성공 + #expect(true) + } + + @Test("NSCache 캐싱 검증 - 반복 접근 시 정상 동작") + func testLoggerCaching() async throws { + let vm1 = TestViewModel() + let vm2 = TestViewModel() + + // 같은 타입에서 여러 번 로깅 (NSCache에서 캐싱된 Logger 재사용) + vm1.logNotice("첫 번째 인스턴스") + vm2.logNotice("두 번째 인스턴스") + vm1.logNotice("다시 첫 번째") + vm2.logNotice("다시 두 번째") + + // 크래시 없이 정상 동작하면 성공 + #expect(true) + } +} diff --git a/MLS/Data/Data/Network/DTO/AlarmDTO/AlarmResponseDTO.swift b/MLS/Data/Data/Network/DTO/AlarmDTO/AlarmResponseDTO.swift index c531c810..519a8084 100644 --- a/MLS/Data/Data/Network/DTO/AlarmDTO/AlarmResponseDTO.swift +++ b/MLS/Data/Data/Network/DTO/AlarmDTO/AlarmResponseDTO.swift @@ -32,6 +32,7 @@ public struct AlarmResponseDTO: Decodable { } public struct NormalContent: Decodable { + public let id: Int public let type: String public let title: String public let link: String @@ -50,6 +51,7 @@ public extension AlarmResponseDTO { switch content { case .normal(let normal): return AlarmResponse( + id: normal.id, type: normal.type, title: normal.title, link: normal.link, @@ -57,6 +59,7 @@ public extension AlarmResponseDTO { ) case .all(let all): return AlarmResponse( + id: all.alrim.id, type: all.alrim.type, title: all.alrim.title, link: all.alrim.link, @@ -72,6 +75,7 @@ public extension AlarmResponseDTO { switch content { case .all(let all): return AllAlarmResponse( + id: all.alrim.id, type: all.alrim.type, title: all.alrim.title, link: all.alrim.link, diff --git a/MLS/Data/Data/Network/Endpoints/AlarmEndPoint.swift b/MLS/Data/Data/Network/Endpoints/AlarmEndPoint.swift index 6a331b6d..3cf91795 100644 --- a/MLS/Data/Data/Network/Endpoints/AlarmEndPoint.swift +++ b/MLS/Data/Data/Network/Endpoints/AlarmEndPoint.swift @@ -6,7 +6,7 @@ public enum AlarmEndPoint { public static func fetchPatchNotes(query: Encodable) -> ResponsableEndPoint { .init( baseURL: base, - path: "/api/v1/alrim/list/patch-notes", + path: "/api/v2/alrim/list/patch-notes", method: .GET, query: query ) @@ -15,7 +15,7 @@ public enum AlarmEndPoint { public static func fetchNotices(query: Encodable) -> ResponsableEndPoint { .init( baseURL: base, - path: "/api/v1/alrim/list/notices", + path: "/api/v2/alrim/list/notices", method: .GET, query: query ) @@ -24,7 +24,7 @@ public enum AlarmEndPoint { public static func fetchOutdatedEvents(query: Encodable) -> ResponsableEndPoint { .init( baseURL: base, - path: "/api/v1/alrim/list/events/outdated", + path: "/api/v2/alrim/list/events/outdated", method: .GET, query: query ) @@ -33,7 +33,7 @@ public enum AlarmEndPoint { public static func fetchOngoingEvents(query: Encodable) -> ResponsableEndPoint { .init( baseURL: base, - path: "/api/v1/alrim/list/events/ongoing", + path: "/api/v2/alrim/list/events/ongoing", method: .GET, query: query ) @@ -42,7 +42,7 @@ public enum AlarmEndPoint { public static func fetchAll(query: Encodable) -> ResponsableEndPoint { .init( baseURL: base, - path: "/api/v1/alrim/all", + path: "/api/v2/alrim/all", method: .GET, query: query ) diff --git a/MLS/Data/Data/Repository/AlarmAPIRepositoryImpl.swift b/MLS/Data/Data/Repository/AlarmAPIRepositoryImpl.swift index 6e2763d7..ab98afde 100644 --- a/MLS/Data/Data/Repository/AlarmAPIRepositoryImpl.swift +++ b/MLS/Data/Data/Repository/AlarmAPIRepositoryImpl.swift @@ -13,31 +13,31 @@ public class AlarmAPIRepositoryImpl: AlarmAPIRepository { self.tokenInterceptor = interceptor } - public func fetchPatchNotes(cursor: String?, pageSize: Int) -> Observable> { + public func fetchPatchNotes(cursor: Int?, pageSize: Int) -> Observable> { let endpoint = AlarmEndPoint.fetchPatchNotes(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) .map { $0.toAlarmDomain() } } - public func fetchNotices(cursor: String?, pageSize: Int) -> Observable> { + public func fetchNotices(cursor: Int?, pageSize: Int) -> Observable> { let endpoint = AlarmEndPoint.fetchNotices(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) .map { $0.toAlarmDomain() } } - public func fetchOutdatedEvents(cursor: String?, pageSize: Int) -> Observable> { + public func fetchOutdatedEvents(cursor: Int?, pageSize: Int) -> Observable> { let endpoint = AlarmEndPoint.fetchOutdatedEvents(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) .map { $0.toAlarmDomain() } } - public func fetchOngoingEvents(cursor: String?, pageSize: Int) -> Observable> { + public func fetchOngoingEvents(cursor: Int?, pageSize: Int) -> Observable> { let endpoint = AlarmEndPoint.fetchOngoingEvents(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) .map { $0.toAlarmDomain() } } - public func fetchAll(cursor: String?, pageSize: Int) -> Observable> { + public func fetchAll(cursor: Int?, pageSize: Int) -> Observable> { let endpoint = AlarmEndPoint.fetchAll(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) .map { $0.toAllAlarmDomain() } @@ -52,7 +52,7 @@ public class AlarmAPIRepositoryImpl: AlarmAPIRepository { private extension AlarmAPIRepositoryImpl { struct AlarmQuery: Encodable { - let cursor: String? + let cursor: Int? let pageSize: Int } diff --git a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchAllAlarmUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchAllAlarmUseCaseImpl.swift index 47060dfb..9622cd52 100644 --- a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchAllAlarmUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchAllAlarmUseCaseImpl.swift @@ -11,7 +11,7 @@ public class FetchAllAlarmUseCaseImpl: FetchAllAlarmUseCase { self.repository = repository } - public func execute(cursor: String?, pageSize: Int) -> Observable> { - return repository.fetchAll(cursor: cursor, pageSize: pageSize) + public func execute(id: Int?, pageSize: Int) -> Observable> { + return repository.fetchAll(cursor: id, pageSize: pageSize) } } diff --git a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchNoticesUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchNoticesUseCaseImpl.swift index 410913be..b464aeed 100644 --- a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchNoticesUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchNoticesUseCaseImpl.swift @@ -11,7 +11,7 @@ public class FetchNoticesUseCaseImpl: FetchNoticesUseCase { self.repository = repository } - public func execute(cursor: String?, pageSize: Int) -> Observable> { - return repository.fetchNotices(cursor: cursor, pageSize: pageSize) + public func execute(id: Int?, pageSize: Int) -> Observable> { + return repository.fetchNotices(cursor: id, pageSize: pageSize) } } diff --git a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOngoingEventsUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOngoingEventsUseCaseImpl.swift index dd28aa8d..2a1c961f 100644 --- a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOngoingEventsUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOngoingEventsUseCaseImpl.swift @@ -11,7 +11,7 @@ public class FetchOngoingEventsUseCaseImpl: FetchOngoingEventsUseCase { self.repository = repository } - public func execute(cursor: String?, pageSize: Int) -> Observable> { - return repository.fetchOngoingEvents(cursor: cursor, pageSize: pageSize) + public func execute(id: Int?, pageSize: Int) -> Observable> { + return repository.fetchOngoingEvents(cursor: id, pageSize: pageSize) } } diff --git a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOutdatedEventsUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOutdatedEventsUseCaseImpl.swift index f3571532..ba778aa4 100644 --- a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOutdatedEventsUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOutdatedEventsUseCaseImpl.swift @@ -11,7 +11,7 @@ public class FetchOutdatedEventsUseCaseImpl: FetchOutdatedEventsUseCase { self.repository = repository } - public func execute(cursor: String?, pageSize: Int) -> Observable> { - return repository.fetchOutdatedEvents(cursor: cursor, pageSize: pageSize) + public func execute(id: Int?, pageSize: Int) -> Observable> { + return repository.fetchOutdatedEvents(cursor: id, pageSize: pageSize) } } diff --git a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchPatchNotesUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchPatchNotesUseCaseImpl.swift index 2817b0ef..122fa2ec 100644 --- a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchPatchNotesUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchPatchNotesUseCaseImpl.swift @@ -11,7 +11,7 @@ public class FetchPatchNotesUseCaseImpl: FetchPatchNotesUseCase { self.repository = repository } - public func execute(cursor: String?, pageSize: Int) -> Observable> { - return repository.fetchPatchNotes(cursor: cursor, pageSize: pageSize) + public func execute(id: Int?, pageSize: Int) -> Observable> { + return repository.fetchPatchNotes(cursor: id, pageSize: pageSize) } } diff --git a/MLS/Domain/DomainInterface/Entity/Alarm/AlarmResponse.swift b/MLS/Domain/DomainInterface/Entity/Alarm/AlarmResponse.swift index 78b27ee3..0f486c35 100644 --- a/MLS/Domain/DomainInterface/Entity/Alarm/AlarmResponse.swift +++ b/MLS/Domain/DomainInterface/Entity/Alarm/AlarmResponse.swift @@ -1,12 +1,14 @@ import Foundation public struct AlarmResponse: Equatable { + public let id: Int public let type: String public let title: String public let link: String public let date: String - public init(type: String, title: String, link: String, date: String) { + public init(id: Int, type: String, title: String, link: String, date: String) { + self.id = id self.type = type self.title = title self.link = link @@ -15,13 +17,15 @@ public struct AlarmResponse: Equatable { } public struct AllAlarmResponse: Equatable { + public let id: Int public let type: String public let title: String public let link: String public let date: String public var alreadyRead: Bool - public init(type: String, title: String, link: String, date: String, alreadyRead: Bool) { + public init(id: Int, type: String, title: String, link: String, date: String, alreadyRead: Bool) { + self.id = id self.type = type self.title = title self.link = link diff --git a/MLS/Domain/DomainInterface/Repository/AlarmAPIRepository.swift b/MLS/Domain/DomainInterface/Repository/AlarmAPIRepository.swift index 4c3c303b..e5c975b0 100644 --- a/MLS/Domain/DomainInterface/Repository/AlarmAPIRepository.swift +++ b/MLS/Domain/DomainInterface/Repository/AlarmAPIRepository.swift @@ -3,15 +3,15 @@ import Foundation import RxSwift public protocol AlarmAPIRepository { - func fetchPatchNotes(cursor: String?, pageSize: Int) -> Observable> + func fetchPatchNotes(cursor: Int?, pageSize: Int) -> Observable> - func fetchNotices(cursor: String?, pageSize: Int) -> Observable> + func fetchNotices(cursor: Int?, pageSize: Int) -> Observable> - func fetchOutdatedEvents(cursor: String?, pageSize: Int) -> Observable> + func fetchOutdatedEvents(cursor: Int?, pageSize: Int) -> Observable> - func fetchOngoingEvents(cursor: String?, pageSize: Int) -> Observable> + func fetchOngoingEvents(cursor: Int?, pageSize: Int) -> Observable> - func fetchAll(cursor: String?, pageSize: Int) -> Observable> + func fetchAll(cursor: Int?, pageSize: Int) -> Observable> func setRead(alarmLink: String) -> Completable } diff --git a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchAllAlarmUseCase.swift b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchAllAlarmUseCase.swift index 41206233..b4dcccab 100644 --- a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchAllAlarmUseCase.swift +++ b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchAllAlarmUseCase.swift @@ -1,5 +1,5 @@ import RxSwift public protocol FetchAllAlarmUseCase { - func execute(cursor: String?, pageSize: Int) -> Observable> + func execute(id: Int?, pageSize: Int) -> Observable> } diff --git a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchNoticesUseCase.swift b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchNoticesUseCase.swift index f5d75581..3f2e85fb 100644 --- a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchNoticesUseCase.swift +++ b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchNoticesUseCase.swift @@ -1,5 +1,5 @@ import RxSwift public protocol FetchNoticesUseCase { - func execute(cursor: String?, pageSize: Int) -> Observable> + func execute(id: Int?, pageSize: Int) -> Observable> } diff --git a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOngoingEventsUseCase.swift b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOngoingEventsUseCase.swift index 80303048..d1ca0894 100644 --- a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOngoingEventsUseCase.swift +++ b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOngoingEventsUseCase.swift @@ -1,5 +1,5 @@ import RxSwift public protocol FetchOngoingEventsUseCase { - func execute(cursor: String?, pageSize: Int) -> Observable> + func execute(id: Int?, pageSize: Int) -> Observable> } diff --git a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOutdatedEventsUseCase.swift b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOutdatedEventsUseCase.swift index 959663ea..e322d2af 100644 --- a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOutdatedEventsUseCase.swift +++ b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOutdatedEventsUseCase.swift @@ -1,5 +1,5 @@ import RxSwift public protocol FetchOutdatedEventsUseCase { - func execute(cursor: String?, pageSize: Int) -> Observable> + func execute(id: Int?, pageSize: Int) -> Observable> } diff --git a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchPatchNotesUseCase.swift b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchPatchNotesUseCase.swift index 62d27587..2439fb39 100644 --- a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchPatchNotesUseCase.swift +++ b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchPatchNotesUseCase.swift @@ -1,5 +1,5 @@ import RxSwift public protocol FetchPatchNotesUseCase { - func execute(cursor: String?, pageSize: Int) -> Observable> + func execute(id: Int?, pageSize: Int) -> Observable> } diff --git a/MLS/MLS.xcodeproj/project.pbxproj b/MLS/MLS.xcodeproj/project.pbxproj index 7b77279b..053ef9dd 100644 --- a/MLS/MLS.xcodeproj/project.pbxproj +++ b/MLS/MLS.xcodeproj/project.pbxproj @@ -30,6 +30,9 @@ 08ED492A2DCFDED4002C21A2 /* RxRelay in Frameworks */ = {isa = PBXBuildFile; productRef = 08ED49292DCFDED4002C21A2 /* RxRelay */; }; 08ED492C2DCFDED4002C21A2 /* RxSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 08ED492B2DCFDED4002C21A2 /* RxSwift */; }; 08ED4DB12DCFE098002C21A2 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 08ED4DB02DCFE098002C21A2 /* SnapKit */; }; + 08F7AA012F86745C00EF5C06 /* MLSAuthFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 08F7AA022F86745C00EF5C06 /* MLSAuthFeature */; }; + 08F7AA032F86745C00EF5C06 /* MLSAuthFeatureInterface in Frameworks */ = {isa = PBXBuildFile; productRef = 08F7AA042F86745C00EF5C06 /* MLSAuthFeatureInterface */; }; + 08F7AA052F86745C00EF5C06 /* MLSAuthFeatureTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 08F7AA062F86745C00EF5C06 /* MLSAuthFeatureTesting */; }; 770ADB1F2E433EDA00270506 /* RxKeyboard in Frameworks */ = {isa = PBXBuildFile; productRef = 770ADB1E2E433EDA00270506 /* RxKeyboard */; }; 772199F22E0E7EC800A7B58C /* AuthFeatureInterface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 772199F12E0E7EC800A7B58C /* AuthFeatureInterface.framework */; }; 772199F32E0E7EC800A7B58C /* AuthFeatureInterface.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 772199F12E0E7EC800A7B58C /* AuthFeatureInterface.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -51,10 +54,16 @@ 779A490E2E1AD26700ABDE4F /* BookmarkFeature.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 779A490C2E1AD26700ABDE4F /* BookmarkFeature.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 779A49102E1AD26D00ABDE4F /* BookmarkFeatureInterface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 779A490F2E1AD26D00ABDE4F /* BookmarkFeatureInterface.framework */; }; 779A49112E1AD26D00ABDE4F /* BookmarkFeatureInterface.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 779A490F2E1AD26D00ABDE4F /* BookmarkFeatureInterface.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 77A293312F79989200845081 /* DesignSystem.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 77A293302F79989200845081 /* DesignSystem.framework */; }; + 77A293322F79989200845081 /* DesignSystem.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 77A293302F79989200845081 /* DesignSystem.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 77B1F9952EE06A4E00AE4B4D /* RxGesture in Frameworks */ = {isa = PBXBuildFile; productRef = 77B1F9942EE06A4E00AE4B4D /* RxGesture */; }; 77E260412EEABEC40059E889 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 77E260402EEABEC40059E889 /* Settings.bundle */; }; 77EB18D62DED9256004FB380 /* AuthFeature.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 77EB18D52DED9256004FB380 /* AuthFeature.framework */; }; 77EB18D72DED9256004FB380 /* AuthFeature.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 77EB18D52DED9256004FB380 /* AuthFeature.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 77FA68B82F72C9C10064B6EB /* RxCocoa in Frameworks */ = {isa = PBXBuildFile; productRef = 77FA68B72F72C9C10064B6EB /* RxCocoa */; }; + 77FA68BA2F72C9C10064B6EB /* RxSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 77FA68B92F72C9C10064B6EB /* RxSwift */; }; + 77FA68BC2F72C9C70064B6EB /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 77FA68BB2F72C9C70064B6EB /* SnapKit */; }; + 77FA68BE2F72CA490064B6EB /* MLSDesignSystem in Frameworks */ = {isa = PBXBuildFile; productRef = 77FA68BD2F72CA490064B6EB /* MLSDesignSystem */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -95,6 +104,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + 77A293332F79989200845081 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 77A293322F79989200845081 /* DesignSystem.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -118,6 +138,7 @@ 087D3EE82DA7972C002F924D /* MLS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLS.app; sourceTree = BUILT_PRODUCTS_DIR; }; 08DA58A62E1E5BE3009097A6 /* DictionaryFeature.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = DictionaryFeature.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 08DA58A92E1E5BEB009097A6 /* DictionaryFeatureInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = DictionaryFeatureInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 08F7A9232F86745C00EF5C06 /* MLSAuthFeatureExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLSAuthFeatureExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 772199F12E0E7EC800A7B58C /* AuthFeatureInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AuthFeatureInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7721A5032E0EE7AE00A7B58C /* BaseFeature.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = BaseFeature.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 77660AD12DD0D361007A4EF3 /* KakaoConfig.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = KakaoConfig.xcconfig; sourceTree = ""; }; @@ -127,9 +148,11 @@ 7777F7072E9EAC0D00F53D68 /* MyPageFeatureInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MyPageFeatureInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 779A490C2E1AD26700ABDE4F /* BookmarkFeature.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = BookmarkFeature.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 779A490F2E1AD26D00ABDE4F /* BookmarkFeatureInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = BookmarkFeatureInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 77A293302F79989200845081 /* DesignSystem.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = DesignSystem.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 77BEB0402DBA84B0002FFCFC /* MLSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MLSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 77E260402EEABEC40059E889 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; name = Settings.bundle; path = MLS/Resource/Settings.bundle; sourceTree = ""; }; 77EB18D52DED9256004FB380 /* AuthFeature.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AuthFeature.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 77FA687A2F72C7360064B6EB /* MLSDesignSystemExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLSDesignSystemExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -140,6 +163,20 @@ ); target = 087D3EE72DA7972C002F924D /* MLS */; }; + 08F7A9362F86745D00EF5C06 /* Exceptions for "MLSAuthFeatureExample" folder in "MLSAuthFeatureExample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 08F7A9222F86745C00EF5C06 /* MLSAuthFeatureExample */; + }; + 77FA688B2F72C7380064B6EB /* Exceptions for "MLSDesignSystemExample" folder in "MLSDesignSystemExample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 77FA68792F72C7360064B6EB /* MLSDesignSystemExample */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -151,11 +188,27 @@ path = MLS; sourceTree = ""; }; + 08F7A9242F86745C00EF5C06 /* MLSAuthFeatureExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 08F7A9362F86745D00EF5C06 /* Exceptions for "MLSAuthFeatureExample" folder in "MLSAuthFeatureExample" target */, + ); + path = MLSAuthFeatureExample; + sourceTree = ""; + }; 77BEB0412DBA84B0002FFCFC /* MLSTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = MLSTests; sourceTree = ""; }; + 77FA687B2F72C7360064B6EB /* MLSDesignSystemExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 77FA688B2F72C7380064B6EB /* Exceptions for "MLSDesignSystemExample" folder in "MLSDesignSystemExample" target */, + ); + path = MLSDesignSystemExample; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -194,6 +247,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 08F7A9202F86745C00EF5C06 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 08F7AA012F86745C00EF5C06 /* MLSAuthFeature in Frameworks */, + 08F7AA032F86745C00EF5C06 /* MLSAuthFeatureInterface in Frameworks */, + 08F7AA052F86745C00EF5C06 /* MLSAuthFeatureTesting in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 77BEB03D2DBA84B0002FFCFC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -201,6 +264,18 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 77FA68772F72C7360064B6EB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 77FA68BC2F72C9C70064B6EB /* SnapKit in Frameworks */, + 77FA68BA2F72C9C10064B6EB /* RxSwift in Frameworks */, + 77A293312F79989200845081 /* DesignSystem.framework in Frameworks */, + 77FA68BE2F72CA490064B6EB /* MLSDesignSystem in Frameworks */, + 77FA68B82F72C9C10064B6EB /* RxCocoa in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -249,6 +324,7 @@ 084A25312DB93A5400C395C0 /* Frameworks */ = { isa = PBXGroup; children = ( + 77A293302F79989200845081 /* DesignSystem.framework */, 7777F7062E9EAC0D00F53D68 /* MyPageFeature.framework */, 7777F7072E9EAC0D00F53D68 /* MyPageFeatureInterface.framework */, 7777F7002E9EAB8400F53D68 /* BookmarkFeature.framework */, @@ -282,6 +358,8 @@ 085A7F742DAF99570046663F /* .swiftlint.yml */, 087D3EEA2DA7972C002F924D /* MLS */, 77BEB0412DBA84B0002FFCFC /* MLSTests */, + 77FA687B2F72C7360064B6EB /* MLSDesignSystemExample */, + 08F7A9242F86745C00EF5C06 /* MLSAuthFeatureExample */, 084A25312DB93A5400C395C0 /* Frameworks */, 087D3EE92DA7972C002F924D /* Products */, ); @@ -292,6 +370,8 @@ children = ( 087D3EE82DA7972C002F924D /* MLS.app */, 77BEB0402DBA84B0002FFCFC /* MLSTests.xctest */, + 77FA687A2F72C7360064B6EB /* MLSDesignSystemExample.app */, + 08F7A9232F86745C00EF5C06 /* MLSAuthFeatureExample.app */, ); name = Products; sourceTree = ""; @@ -334,6 +414,31 @@ productReference = 087D3EE82DA7972C002F924D /* MLS.app */; productType = "com.apple.product-type.application"; }; + 08F7A9222F86745C00EF5C06 /* MLSAuthFeatureExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 08F7A9372F86745D00EF5C06 /* Build configuration list for PBXNativeTarget "MLSAuthFeatureExample" */; + buildPhases = ( + 08F7A91F2F86745C00EF5C06 /* Sources */, + 08F7A9202F86745C00EF5C06 /* Frameworks */, + 08F7A9212F86745C00EF5C06 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 08F7A9242F86745C00EF5C06 /* MLSAuthFeatureExample */, + ); + name = MLSAuthFeatureExample; + packageProductDependencies = ( + 08F7AA022F86745C00EF5C06 /* MLSAuthFeature */, + 08F7AA042F86745C00EF5C06 /* MLSAuthFeatureInterface */, + 08F7AA062F86745C00EF5C06 /* MLSAuthFeatureTesting */, + ); + productName = MLSAuthFeatureExample; + productReference = 08F7A9232F86745C00EF5C06 /* MLSAuthFeatureExample.app */; + productType = "com.apple.product-type.application"; + }; 77BEB03F2DBA84B0002FFCFC /* MLSTests */ = { isa = PBXNativeTarget; buildConfigurationList = 77BEB0482DBA84B0002FFCFC /* Build configuration list for PBXNativeTarget "MLSTests" */; @@ -357,6 +462,33 @@ productReference = 77BEB0402DBA84B0002FFCFC /* MLSTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 77FA68792F72C7360064B6EB /* MLSDesignSystemExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 77FA688C2F72C7380064B6EB /* Build configuration list for PBXNativeTarget "MLSDesignSystemExample" */; + buildPhases = ( + 77FA68762F72C7360064B6EB /* Sources */, + 77FA68772F72C7360064B6EB /* Frameworks */, + 77FA68782F72C7360064B6EB /* Resources */, + 77A293332F79989200845081 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 77FA687B2F72C7360064B6EB /* MLSDesignSystemExample */, + ); + name = MLSDesignSystemExample; + packageProductDependencies = ( + 77FA68B72F72C9C10064B6EB /* RxCocoa */, + 77FA68B92F72C9C10064B6EB /* RxSwift */, + 77FA68BB2F72C9C70064B6EB /* SnapKit */, + 77FA68BD2F72CA490064B6EB /* MLSDesignSystem */, + ); + productName = MLSDesignSystemExample; + productReference = 77FA687A2F72C7360064B6EB /* MLSDesignSystemExample.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -364,16 +496,22 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1620; + LastSwiftUpdateCheck = 2610; LastUpgradeCheck = 1620; TargetAttributes = { 087D3EE72DA7972C002F924D = { CreatedOnToolsVersion = 16.2; }; + 08F7A9222F86745C00EF5C06 = { + CreatedOnToolsVersion = 26.1.1; + }; 77BEB03F2DBA84B0002FFCFC = { CreatedOnToolsVersion = 16.2; TestTargetID = 087D3EE72DA7972C002F924D; }; + 77FA68792F72C7360064B6EB = { + CreatedOnToolsVersion = 26.1.1; + }; }; }; buildConfigurationList = 087D3EE32DA7972C002F924D /* Build configuration list for PBXProject "MLS" */; @@ -427,6 +565,8 @@ targets = ( 087D3EE72DA7972C002F924D /* MLS */, 77BEB03F2DBA84B0002FFCFC /* MLSTests */, + 77FA68792F72C7360064B6EB /* MLSDesignSystemExample */, + 08F7A9222F86745C00EF5C06 /* MLSAuthFeatureExample */, ); }; /* End PBXProject section */ @@ -442,6 +582,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 08F7A9212F86745C00EF5C06 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 77BEB03E2DBA84B0002FFCFC /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -449,6 +596,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 77FA68782F72C7360064B6EB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -480,6 +634,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 08F7A91F2F86745C00EF5C06 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 77BEB03C2DBA84B0002FFCFC /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -487,6 +648,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 77FA68762F72C7360064B6EB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -513,6 +681,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = W5QTRMS954; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = MLS/Resource/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "메랜사"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -525,7 +694,7 @@ "@executable_path/Frameworks", ); MACH_O_TYPE = mh_execute; - MARKETING_VERSION = 3.0.1; + MARKETING_VERSION = 3.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLS; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -555,6 +724,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = W5QTRMS954; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = MLS/Resource/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "메랜사"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -567,7 +737,7 @@ "@executable_path/Frameworks", ); MACH_O_TYPE = mh_execute; - MARKETING_VERSION = 3.0.1; + MARKETING_VERSION = 3.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLS; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -701,6 +871,72 @@ }; name = Release; }; + 08F7A9342F86745D00EF5C06 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MLSAuthFeatureExample/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLSAuthFeatureExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 08F7A9352F86745D00EF5C06 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MLSAuthFeatureExample/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLSAuthFeatureExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 77BEB0462DBA84B0002FFCFC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -737,6 +973,78 @@ }; name = Release; }; + 77FA688D2F72C7380064B6EB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MLSDesignSystemExample/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLSDesignSystemExample.MLSDesignSystemExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 77FA688E2F72C7380064B6EB /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MLSDesignSystemExample/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLSDesignSystemExample.MLSDesignSystemExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -758,6 +1066,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 08F7A9372F86745D00EF5C06 /* Build configuration list for PBXNativeTarget "MLSAuthFeatureExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 08F7A9342F86745D00EF5C06 /* Debug */, + 08F7A9352F86745D00EF5C06 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 77BEB0482DBA84B0002FFCFC /* Build configuration list for PBXNativeTarget "MLSTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -767,6 +1084,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 77FA688C2F72C7380064B6EB /* Build configuration list for PBXNativeTarget "MLSDesignSystemExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 77FA688D2F72C7380064B6EB /* Debug */, + 77FA688E2F72C7380064B6EB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -859,6 +1185,18 @@ package = 08ED4DAF2DCFE098002C21A2 /* XCRemoteSwiftPackageReference "SnapKit" */; productName = SnapKit; }; + 08F7AA022F86745C00EF5C06 /* MLSAuthFeature */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSAuthFeature; + }; + 08F7AA042F86745C00EF5C06 /* MLSAuthFeatureInterface */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSAuthFeatureInterface; + }; + 08F7AA062F86745C00EF5C06 /* MLSAuthFeatureTesting */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSAuthFeatureTesting; + }; 770ADB1E2E433EDA00270506 /* RxKeyboard */ = { isa = XCSwiftPackageProductDependency; package = 770ADB1D2E433EDA00270506 /* XCRemoteSwiftPackageReference "RxKeyboard" */; @@ -884,6 +1222,25 @@ package = 77B1F9932EE06A4E00AE4B4D /* XCRemoteSwiftPackageReference "RxGesture" */; productName = RxGesture; }; + 77FA68B72F72C9C10064B6EB /* RxCocoa */ = { + isa = XCSwiftPackageProductDependency; + package = 08ED49202DCFDE9C002C21A2 /* XCRemoteSwiftPackageReference "RxSwift" */; + productName = RxCocoa; + }; + 77FA68B92F72C9C10064B6EB /* RxSwift */ = { + isa = XCSwiftPackageProductDependency; + package = 08ED49202DCFDE9C002C21A2 /* XCRemoteSwiftPackageReference "RxSwift" */; + productName = RxSwift; + }; + 77FA68BB2F72C9C70064B6EB /* SnapKit */ = { + isa = XCSwiftPackageProductDependency; + package = 08ED4DAF2DCFE098002C21A2 /* XCRemoteSwiftPackageReference "SnapKit" */; + productName = SnapKit; + }; + 77FA68BD2F72CA490064B6EB /* MLSDesignSystem */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSDesignSystem; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 087D3EE02DA7972C002F924D /* Project object */; diff --git a/MLS/MLS.xcworkspace/contents.xcworkspacedata b/MLS/MLS.xcworkspace/contents.xcworkspacedata index 939189f2..99fde6a0 100644 --- a/MLS/MLS.xcworkspace/contents.xcworkspacedata +++ b/MLS/MLS.xcworkspace/contents.xcworkspacedata @@ -35,4 +35,16 @@ + + + + + + + + diff --git a/MLS/MLSAppFeature/.claude/settings.local.json b/MLS/MLSAppFeature/.claude/settings.local.json new file mode 100644 index 00000000..070af57c --- /dev/null +++ b/MLS/MLSAppFeature/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(swift build:*)", + "Bash(rm:*)", + "Bash(mkdir:*)", + "Bash(swift test:*)", + "Bash(find:*)" + ] + } +} diff --git a/MLS/MLSAppFeature/.gitignore b/MLS/MLSAppFeature/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/MLS/MLSAppFeature/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/MLS/MLSAppFeature/Package.swift b/MLS/MLSAppFeature/Package.swift new file mode 100644 index 00000000..895561cb --- /dev/null +++ b/MLS/MLSAppFeature/Package.swift @@ -0,0 +1,52 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MLSAppFeature", + platforms: [.iOS(.v15)], + products: [ + // Interface: 외부 인터페이스와 모델을 제공하는 모듈 + .library( + name: "MLSAppFeatureInterface", + targets: ["MLSAppFeatureInterface"] + ), + // Feature: 실제 기능이 구현된 모듈 + .library( + name: "MLSAppFeature", + targets: ["MLSAppFeature"] + ), + // Testing: 단위 테스트나 Example 앱에서 사용될 Mock 데이터를 제공하는 모듈 + .library( + name: "MLSAppFeatureTesting", + targets: ["MLSAppFeatureTesting"] + ) + ], + targets: [ + // Interface 모듈 (도메인 모델 및 프로토콜) + .target( + name: "MLSAppFeatureInterface", + dependencies: [] + ), + // Feature 모듈 (실제 구현) + .target( + name: "MLSAppFeature", + dependencies: ["MLSAppFeatureInterface"] + ), + // Testing 모듈 (Mock 객체) + .target( + name: "MLSAppFeatureTesting", + dependencies: ["MLSAppFeatureInterface"] + ), + // Tests 모듈 + .testTarget( + name: "MLSAppFeatureTests", + dependencies: [ + "MLSAppFeature", + "MLSAppFeatureInterface", + "MLSAppFeatureTesting" + ] + ) + ] +) diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Local/UserDefaultsDataSource.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Local/UserDefaultsDataSource.swift new file mode 100644 index 00000000..1f314b3d --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Local/UserDefaultsDataSource.swift @@ -0,0 +1,41 @@ +import Foundation + +import MLSAppFeatureInterface + +/// UserDefaults를 사용하는 Local Data Source +final class UserDefaultsDataSource { + nonisolated(unsafe) private let userDefaults: UserDefaults + private let versionKey = "com.mls.updateChecker.skippedVersion" + private let dateKey = "com.mls.updateChecker.skippedDate" + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + /// 스킵 버전을 저장합니다 + func saveSkipVersion(_ version: Version, skippedAt date: Date) { + userDefaults.set(version.versionString, forKey: versionKey) + userDefaults.set(date.timeIntervalSince1970, forKey: dateKey) + } + + /// 저장된 스킵 버전을 조회합니다 + func getSkippedVersion() -> Version? { + guard let versionString = userDefaults.string(forKey: versionKey) else { + return nil + } + return Version(versionString: versionString) + } + + /// 스킵 날짜를 조회합니다 + func getSkippedDate() -> Date? { + let timestamp = userDefaults.double(forKey: dateKey) + guard timestamp > 0 else { return nil } + return Date(timeIntervalSince1970: timestamp) + } + + /// 스킵 정보를 삭제합니다 + func clearSkipInfo() { + userDefaults.removeObject(forKey: versionKey) + userDefaults.removeObject(forKey: dateKey) + } +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Remote/AppStoreService.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Remote/AppStoreService.swift new file mode 100644 index 00000000..b3ffb71a --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Remote/AppStoreService.swift @@ -0,0 +1,50 @@ +import Foundation + +import MLSAppFeatureInterface + +/// iTunes Search API를 통해 앱스토어 버전 정보를 조회하는 Remote Data Source +final class AppStoreService { + private let urlSession: URLSession + + init(urlSession: URLSession = .shared) { + self.urlSession = urlSession + } + + /// 앱스토어에서 최신 버전을 조회합니다 + /// - Parameter appID: 앱스토어 앱 ID + /// - Returns: 최신 버전 정보 + /// - Throws: AppStoreError + func fetchLatestVersion(appID: String) async throws -> Version { + guard let url = URL(string: "https://itunes.apple.com/lookup?id=\(appID)") else { + throw AppStoreError.invalidURL + } + + let (data, response) = try await urlSession.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw AppStoreError.invalidResponse + } + + guard let lookupResponse = try? JSONDecoder().decode(AppStoreLookupResponse.self, from: data) else { + throw AppStoreError.parsingError + } + + guard let result = lookupResponse.results.first, + let version = Version(versionString: result.version) else { + throw AppStoreError.versionNotFound + } + + return version + } +} + +// MARK: - Response Models + +private struct AppStoreLookupResponse: Decodable { + let results: [AppStoreResult] +} + +private struct AppStoreResult: Decodable { + let version: String +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/AppStoreRepository.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/AppStoreRepository.swift new file mode 100644 index 00000000..4f3a6508 --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/AppStoreRepository.swift @@ -0,0 +1,21 @@ +import Foundation + +import MLSAppFeatureInterface + +/// 앱스토어 정보를 조회하는 Repository 구현체 +public final class AppStoreRepository: AppStoreRepositoryProtocol, @unchecked Sendable { + private let remoteDataSource: AppStoreService + + public init() { + self.remoteDataSource = AppStoreService() + } + + /// 테스트용 초기화자 + init(remoteDataSource: AppStoreService) { + self.remoteDataSource = remoteDataSource + } + + public func fetchLatestVersion(appID: String) async throws -> Version { + return try await remoteDataSource.fetchLatestVersion(appID: appID) + } +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/UpdateSkipRepository.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/UpdateSkipRepository.swift new file mode 100644 index 00000000..0b5692f5 --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/UpdateSkipRepository.swift @@ -0,0 +1,45 @@ +import Foundation + +import MLSAppFeatureInterface + +/// 업데이트 스킵 정보를 관리하는 Repository 구현체 +public final class UpdateSkipRepository: UpdateSkipRepositoryProtocol, @unchecked Sendable { + private let localDataSource: UserDefaultsDataSource + private let skipDuration: TimeInterval + + /// UpdateSkipRepository 초기화 + /// - Parameter skipDuration: 스킵 유효 기간 (기본값: 7일) + public init(skipDuration: TimeInterval = 7 * 24 * 60 * 60) { + self.localDataSource = UserDefaultsDataSource() + self.skipDuration = skipDuration + } + + /// 테스트용 초기화자 + /// - Parameters: + /// - localDataSource: 로컬 데이터 소스 + /// - skipDuration: 스킵 유효 기간 + init(localDataSource: UserDefaultsDataSource, skipDuration: TimeInterval = 7 * 24 * 60 * 60) { + self.localDataSource = localDataSource + self.skipDuration = skipDuration + } + + public func saveSkipVersion(_ version: Version, skippedAt date: Date) { + localDataSource.saveSkipVersion(version, skippedAt: date) + } + + public func isSkipValid(for version: Version) -> Bool { + guard let skippedVersion = localDataSource.getSkippedVersion(), + let skippedDate = localDataSource.getSkippedDate(), + skippedVersion == version else { + return false + } + + // 스킵 기간이 지났는지 확인 + let elapsed = Date().timeIntervalSince(skippedDate) + return elapsed >= 0 && elapsed < skipDuration + } + + public func clearSkipInfo() { + localDataSource.clearSkipInfo() + } +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Domain/UseCases/UpdateCheckerUseCase.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Domain/UseCases/UpdateCheckerUseCase.swift new file mode 100644 index 00000000..b3c20b1b --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Domain/UseCases/UpdateCheckerUseCase.swift @@ -0,0 +1,55 @@ +import Foundation + +import MLSAppFeatureInterface + +/// 앱 업데이트 체크 Use Case 구현체 +public final class UpdateCheckerUseCase: UpdateCheckerUseCaseProtocol { + private let appStoreRepository: AppStoreRepositoryProtocol + private let skipRepository: UpdateSkipRepositoryProtocol + private let appID: String + + /// UpdateCheckerUseCase 초기화 + /// - Parameters: + /// - appID: 앱스토어 앱 ID + /// - appStoreRepository: 앱스토어 정보 조회 Repository + /// - skipRepository: 스킵 정보 관리 Repository + public init( + appID: String, + appStoreRepository: AppStoreRepositoryProtocol, + skipRepository: UpdateSkipRepositoryProtocol + ) { + self.appID = appID + self.appStoreRepository = appStoreRepository + self.skipRepository = skipRepository + } + + public func checkUpdate(currentVersion: Version) async throws -> UpdateStatus { + let latestVersion = try await appStoreRepository.fetchLatestVersion(appID: appID) + + // 최신 버전이 현재 버전보다 낮거나 같으면 업데이트 불필요 + guard latestVersion > currentVersion else { + return .none + } + + // major 버전이 다르면 강제 업데이트 + if latestVersion.major != currentVersion.major { + return .force(latestVersion: latestVersion) + } + + // minor 또는 patch 차이면 선택 업데이트 + // 단, 사용자가 이전에 스킵했고 7일이 지나지 않았으면 none 반환 + if skipRepository.isSkipValid(for: latestVersion) { + return .none + } + + return .optional(latestVersion: latestVersion) + } + + public func skipUpdate(version: Version) { + skipRepository.saveSkipVersion(version, skippedAt: Date()) + } + + public func clearSkipInfo() { + skipRepository.clearSkipInfo() + } +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/UpdateStatus.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/UpdateStatus.swift new file mode 100644 index 00000000..e9707969 --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/UpdateStatus.swift @@ -0,0 +1,13 @@ +import Foundation + +/// 앱 업데이트 상태를 나타내는 열거형 +public enum UpdateStatus: Equatable, Sendable { + /// 강제 업데이트 필요 (major 버전 차이) + case force(latestVersion: Version) + + /// 선택적 업데이트 가능 (minor 또는 patch 차이) + case optional(latestVersion: Version) + + /// 업데이트 불필요 + case none +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/Version.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/Version.swift new file mode 100644 index 00000000..ae647174 --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/Version.swift @@ -0,0 +1,38 @@ +import Foundation + +/// 앱 버전을 나타내는 도메인 엔티티 +/// major.minor.patch 형식의 시맨틱 버저닝을 지원합니다 +public struct Version: Equatable, Comparable, Sendable { + public let major: Int + public let minor: Int + public let patch: Int + + public init(major: Int, minor: Int, patch: Int) { + self.major = major + self.minor = minor + self.patch = patch + } + + /// 버전 문자열로부터 Version 객체를 생성합니다 + /// - Parameter versionString: "1.2.3" 형식의 버전 문자열 + /// - Returns: 파싱에 성공하면 Version 객체, 실패하면 nil + public init?(versionString: String) { + let components = versionString.split(separator: ".").compactMap { Int($0) } + guard components.count >= 2 else { return nil } + + self.major = components[0] + self.minor = components[1] + self.patch = components.count > 2 ? components[2] : 0 + } + + /// 버전을 문자열로 반환합니다 + public var versionString: String { + "\(major).\(minor).\(patch)" + } + + public static func < (lhs: Version, rhs: Version) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + return lhs.patch < rhs.patch + } +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/AppStoreRepositoryProtocol.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/AppStoreRepositoryProtocol.swift new file mode 100644 index 00000000..dea53755 --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/AppStoreRepositoryProtocol.swift @@ -0,0 +1,19 @@ +import Foundation + +/// 앱스토어 정보를 조회하는 Repository 프로토콜 +public protocol AppStoreRepositoryProtocol: Sendable { + /// 앱스토어에서 최신 버전을 조회합니다 + /// - Parameter appID: 앱스토어 앱 ID + /// - Returns: 최신 버전 정보 + /// - Throws: 조회 실패 시 AppStoreError + func fetchLatestVersion(appID: String) async throws -> Version +} + +/// 앱스토어 조회 시 발생할 수 있는 에러 +public enum AppStoreError: Error, Equatable, Sendable { + case invalidURL + case networkFailure + case invalidResponse + case versionNotFound + case parsingError +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/UpdateSkipRepositoryProtocol.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/UpdateSkipRepositoryProtocol.swift new file mode 100644 index 00000000..c2f840a3 --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/UpdateSkipRepositoryProtocol.swift @@ -0,0 +1,18 @@ +import Foundation + +/// 업데이트 스킵 정보를 관리하는 Repository 프로토콜 +public protocol UpdateSkipRepositoryProtocol: Sendable { + /// 특정 버전에 대한 스킵 정보를 저장합니다 + /// - Parameters: + /// - version: 스킵할 버전 + /// - date: 스킵 날짜 + func saveSkipVersion(_ version: Version, skippedAt date: Date) + + /// 특정 버전에 대한 스킵 정보가 유효한지 확인합니다 + /// - Parameter version: 확인할 버전 + /// - Returns: 스킵 정보가 유효하면 true (7일 이내) + func isSkipValid(for version: Version) -> Bool + + /// 저장된 스킵 정보를 삭제합니다 + func clearSkipInfo() +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/UseCases/UpdateCheckerUseCaseProtocol.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/UseCases/UpdateCheckerUseCaseProtocol.swift new file mode 100644 index 00000000..12583c07 --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/UseCases/UpdateCheckerUseCaseProtocol.swift @@ -0,0 +1,17 @@ +import Foundation + +/// 앱 업데이트 체크를 담당하는 Use Case 프로토콜 +public protocol UpdateCheckerUseCaseProtocol: Sendable { + /// 현재 버전과 앱스토어의 최신 버전을 비교하여 업데이트 상태를 반환합니다 + /// - Parameter currentVersion: 현재 앱 버전 + /// - Returns: 업데이트 상태 (force, optional, none) + /// - Throws: 앱스토어 조회 실패 시 에러 + func checkUpdate(currentVersion: Version) async throws -> UpdateStatus + + /// 사용자가 선택 업데이트를 스킵했을 때 호출합니다 + /// - Parameter version: 스킵할 버전 + func skipUpdate(version: Version) + + /// 스킵 정보를 초기화합니다 + func clearSkipInfo() +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockAppStoreRepository.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockAppStoreRepository.swift new file mode 100644 index 00000000..29044c2b --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockAppStoreRepository.swift @@ -0,0 +1,32 @@ +import Foundation +import MLSAppFeatureInterface + +/// 테스트용 MockAppStoreRepository +public final class MockAppStoreRepository: AppStoreRepositoryProtocol, @unchecked Sendable { + public var mockVersion: Version? + public var mockError: Error? + public var fetchCallCount = 0 + + public init() {} + + public func fetchLatestVersion(appID: String) async throws -> Version { + fetchCallCount += 1 + + if let error = mockError { + throw error + } + + guard let version = mockVersion else { + throw AppStoreError.versionNotFound + } + + return version + } + + /// Mock 데이터를 초기화합니다 + public func reset() { + mockVersion = nil + mockError = nil + fetchCallCount = 0 + } +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockUpdateSkipRepository.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockUpdateSkipRepository.swift new file mode 100644 index 00000000..5beebb5d --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockUpdateSkipRepository.swift @@ -0,0 +1,62 @@ +import Foundation +import MLSAppFeatureInterface + +/// 테스트용 MockUpdateSkipRepository +public final class MockUpdateSkipRepository: UpdateSkipRepositoryProtocol, @unchecked Sendable { + private var skippedVersion: Version? + private var skippedDate: Date? + public var skipDuration: TimeInterval = 7 * 24 * 60 * 60 + + public var saveCallCount = 0 + public var clearCallCount = 0 + + public init() {} + + public func saveSkipVersion(_ version: Version, skippedAt date: Date) { + saveCallCount += 1 + skippedVersion = version + skippedDate = date + } + + public func isSkipValid(for version: Version) -> Bool { + guard let skippedVersion = skippedVersion, + let skippedDate = skippedDate else { + return false + } + + guard skippedVersion == version else { + return false + } + + let currentDate = Date() + let elapsedTime = currentDate.timeIntervalSince(skippedDate) + + return elapsedTime < skipDuration + } + + public func clearSkipInfo() { + clearCallCount += 1 + skippedVersion = nil + skippedDate = nil + } + + /// Mock 데이터를 초기화합니다 + public func reset() { + skippedVersion = nil + skippedDate = nil + saveCallCount = 0 + clearCallCount = 0 + } + + // MARK: - Test Helpers + + /// 테스트용 헬퍼 메서드 + public func getSkippedVersion() -> Version? { + return skippedVersion + } + + /// 테스트용 헬퍼 메서드 + public func getSkippedDate() -> Date? { + return skippedDate + } +} diff --git a/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Data/UpdateSkipRepositoryTests.swift b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Data/UpdateSkipRepositoryTests.swift new file mode 100644 index 00000000..c8aa5079 --- /dev/null +++ b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Data/UpdateSkipRepositoryTests.swift @@ -0,0 +1,163 @@ +import Foundation +import Testing + +@testable import MLSAppFeature +@testable import MLSAppFeatureInterface + +/// UpdateSkipRepository의 스킵 정보 저장, 조회, 유효성 검증 기능을 테스트합니다 +@Suite("UpdateSkipRepository 테스트") +struct UpdateSkipRepositoryTests { + + /// 테스트용 UserDefaults와 Repository를 생성합니다 + func createRepository(suiteName: String = "com.mls.test.skip") -> (UpdateSkipRepository, UserDefaults) { + let userDefaults = UserDefaults(suiteName: suiteName)! + userDefaults.removePersistentDomain(forName: suiteName) + userDefaults.synchronize() + + let dataSource = UserDefaultsDataSource(userDefaults: userDefaults) + let repository = UpdateSkipRepository(localDataSource: dataSource) + + return (repository, userDefaults) + } + + // MARK: - 저장 및 조회 테스트 + + /// 스킵 정보를 저장하고 유효성 검증이 올바른지 확인합니다 + @Test("스킵 정보 저장 및 유효성 검증") + func saveAndValidateSkip() { + // Given + let suiteName = "com.mls.test.skip.save" + let (repository, userDefaults) = createRepository(suiteName: suiteName) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let version = Version(major: 1, minor: 2, patch: 0) + + // When: 버전 스킵 저장 + repository.saveSkipVersion(version, skippedAt: Date()) + + // Then: 스킵 정보가 유효함 + #expect(repository.isSkipValid(for: version)) + } + + // MARK: - 7일 정책 테스트 + + /// 스킵한 지 3일 이내인 경우 유효한지 확인합니다 + @Test("스킵 유효: 7일 이내 (3일 전)") + func skipValidWithinDuration() { + // Given: 3일 전에 스킵함 + let suiteName = "com.mls.test.skip.valid" + let (repository, userDefaults) = createRepository(suiteName: suiteName) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let version = Version(major: 1, minor: 2, patch: 0) + let threeDaysAgo = Date().addingTimeInterval(-3 * 24 * 60 * 60) + + // When + repository.saveSkipVersion(version, skippedAt: threeDaysAgo) + + // Then: 7일 이내이므로 유효 + #expect(repository.isSkipValid(for: version)) + } + + /// 스킵한 지 8일이 지난 경우 무효한지 확인합니다 + @Test("스킵 무효: 7일 초과 (8일 전)") + func skipInvalidAfterDuration() { + // Given: 8일 전에 스킵함 + let suiteName = "com.mls.test.skip.expired" + let (repository, userDefaults) = createRepository(suiteName: suiteName) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let version = Version(major: 1, minor: 2, patch: 0) + let eightDaysAgo = Date().addingTimeInterval(-8 * 24 * 60 * 60) + + // When + repository.saveSkipVersion(version, skippedAt: eightDaysAgo) + + // Then: 7일을 초과했으므로 무효 + #expect(!repository.isSkipValid(for: version)) + } + + // MARK: - 버전 불일치 테스트 + + /// 스킵한 버전과 다른 버전을 체크하면 무효한지 확인합니다 + @Test("스킵 무효: 버전 불일치") + func skipInvalidForDifferentVersion() { + // Given: v1.2.0을 스킵함 + let suiteName = "com.mls.test.skip.diff" + let (repository, userDefaults) = createRepository(suiteName: suiteName) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let v1 = Version(major: 1, minor: 2, patch: 0) + let v2 = Version(major: 1, minor: 3, patch: 0) + repository.saveSkipVersion(v1, skippedAt: Date()) + + // When/Then: v1.3.0은 스킵되지 않았으므로 무효 + #expect(!repository.isSkipValid(for: v2)) + } + + // MARK: - 스킵 정보 삭제 테스트 + + /// clearSkipInfo 호출 시 스킵 정보가 삭제되는지 확인합니다 + @Test("스킵 정보 삭제") + func clearSkipInfo() { + // Given: 스킵 정보 저장 + let suiteName = "com.mls.test.skip.clear" + let (repository, userDefaults) = createRepository(suiteName: suiteName) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let version = Version(major: 1, minor: 2, patch: 0) + repository.saveSkipVersion(version, skippedAt: Date()) + + // When: 스킵 정보 삭제 + repository.clearSkipInfo() + + // Then: 스킵 정보가 무효화됨 + #expect(!repository.isSkipValid(for: version)) + } + + // MARK: - 영속성 테스트 + + /// UserDefaults에 저장된 데이터가 다른 Repository 인스턴스에서도 조회되는지 확인합니다 + @Test("UserDefaults 영속성") + func persistence() { + // Given: Repository1에서 스킵 정보 저장 + let suiteName = "com.mls.test.skip.persistence" + let (repository, userDefaults) = createRepository(suiteName: suiteName) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let version = Version(major: 2, minor: 5, patch: 1) + repository.saveSkipVersion(version, skippedAt: Date()) + + // When: 새로운 Repository2 생성 (같은 UserDefaults 사용) + let dataSource2 = UserDefaultsDataSource(userDefaults: userDefaults) + let repository2 = UpdateSkipRepository(localDataSource: dataSource2) + + // Then: Repository2에서도 스킵 정보 조회 가능 + #expect(repository2.isSkipValid(for: version)) + } + + // MARK: - 커스텀 기간 테스트 + + /// 스킵 기간을 커스터마이징할 수 있는지 확인합니다 + @Test("커스텀 스킵 기간: 1일") + func customDuration() { + // Given: 스킵 기간을 1일로 설정 + let suiteName = "com.mls.test.skip.custom" + let userDefaults = UserDefaults(suiteName: suiteName)! + userDefaults.removePersistentDomain(forName: suiteName) + userDefaults.synchronize() + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let dataSource = UserDefaultsDataSource(userDefaults: userDefaults) + let customRepo = UpdateSkipRepository(localDataSource: dataSource, skipDuration: 1 * 24 * 60 * 60) + + let version = Version(major: 1, minor: 2, patch: 0) + let twoDaysAgo = Date().addingTimeInterval(-2 * 24 * 60 * 60) + + // When: 2일 전에 스킵함 + customRepo.saveSkipVersion(version, skippedAt: twoDaysAgo) + + // Then: 1일 기준으로는 무효 + #expect(!customRepo.isSkipValid(for: version)) + } +} diff --git a/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/UpdateCheckerUseCaseTests.swift b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/UpdateCheckerUseCaseTests.swift new file mode 100644 index 00000000..9a0e875e --- /dev/null +++ b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/UpdateCheckerUseCaseTests.swift @@ -0,0 +1,177 @@ +import Foundation +import Testing + +@testable import MLSAppFeature +@testable import MLSAppFeatureInterface +@testable import MLSAppFeatureTesting + +/// UpdateChecker Use Case의 업데이트 판단 로직과 스킵 기능을 테스트합니다 +@Suite("UpdateChecker Use Case 테스트") +struct UpdateCheckerUseCaseTests { + let mockAppStoreRepository: MockAppStoreRepository + let mockSkipRepository: MockUpdateSkipRepository + let useCase: UpdateCheckerUseCase + + /// 각 테스트마다 새로운 Mock 객체와 UseCase를 생성합니다 + init() { + mockAppStoreRepository = MockAppStoreRepository() + mockSkipRepository = MockUpdateSkipRepository() + useCase = UpdateCheckerUseCase( + appID: "123456789", + appStoreRepository: mockAppStoreRepository, + skipRepository: mockSkipRepository + ) + } + + // MARK: - 강제 업데이트 테스트 + + /// major 버전이 다를 때 강제 업데이트가 반환되는지 확인합니다 + @Test("강제 업데이트: major 버전 차이") + func forceUpdate() async throws { + // Given: 앱스토어 버전이 2.0.0, 현재 버전이 1.0.0 + mockAppStoreRepository.mockVersion = Version(major: 2, minor: 0, patch: 0) + let currentVersion = Version(major: 1, minor: 0, patch: 0) + + // When: 업데이트 체크 + let status = try await useCase.checkUpdate(currentVersion: currentVersion) + + // Then: 강제 업데이트 상태 + guard case .force(let latest) = status else { + Issue.record("Expected force update status") + return + } + #expect(latest == Version(major: 2, minor: 0, patch: 0)) + } + + // MARK: - 선택 업데이트 테스트 + + /// minor 버전이 다를 때 선택 업데이트가 반환되는지 확인합니다 + @Test("선택 업데이트: minor 버전 차이") + func optionalUpdate() async throws { + // Given: 앱스토어 버전이 1.2.0, 현재 버전이 1.1.0 + mockAppStoreRepository.mockVersion = Version(major: 1, minor: 2, patch: 0) + let currentVersion = Version(major: 1, minor: 1, patch: 0) + + // When: 업데이트 체크 + let status = try await useCase.checkUpdate(currentVersion: currentVersion) + + // Then: 선택 업데이트 상태 + guard case .optional(let latest) = status else { + Issue.record("Expected optional update status") + return + } + #expect(latest == Version(major: 1, minor: 2, patch: 0)) + } + + // MARK: - 업데이트 불필요 테스트 + + /// 현재 버전과 최신 버전이 같을 때 none이 반환되는지 확인합니다 + @Test("업데이트 불필요: 동일한 버전") + func noneUpdate() async throws { + // Given: 앱스토어 버전과 현재 버전이 모두 1.0.0 + mockAppStoreRepository.mockVersion = Version(major: 1, minor: 0, patch: 0) + let currentVersion = Version(major: 1, minor: 0, patch: 0) + + // When: 업데이트 체크 + let status = try await useCase.checkUpdate(currentVersion: currentVersion) + + // Then: 업데이트 불필요 + #expect(status == .none) + } + + // MARK: - 스킵 로직 테스트 + + /// 사용자가 스킵한 버전이 7일 이내인 경우 none이 반환되는지 확인합니다 + @Test("스킵 로직: 유효한 스킵 (7일 이내)") + func skipLogicValid() async throws { + // Given: 최신 버전 1.2.0을 오늘 스킵함 + let latest = Version(major: 1, minor: 2, patch: 0) + mockAppStoreRepository.mockVersion = latest + mockSkipRepository.saveSkipVersion(latest, skippedAt: Date()) + + // When: 업데이트 체크 + let currentVersion = Version(major: 1, minor: 1, patch: 0) + let status = try await useCase.checkUpdate(currentVersion: currentVersion) + + // Then: 스킵이 유효하므로 none 반환 + #expect(status == .none) + } + + /// 스킵한 지 7일이 지난 경우 다시 optional이 반환되는지 확인합니다 + @Test("스킵 로직: 만료된 스킵 (7일 초과)") + func skipLogicExpired() async throws { + // Given: 최신 버전 1.2.0을 8일 전에 스킵함 + let latest = Version(major: 1, minor: 2, patch: 0) + mockAppStoreRepository.mockVersion = latest + let eightDaysAgo = Date().addingTimeInterval(-8 * 24 * 60 * 60) + mockSkipRepository.saveSkipVersion(latest, skippedAt: eightDaysAgo) + + // When: 업데이트 체크 + let currentVersion = Version(major: 1, minor: 1, patch: 0) + let status = try await useCase.checkUpdate(currentVersion: currentVersion) + + // Then: 스킵이 만료되어 optional 반환 + guard case .optional = status else { + Issue.record("Expected optional update status") + return + } + } + + /// 강제 업데이트는 스킵 정보를 무시하고 항상 force를 반환하는지 확인합니다 + @Test("스킵 로직: 강제 업데이트는 스킵 무시") + func forceUpdateIgnoresSkip() async throws { + // Given: 최신 버전 2.0.0을 스킵했지만 major 버전 차이 + let latest = Version(major: 2, minor: 0, patch: 0) + mockAppStoreRepository.mockVersion = latest + mockSkipRepository.saveSkipVersion(latest, skippedAt: Date()) + + // When: 업데이트 체크 + let currentVersion = Version(major: 1, minor: 0, patch: 0) + let status = try await useCase.checkUpdate(currentVersion: currentVersion) + + // Then: 스킵을 무시하고 강제 업데이트 반환 + guard case .force = status else { + Issue.record("Expected force update status") + return + } + } + + // MARK: - 스킵 관리 테스트 + + /// skipUpdate 호출 시 Repository에 저장이 요청되는지 확인합니다 + @Test("스킵 저장 기능") + func skipUpdate() { + // Given + let version = Version(major: 1, minor: 2, patch: 0) + + // When: 버전 스킵 + useCase.skipUpdate(version: version) + + // Then: Repository의 save가 호출됨 + #expect(mockSkipRepository.saveCallCount == 1) + } + + /// clearSkipInfo 호출 시 Repository의 clear가 호출되는지 확인합니다 + @Test("스킵 정보 초기화") + func clearSkipInfo() { + // When: 스킵 정보 초기화 + useCase.clearSkipInfo() + + // Then: Repository의 clear가 호출됨 + #expect(mockSkipRepository.clearCallCount == 1) + } + + // MARK: - 에러 처리 테스트 + + /// 앱스토어 조회 실패 시 에러가 전파되는지 확인합니다 + @Test("에러 처리: 앱스토어 조회 실패") + func errorHandling() async throws { + // Given: 앱스토어 조회 시 에러 발생 + mockAppStoreRepository.mockError = AppStoreError.invalidResponse + + // When/Then: 에러가 전파됨 + await #expect(throws: AppStoreError.self) { + try await useCase.checkUpdate(currentVersion: Version(major: 1, minor: 0, patch: 0)) + } + } +} diff --git a/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/VersionTests.swift b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/VersionTests.swift new file mode 100644 index 00000000..220217d8 --- /dev/null +++ b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/VersionTests.swift @@ -0,0 +1,58 @@ +import Testing + +@testable import MLSAppFeatureInterface + +/// Version 엔티티의 생성, 비교, 파싱 기능을 테스트합니다 +@Suite("Version 엔티티 테스트") +struct VersionTests { + + // MARK: - 초기화 테스트 + + /// 버전을 major, minor, patch로 초기화하고 속성이 올바른지 확인합니다 + @Test("버전 초기화 및 문자열 변환") + func versionInitialization() { + let version = Version(major: 1, minor: 2, patch: 3) + + #expect(version.major == 1) + #expect(version.minor == 2) + #expect(version.patch == 3) + #expect(version.versionString == "1.2.3") + } + + /// 버전 문자열로부터 Version 객체를 생성하고 파싱이 올바른지 확인합니다 + @Test("버전 문자열 파싱") + func versionFromString() { + // 정상 케이스: "1.2.3" + #expect(Version(versionString: "1.2.3") == Version(major: 1, minor: 2, patch: 3)) + + // patch가 없는 경우: "1.2" → patch는 0으로 처리 + #expect(Version(versionString: "1.2") == Version(major: 1, minor: 2, patch: 0)) + + // 잘못된 형식 + #expect(Version(versionString: "invalid") == nil) + #expect(Version(versionString: "1") == nil) + } + + // MARK: - 비교 테스트 + + /// 버전 간 비교 연산자가 올바르게 동작하는지 확인합니다 + @Test("버전 비교 연산") + func versionComparison() { + let v1 = Version(major: 1, minor: 0, patch: 0) + let v2 = Version(major: 2, minor: 0, patch: 0) + let v3 = Version(major: 1, minor: 2, patch: 0) + let v4 = Version(major: 1, minor: 2, patch: 3) + + // major 버전 비교 + #expect(v1 < v2) + + // minor 버전 비교 + #expect(v1 < v3) + + // patch 버전 비교 + #expect(v3 < v4) + + // 동등 비교 + #expect(v1 == Version(major: 1, minor: 0, patch: 0)) + } +} diff --git a/MLS/MLSAuthFeature/.gitignore b/MLS/MLSAuthFeature/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/MLS/MLSAuthFeature/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/MLS/MLSAuthFeature/Package.swift b/MLS/MLSAuthFeature/Package.swift new file mode 100644 index 00000000..1e1db184 --- /dev/null +++ b/MLS/MLSAuthFeature/Package.swift @@ -0,0 +1,83 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "MLSAuthFeature", + platforms: [.iOS(.v15)], + products: [ + // Interface: Presentation 팩토리 프로토콜 + .library( + name: "MLSAuthFeatureInterface", + targets: ["MLSAuthFeatureInterface"] + ), + // Feature: Presentation + Domain + Data 구현체 + .library( + name: "MLSAuthFeature", + targets: ["MLSAuthFeature"] + ), + // Testing: 단위 테스트나 Example 앱에서 사용될 Mock 데이터를 제공하는 모듈 + .library( + name: "MLSAuthFeatureTesting", + targets: ["MLSAuthFeatureTesting"] + ) + ], + dependencies: [ + .package(path: "../MLSCore"), + .package(path: "../MLSDesignSystem"), + .package(url: "https://github.com/ReactorKit/ReactorKit.git", from: "3.2.0"), + .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.22.0"), + .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.7.0"), + .package(url: "https://github.com/RxSwiftCommunity/RxKeyboard.git", from: "2.0.0"), + .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1") + ], + targets: [ + // Interface 모듈 (Presentation 팩토리 프로토콜) + .target( + name: "MLSAuthFeatureInterface", + dependencies: [ + .product(name: "MLSCore", package: "MLSCore"), + .product(name: "MLSDesignSystem", package: "MLSDesignSystem"), + .product(name: "RxSwift", package: "RxSwift") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + // Feature 모듈 (Presentation + Domain + Data 구현체) + .target( + name: "MLSAuthFeature", + dependencies: [ + "MLSAuthFeatureInterface", + .product(name: "MLSCore", package: "MLSCore"), + .product(name: "MLSDesignSystem", package: "MLSDesignSystem"), + .product(name: "ReactorKit", package: "ReactorKit"), + .product(name: "RxSwift", package: "RxSwift"), + .product(name: "RxCocoa", package: "RxSwift"), + .product(name: "RxRelay", package: "RxSwift"), + .product(name: "RxKeyboard", package: "RxKeyboard"), + .product(name: "KakaoSDKAuth", package: "kakao-ios-sdk"), + .product(name: "KakaoSDKUser", package: "kakao-ios-sdk"), + .product(name: "SnapKit", package: "SnapKit") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + // Testing 모듈 (Mock 객체) + .target( + name: "MLSAuthFeatureTesting", + dependencies: [ + "MLSAuthFeatureInterface", + .product(name: "RxSwift", package: "RxSwift") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + // Tests 모듈 + .testTarget( + name: "MLSAuthFeatureTests", + dependencies: [ + "MLSAuthFeature", + "MLSAuthFeatureInterface", + "MLSAuthFeatureTesting", + .product(name: "RxBlocking", package: "RxSwift") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ) + ] +) diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/DTOs/AuthResponseDTO.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/DTOs/AuthResponseDTO.swift new file mode 100644 index 00000000..5079deba --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/DTOs/AuthResponseDTO.swift @@ -0,0 +1,24 @@ +import MLSAuthFeatureInterface + +public struct AuthResponseDTO: Decodable { + public let accessToken: String + public let refreshToken: String + public let member: MemberDTO? +} + +public extension AuthResponseDTO { + func toLoginDomain() -> LoginResponse { + return .init( + isRegister: member != nil, + accessToken: accessToken, + refreshToken: refreshToken + ) + } + + func toSignUpDomain() -> SignUpResponse { + return .init( + accessToken: accessToken, + refreshToken: refreshToken + ) + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/DTOs/JobsDTO.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/DTOs/JobsDTO.swift new file mode 100644 index 00000000..42847b79 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/DTOs/JobsDTO.swift @@ -0,0 +1,23 @@ +import MLSAuthFeatureInterface + +public struct JobsDTO: Decodable { + public let jobId: Int + public let jobName: String + public let jobLevel: Int + public let parentJobId: Int? +} + +public extension JobsDTO { + func toDomain() -> Job { + return Job(name: jobName, id: jobId) + } +} + +public extension Array where Element == JobsDTO { + func toDomain() -> JobListResponse { + let jobs = self + .filter { $0.jobLevel == 0 } + .map { Job(name: $0.jobName, id: $0.jobId) } + return JobListResponse(jobList: jobs) + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/DTOs/MemberDTO.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/DTOs/MemberDTO.swift new file mode 100644 index 00000000..f7f39224 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/DTOs/MemberDTO.swift @@ -0,0 +1,13 @@ +public struct MemberDTO: Decodable { + public let id: String + public let provider: String + public let nickname: String + public let fcmToken: String? + public let marketingAgreement: Bool? + public let noticeAgreement: Bool? + public let patchNoteAgreement: Bool? + public let eventAgreement: Bool? + public let jobId: Int? + public let level: Int? + public let profileImageUrl: String +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/Endpoints/AuthEndPoint.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/Endpoints/AuthEndPoint.swift new file mode 100644 index 00000000..1fb0051f --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/Endpoints/AuthEndPoint.swift @@ -0,0 +1,143 @@ +import MLSAuthFeatureInterface +import MLSCore + +public enum AuthEndPoint { + static let base = "https://mapleland.2megabytes.me" + + public static func fetchProfile() -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/auth/me", + method: .GET + ) + } + + public static func loginWithKakao(credential: Credential) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/auth/login/kakao", + method: .POST, + headers: ["access-token": credential.token] + ) + } + + public static func loginWithApple(credential: Credential) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/auth/login/apple", + method: .POST, + headers: ["id-token": credential.token] + ) + } + + public static func signupWithKakao(credential: String, body: Encodable) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/auth/signup/kakao", + method: .POST, + headers: ["access-token": credential], + body: body + ) + } + + public static func signupWithApple(credential: String, body: Encodable) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/auth/signup/apple", + method: .POST, + headers: ["id-token": credential], + body: body + ) + } + + public static func reIssueToken(refreshToken: String) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/auth/reissue", + method: .POST, + headers: [ + "accept": "*/*", + "refresh-token": refreshToken + ] + ) + } + + public static func fcmToken(body: Encodable) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/auth/member/fcm-token", + method: .PUT, + body: body + ) + } + + public static func withdraw() -> EndPoint { + .init( + baseURL: base, + path: "/api/v1/auth/member", + method: .DELETE + ) + } + + public static func updateMarketingAgreement(credential: String, body: Encodable) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/auth/member/marketing-agreement", + method: .PUT, + headers: ["Authorization": "Bearer \(credential)"], + body: body + ) + } + + public static func fetchJobs() -> ResponsableEndPoint<[JobsDTO]> { + .init( + baseURL: base, + path: "/api/v1/jobs", + method: .GET + ) + } + + public static func fetchJob(jobId: String) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/jobs/\(jobId)", + method: .GET + ) + } + + public static func updateCharacterInfo(body: Encodable) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/auth/member/profile", + method: .PUT, + body: body + ) + } + + public static func updateNotification(body: Encodable) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/auth/member/alert-agreement", + method: .PUT, + body: body + ) + } + + public static func updateNickName(body: Encodable) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/auth/member/nickname", + method: .PUT, + body: body + ) + } + + public static func updateProfileImage(body: Encodable) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/auth/member/profile-image", + method: .PUT, + body: body + ) + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/Providers/AppleCredentialProvider.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/Providers/AppleCredentialProvider.swift new file mode 100644 index 00000000..9ea26fdf --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/Providers/AppleCredentialProvider.swift @@ -0,0 +1,60 @@ +import AuthenticationServices +import UIKit + +import MLSAuthFeatureInterface + +import RxSwift + +public final class AppleCredentialProvider: NSObject, SocialCredentialProvider { + override public init() {} + + private var authServiceResponse = PublishSubject() + + public func getCredential() -> Observable { + let subject = PublishSubject() + authServiceResponse = subject + performRequest() + return subject + } + + private func performRequest() { + let provider = ASAuthorizationAppleIDProvider() + let request = provider.createRequest() + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } +} + +extension AppleCredentialProvider: ASAuthorizationControllerPresentationContextProviding, ASAuthorizationControllerDelegate { + public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + let scenes = UIApplication.shared.connectedScenes + let windowScene = scenes.first as? UIWindowScene + return windowScene?.windows.first ?? UIWindow() + } + + public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else { + authServiceResponse.onError(AuthError.unknown(message: "Invalid Apple credential")) + return + } + + guard let idTokenData = appleIDCredential.identityToken, + let idToken = String(data: idTokenData, encoding: .utf8), + let codeData = appleIDCredential.authorizationCode, + let authCode = String(data: codeData, encoding: .utf8) + else { + authServiceResponse.onError(AuthError.unknown(message: "Failed to parse Apple token or code")) + return + } + + let credential = Credential(token: idToken, providerID: authCode) + authServiceResponse.onNext(credential) + authServiceResponse.onCompleted() + } + + public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + authServiceResponse.onError(error) + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/Providers/KakaoCredentialProvider.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/Providers/KakaoCredentialProvider.swift new file mode 100644 index 00000000..b877ce4d --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/Providers/KakaoCredentialProvider.swift @@ -0,0 +1,57 @@ +import Foundation + +import MLSAuthFeatureInterface + +import KakaoSDKAuth +import KakaoSDKUser +import RxSwift + +public final class KakaoCredentialProvider: SocialCredentialProvider, @unchecked Sendable { + public init() {} + + public func getCredential() -> Observable { + return Observable.create { [weak self] observer in + let disposable = Disposables.create() + + let handleLogin: (OAuthToken?, Error?) -> Void = { oauthToken, error in + self?.fetchEmailAfterDelay(oauthToken: oauthToken, error: error, observer: observer) + } + + DispatchQueue.main.async { + if UserApi.isKakaoTalkLoginAvailable() { + UserApi.shared.loginWithKakaoTalk(completion: handleLogin) + } else { + UserApi.shared.loginWithKakaoAccount(completion: handleLogin) + } + } + + return disposable + } + } + + private func fetchEmailAfterDelay(oauthToken: OAuthToken?, error: Error?, observer: AnyObserver) { + if let error { + observer.onError(error) + return + } + + guard let accessToken = oauthToken?.accessToken else { + observer.onError(AuthError.unknown(message: "토큰이 없어요")) + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + UserApi.shared.me { user, error in + if let error { + observer.onError(error) + return + } + + let id = user?.id ?? 0 + let credential = Credential(token: accessToken, providerID: String(id)) + observer.onNext(credential) + observer.onCompleted() + } + } + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/Repositories/AuthAPIRepositoryImpl.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/Repositories/AuthAPIRepositoryImpl.swift new file mode 100644 index 00000000..a567d2ed --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Data/Repositories/AuthAPIRepositoryImpl.swift @@ -0,0 +1,137 @@ +import Foundation + +import MLSAuthFeatureInterface +import MLSCore + +import RxSwift + +public class AuthAPIRepositoryImpl: AuthAPIRepository { + private let provider: NetworkProvider + private let tokenInterceptor: Interceptor + private let authInterceptor: Interceptor + + public init(provider: NetworkProvider, tokenInterceptor: Interceptor, authInterceptor: Interceptor) { + self.provider = provider + self.tokenInterceptor = tokenInterceptor + self.authInterceptor = authInterceptor + } + + public func loginWithKakao(credential: Credential) -> Observable { + let endpoint = AuthEndPoint.loginWithKakao(credential: credential) + return provider.requestData(endPoint: endpoint, interceptor: authInterceptor) + .map { $0.toLoginDomain() } + .catch { error in + if case NetworkError.statusError(let code, _) = error, code == 404 { + return Observable.error(AuthError.userNotFound(credential: credential)) + } else { + return Observable.error(error) + } + } + } + + public func loginWithApple(credential: Credential) -> Observable { + let endpoint = AuthEndPoint.loginWithApple(credential: credential) + return provider.requestData(endPoint: endpoint, interceptor: authInterceptor) + .map { $0.toLoginDomain() } + .catch { error in + if case NetworkError.statusError(let code, _) = error, code == 404 { + return Observable.error(AuthError.userNotFound(credential: credential)) + } else { + return Observable.error(error) + } + } + } + + public func signUpWithKakao(credential: Credential, isMarketingAgreement: Bool, fcmToken: String?) -> Observable { + let endpoint = AuthEndPoint.signupWithKakao( + credential: credential.token, + body: KakaoBody( + providerId: credential.providerID, + fcmToken: fcmToken, + marketingAgreement: isMarketingAgreement + ) + ) + return provider.requestData(endPoint: endpoint, interceptor: nil).map { $0.toSignUpDomain() } + } + + public func signUpWithApple(credential: Credential, isMarketingAgreement: Bool, fcmToken: String?) -> Observable { + let endpoint = AuthEndPoint.signupWithApple( + credential: credential.token, + body: AppleBody( + providerId: credential.providerID, + fcmToken: fcmToken, + marketingAgreement: isMarketingAgreement + ) + ) + return provider.requestData(endPoint: endpoint, interceptor: nil).map { $0.toSignUpDomain() } + } + + public func withdraw() -> Completable { + let endPoint = AuthEndPoint.withdraw() + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) + } + + public func reissueToken(refreshToken: String) -> Observable { + let endPoint = AuthEndPoint.reIssueToken(refreshToken: refreshToken) + return provider.requestData(endPoint: endPoint, interceptor: nil).map { $0.toLoginDomain() } + } + + public func fcmToken(fcmToken: String?) -> Completable { + let endPoint = AuthEndPoint.fcmToken(body: FCMTokenBody(fcmToken: fcmToken)) + return provider.requestData(endPoint: endPoint, interceptor: authInterceptor) + } + + public func fetchJobList() -> Observable { + let endPoint = AuthEndPoint.fetchJobs() + return provider.requestData(endPoint: endPoint, interceptor: nil).map { $0.toDomain() } + } + + public func updateUserInfo(level: Int, selectedJobID: Int) -> Completable { + let endPoint = AuthEndPoint.updateCharacterInfo(body: UpdateInfoBody(level: level, jobId: selectedJobID)) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) + } + + public func updateNotificationAgreement(noticeAgreement: Bool, patchNoteAgreement: Bool, eventAgreement: Bool) -> Completable { + let endPoint = AuthEndPoint.updateNotification( + body: NotificationAgreementBody( + noticeAgreement: noticeAgreement, + patchNoteAgreement: patchNoteAgreement, + eventAgreement: eventAgreement + ) + ) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) + } +} + +private extension AuthAPIRepositoryImpl { + struct KakaoBody: Encodable { + let provider = "KAKAO" + let providerId: String + let nickname: String? = nil + let fcmToken: String? + let marketingAgreement: Bool + } + + struct AppleBody: Encodable { + let provider = "APPLE" + let providerId: String + let nickname: String? = nil + let fcmToken: String? + let marketingAgreement: Bool + } + + struct FCMTokenBody: Encodable { + let fcmToken: String? + } + + struct NotificationAgreementBody: Encodable { + let noticeAgreement: Bool + let patchNoteAgreement: Bool + let eventAgreement: Bool + } + + struct UpdateInfoBody: Encodable { + let level: Int + let jobId: Int + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Domain/UseCases/CheckEmptyLevelAndRoleUseCaseImpl.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Domain/UseCases/CheckEmptyLevelAndRoleUseCaseImpl.swift new file mode 100644 index 00000000..2cfc7b7b --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Domain/UseCases/CheckEmptyLevelAndRoleUseCaseImpl.swift @@ -0,0 +1,11 @@ +import MLSAuthFeatureInterface + +public class CheckEmptyLevelAndRoleUseCaseImpl: CheckEmptyLevelAndRoleUseCase { + public init() {} + + public func execute(level: Int?, job: String?) -> Bool { + let isValidLevel = level.map { (1 ... 200).contains($0) } ?? false + let isValidRole = job != nil && job != "" + return isValidLevel && isValidRole + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Domain/UseCases/CheckValidLevelUseCaseImpl.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Domain/UseCases/CheckValidLevelUseCaseImpl.swift new file mode 100644 index 00000000..0a8c7471 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Domain/UseCases/CheckValidLevelUseCaseImpl.swift @@ -0,0 +1,10 @@ +import MLSAuthFeatureInterface + +public class CheckValidLevelUseCaseImpl: CheckValidLevelUseCase { + public init() {} + + public func execute(level: Int?) -> Bool? { + guard let level else { return nil } + return (1 ... 200).contains(level) + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Domain/UseCases/SocialLoginUseCaseImpl.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Domain/UseCases/SocialLoginUseCaseImpl.swift new file mode 100644 index 00000000..a618f57c --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Domain/UseCases/SocialLoginUseCaseImpl.swift @@ -0,0 +1,59 @@ +import MLSAuthFeatureInterface + +import RxSwift + +public final class SocialLoginUseCaseImpl: SocialLoginUseCase { + private let authRepository: AuthAPIRepository + private let tokenRepository: TokenRepository + private let userDefaultsRepository: UserDefaultsRepository + + public init( + authRepository: AuthAPIRepository, + tokenRepository: TokenRepository, + userDefaultsRepository: UserDefaultsRepository + ) { + self.authRepository = authRepository + self.tokenRepository = tokenRepository + self.userDefaultsRepository = userDefaultsRepository + } + + public func execute(credential: Credential, platform: LoginPlatform) -> Observable { + let loginObservable: Observable + switch platform { + case .apple: + loginObservable = authRepository.loginWithApple(credential: credential) + case .kakao: + loginObservable = authRepository.loginWithKakao(credential: credential) + } + + return loginObservable + .flatMap { response -> Observable in + let saveAccess = self.tokenRepository.saveToken(type: .accessToken, value: response.accessToken) + let saveRefresh = self.tokenRepository.saveToken(type: .refreshToken, value: response.refreshToken) + let savePlatform = self.userDefaultsRepository.savePlatform(platform: platform) + + guard case (.success, .success) = (saveAccess, saveRefresh) else { + return Observable.error(TokenRepositoryError.dataConversionError(message: "Failed to save tokens")) + } + + var fcmToken: String? + if case .success(let token) = self.tokenRepository.fetchToken(type: .fcmToken) { + fcmToken = token + } + + let fcmUpdate = if let fcmToken { + self.authRepository.fcmToken(fcmToken: fcmToken) + .catch { error in + print("FCM token update failed: \(error)") + return .empty() + } + } else { + Completable.empty() + } + return fcmUpdate.andThen(savePlatform).andThen(Observable.just(response)) + } + .catch { error in + Observable.error(error) + } + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Domain/UseCases/SocialSignUpUseCaseImpl.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Domain/UseCases/SocialSignUpUseCaseImpl.swift new file mode 100644 index 00000000..78eadfaa --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Domain/UseCases/SocialSignUpUseCaseImpl.swift @@ -0,0 +1,46 @@ +import MLSAuthFeatureInterface + +import RxSwift + +public final class SocialSignUpUseCaseImpl: SocialSignUpUseCase { + private let authRepository: AuthAPIRepository + private let tokenRepository: TokenRepository + private let userDefaultsRepository: UserDefaultsRepository + + public init( + authRepository: AuthAPIRepository, + tokenRepository: TokenRepository, + userDefaultsRepository: UserDefaultsRepository + ) { + self.authRepository = authRepository + self.tokenRepository = tokenRepository + self.userDefaultsRepository = userDefaultsRepository + } + + public func execute(credential: Credential, platform: LoginPlatform, isMarketingAgreement: Bool, fcmToken: String?) -> Observable { + let signUpObservable: Observable + switch platform { + case .apple: + signUpObservable = authRepository.signUpWithApple(credential: credential, isMarketingAgreement: isMarketingAgreement, fcmToken: fcmToken) + case .kakao: + signUpObservable = authRepository.signUpWithKakao(credential: credential, isMarketingAgreement: isMarketingAgreement, fcmToken: fcmToken) + } + + return signUpObservable + .flatMap { response -> Observable in + let saveAccess = self.tokenRepository.saveToken(type: .accessToken, value: response.accessToken) + let saveRefresh = self.tokenRepository.saveToken(type: .refreshToken, value: response.refreshToken) + let savePlatform = self.userDefaultsRepository.savePlatform(platform: platform) + + switch (saveAccess, saveRefresh) { + case (.success, .success): + return savePlatform.andThen(Observable.just(response)) + default: + return Observable.error(TokenRepositoryError.dataConversionError(message: "Failed to save tokens")) + } + } + .catch { error in + Observable.error(error) + } + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/Login/LoginFactoryImpl.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/Login/LoginFactoryImpl.swift new file mode 100644 index 00000000..29d87721 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/Login/LoginFactoryImpl.swift @@ -0,0 +1,52 @@ +import MLSAuthFeatureInterface +import MLSCore + +import RxSwift + +public struct LoginFactoryImpl: LoginFactory { + private let termsAgreementsFactory: TermsAgreementFactory + private let appleProvider: SocialCredentialProvider + private let kakaoProvider: SocialCredentialProvider + private let socialLoginUseCase: SocialLoginUseCase + private let userDefaultsRepository: UserDefaultsRepository + + public init( + termsAgreementsFactory: TermsAgreementFactory, + appleProvider: SocialCredentialProvider, + kakaoProvider: SocialCredentialProvider, + socialLoginUseCase: SocialLoginUseCase, + userDefaultsRepository: UserDefaultsRepository + ) { + self.termsAgreementsFactory = termsAgreementsFactory + self.appleProvider = appleProvider + self.kakaoProvider = kakaoProvider + self.socialLoginUseCase = socialLoginUseCase + self.userDefaultsRepository = userDefaultsRepository + } + + public func make(exitRoute: LoginExitRoute, onLoginCompleted: (() -> Void)?) -> BaseViewController { + let viewController = LoginViewController(termsAgreementsFactory: termsAgreementsFactory) + viewController.isBottomTabbarHidden = true + + viewController.reactor = LoginReactor( + appleProvider: appleProvider, + kakaoProvider: kakaoProvider, + socialLoginUseCase: socialLoginUseCase, + userDefaultsRepository: userDefaultsRepository + ) + + viewController.routeToHome + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak viewController] in + switch exitRoute { + case .home: + onLoginCompleted?() + case .pop: + viewController?.navigationController?.popViewController(animated: true) + } + }) + .disposed(by: viewController.disposeBag) + + return viewController + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/Login/LoginReactor.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/Login/LoginReactor.swift new file mode 100644 index 00000000..3da6bf59 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/Login/LoginReactor.swift @@ -0,0 +1,107 @@ +import MLSAuthFeatureInterface + +import ReactorKit +import RxSwift + +public final class LoginReactor: Reactor { + public enum Route { + case none + case termsAgreements(credential: Credential, platform: LoginPlatform) + case error + case home + case dismiss + } + + // MARK: - Reactor + public enum Action { + case viewWillAppear + case kakaoLoginButtonTapped + case appleLoginButtonTapped + case guestLoginButtonTapped + case backButtonTapped + } + + public enum Mutation { + case navigateTo(route: Route) + case setRelogin(LoginPlatform?) + } + + public struct State { + @Pulse var route: Route = .none + var platform: LoginPlatform? + } + + // MARK: - properties + public var initialState: State + var disposeBag = DisposeBag() + private let appleProvider: SocialCredentialProvider + private let kakaoProvider: SocialCredentialProvider + private let socialLoginUseCase: SocialLoginUseCase + private let userDefaultsRepository: UserDefaultsRepository + + // MARK: - init + public init( + appleProvider: SocialCredentialProvider, + kakaoProvider: SocialCredentialProvider, + socialLoginUseCase: SocialLoginUseCase, + userDefaultsRepository: UserDefaultsRepository + ) { + self.appleProvider = appleProvider + self.kakaoProvider = kakaoProvider + self.socialLoginUseCase = socialLoginUseCase + self.userDefaultsRepository = userDefaultsRepository + self.initialState = State() + } + + // MARK: - Reactor Methods + public func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear: + return userDefaultsRepository.fetchPlatform() + .map { Mutation.setRelogin($0) } + case .kakaoLoginButtonTapped: + return handleLogin(provider: kakaoProvider, platform: .kakao) + case .appleLoginButtonTapped: + return handleLogin(provider: appleProvider, platform: .apple) + case .guestLoginButtonTapped: + return .just(.navigateTo(route: .home)) + case .backButtonTapped: + return .just(.navigateTo(route: .dismiss)) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case .navigateTo(let route): + newState.route = route + case .setRelogin(let platform): + newState.platform = platform + } + return newState + } +} + +// MARK: - Methods +private extension LoginReactor { + func handleLogin(provider: SocialCredentialProvider, platform: LoginPlatform) -> Observable { + return provider.getCredential() + .flatMap { [weak self] credential -> Observable in + guard let self else { return .empty() } + return self.socialLoginUseCase.execute(credential: credential, platform: platform) + .map { response -> Mutation in + if response.isRegister { + return .navigateTo(route: .home) + } else { + return .navigateTo(route: .termsAgreements(credential: credential, platform: platform)) + } + } + .catch { error in + if case AuthError.userNotFound = error { + return .just(.navigateTo(route: .termsAgreements(credential: credential, platform: platform))) + } + return .just(.navigateTo(route: .error)) + } + } + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/Login/LoginView.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/Login/LoginView.swift new file mode 100644 index 00000000..a9d9ce83 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/Login/LoginView.swift @@ -0,0 +1,238 @@ +import UIKit + +import MLSAuthFeatureInterface +import MLSDesignSystem + +import SnapKit + +final class LoginView: UIView { + // MARK: - Type + private enum Constant { + static let buttonLogoImageSize: CGFloat = 18 + static let buttonLogoImageLeadingInset: CGFloat = 14 + static let buttonHeight: CGFloat = 44 + static let buttonCornerRadius: CGFloat = 8 + static let buttonSpacing: CGFloat = 8 + static let buttonStackViewBottomInset: CGFloat = 16 + static let horizontalInset: CGFloat = 16 + static let buttonCenterXInset: CGFloat = buttonLogoImageLeadingInset + buttonLogoImageSize + static let labelHeight: CGFloat = 28 + static let subTitleBottomSpacing: CGFloat = -25 + static let recentLogoWidth: CGFloat = 82 + static let recentLogoHeight: CGFloat = 30 + static let recentLogoInset: CGFloat = 8 + } + + // MARK: - Properties + public let header = NavigationBar(type: .arrowLeft) + + private let loginImageView: UIImageView = { + let image = DesignSystemAsset.image(named: "Login_KV_img") + let view = UIImageView(image: image) + return view + }() + + private let buttonStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.spacing = Constant.buttonSpacing + return view + }() + + let kakaoLoginButton: UIButton = { + let button = UIButton() + button.backgroundColor = .init(hexCode: "#FEE500", alpha: 1) + button.layer.cornerRadius = Constant.buttonCornerRadius + return button + }() + + private let kakaoLogoImageView: UIImageView = { + let image = DesignSystemAsset.image(named: "kakaoLogo") + let view = UIImageView(image: image) + view.contentMode = .scaleAspectFit + return view + }() + + private let kakaoLoginLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .korFont(style: .semiBold, size: 15), text: "카카오로 계속하기", color: .init(hexCode: "#000000", alpha: 0.85)) + return label + }() + + let appleLoginButton: UIButton = { + let button = UIButton() + button.backgroundColor = .init(hexCode: "#000000", alpha: 1) + button.layer.cornerRadius = Constant.buttonCornerRadius + return button + }() + + private let appleLogoImageView: UIImageView = { + let image = DesignSystemAsset.image(named: "appleLogo") + let view = UIImageView(image: image) + view.contentMode = .scaleAspectFit + return view + }() + + let appleLoginLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .korFont(style: .semiBold, size: 15), text: "Apple로 계속하기", color: .init(hexCode: "#FFFFFF")) + return label + }() + + let guestLoginButton: CommonButton = { + let button = CommonButton(style: .text, title: "가입 없이 둘러보기", disabledTitle: "가입 없이 둘러보기") + return button + }() + + private let mainTitleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .h_xl_b, text: "모험가님,") + return label + }() + + private let subTitleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .h_xl_r, text: "다시 오신 걸 환영해요!") + return label + }() + + private let recentLoginImageView: UIImageView = { + let view = UIImageView(image: DesignSystemAsset.image(named: "recentLoginLogo")) + view.isHidden = true + return view + }() + + // MARK: - init + init() { + super.init(frame: .zero) + + addViews() + setupConstraints() + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension LoginView { + func addViews() { + addSubview(loginImageView) + addSubview(buttonStackView) + addSubview(recentLoginImageView) + + buttonStackView.addArrangedSubview(kakaoLoginButton) + buttonStackView.addArrangedSubview(appleLoginButton) + + kakaoLoginButton.addSubview(kakaoLogoImageView) + kakaoLoginButton.addSubview(kakaoLoginLabel) + appleLoginButton.addSubview(appleLogoImageView) + appleLoginButton.addSubview(appleLoginLabel) + + addSubview(header) + } + + func setupConstraints() { + header.snp.makeConstraints { make in + make.top.horizontalEdges.equalTo(safeAreaLayoutGuide) + } + + loginImageView.snp.makeConstraints { make in + make.width.equalToSuperview() + make.height.equalTo(UIScreen.main.bounds.width * 1.49) + make.top.horizontalEdges.equalToSuperview() + } + + buttonStackView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.bottom.equalToSuperview().inset(Constant.buttonStackViewBottomInset) + } + + kakaoLoginButton.snp.makeConstraints { make in + make.height.equalTo(Constant.buttonHeight) + } + + kakaoLogoImageView.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalToSuperview().inset(Constant.buttonLogoImageLeadingInset) + make.size.equalTo(Constant.buttonLogoImageSize) + } + + kakaoLoginLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.centerX.equalToSuperview().inset(Constant.buttonCenterXInset) + } + + appleLoginButton.snp.makeConstraints { make in + make.height.equalTo(Constant.buttonHeight) + } + + appleLogoImageView.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalToSuperview().inset(Constant.buttonLogoImageLeadingInset) + make.size.equalTo(Constant.buttonLogoImageSize) + } + + appleLoginLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.centerX.equalToSuperview().inset(Constant.buttonCenterXInset) + } + } + + func configureUI() {} +} + +extension LoginView { + func update(loginPlatform: LoginPlatform?) { + mainTitleLabel.removeFromSuperview() + subTitleLabel.removeFromSuperview() + guestLoginButton.removeFromSuperview() + + switch loginPlatform { + case .kakao, .apple: + // 최근로그인 라벨 추가 + addSubview(mainTitleLabel) + addSubview(subTitleLabel) + + subTitleLabel.snp.remakeConstraints { make in + make.bottom.equalTo(buttonStackView.snp.top).offset(Constant.subTitleBottomSpacing) + make.centerX.equalToSuperview() + make.height.equalTo(Constant.labelHeight) + } + mainTitleLabel.snp.remakeConstraints { make in + make.bottom.equalTo(subTitleLabel.snp.top) + make.centerX.equalToSuperview() + make.height.equalTo(Constant.labelHeight) + } + + recentLoginImageView.isHidden = false + + recentLoginImageView.snp.remakeConstraints { make in + switch loginPlatform { + case .apple: + make.leading.equalTo(appleLoginButton).offset(Constant.recentLogoInset) + make.bottom.equalTo(appleLoginButton.snp.top).offset(Constant.recentLogoInset) + case .kakao: + make.leading.equalTo(kakaoLoginButton).offset(Constant.recentLogoInset) + make.bottom.equalTo(kakaoLoginButton.snp.top).offset(Constant.recentLogoInset) + default: + break + } + make.width.equalTo(Constant.recentLogoWidth) + make.height.equalTo(Constant.recentLogoHeight) + } + case nil: + buttonStackView.addArrangedSubview(guestLoginButton) + guestLoginButton.snp.remakeConstraints { make in + make.height.equalTo(Constant.buttonHeight) + } + recentLoginImageView.isHidden = true + } + + setNeedsLayout() + layoutIfNeeded() + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/Login/LoginViewController.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/Login/LoginViewController.swift new file mode 100644 index 00000000..183550ed --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/Login/LoginViewController.swift @@ -0,0 +1,169 @@ +import UIKit + +import MLSAuthFeatureInterface +import MLSCore +import MLSDesignSystem + +import ReactorKit +import RxCocoa +import RxSwift +import SnapKit + +public final class LoginViewController: BaseViewController, @preconcurrency View { + public typealias Reactor = LoginReactor + + // MARK: - Properties + public var disposeBag = DisposeBag() + + public let routeToHome = PublishRelay() + + private let mainView: LoginView + + private let termsAgreementsFactory: TermsAgreementFactory + + public init(termsAgreementsFactory: TermsAgreementFactory) { + self.mainView = LoginView() + self.termsAgreementsFactory = termsAgreementsFactory + super.init() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +public extension LoginViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension LoginViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide) + } + } + + func configureUI() { + view.backgroundColor = .systemBackground + + if let navigationController = navigationController, + navigationController.viewControllers.count > 1 { + mainView.header.leftButton.isHidden = false + } else { + mainView.header.leftButton.isHidden = true + } + } +} + +public extension LoginViewController { + func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) { + rx.viewWillAppear + .map { _ in Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.kakaoLoginButton.rx.tap + .map { Reactor.Action.kakaoLoginButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.kakaoLoginButton.rx.controlEvent(.touchDown) + .withUnretained(self) + .subscribe { owner, _ in + owner.mainView.kakaoLoginButton.backgroundColor = .init(hexCode: "#E5CE00") + } + .disposed(by: disposeBag) + + mainView.kakaoLoginButton.rx.controlEvent([.touchUpInside, .touchUpOutside, .touchCancel]) + .withUnretained(self) + .subscribe { owner, _ in + owner.mainView.kakaoLoginButton.backgroundColor = .init(hexCode: "#FEE500") + } + .disposed(by: disposeBag) + + mainView.appleLoginButton.rx.tap + .map { Reactor.Action.appleLoginButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.appleLoginButton.rx.controlEvent(.touchDown) + .withUnretained(self) + .subscribe { owner, _ in + owner.mainView.appleLoginLabel.textColor = .init(hexCode: "#E5E5E5") + } + .disposed(by: disposeBag) + + mainView.appleLoginButton.rx.controlEvent([.touchUpInside, .touchUpOutside, .touchCancel]) + .withUnretained(self) + .subscribe { owner, _ in + owner.mainView.appleLoginLabel.textColor = .whiteMLS + } + .disposed(by: disposeBag) + + mainView.guestLoginButton.rx.tap + .map { Reactor.Action.guestLoginButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.header.leftButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + func bindViewState(reactor: Reactor) { + reactor.state + .observe(on: MainScheduler.instance) + .map { $0.platform } + .distinctUntilChanged() + .withUnretained(self) + .subscribe { owner, platform in + owner.mainView.update(loginPlatform: platform) + } + .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .observe(on: MainScheduler.instance) + .withUnretained(self) + .subscribe { owner, route in + switch route { + case .termsAgreements(let credential, let platform): + let controller = owner.termsAgreementsFactory.make(credential: credential, platform: platform) + owner.navigationController?.pushViewController(controller, animated: true) + case .home: + owner.routeToHome.accept(()) + case .error: + DispatchQueue.main.async { + let controller = BaseErrorViewController() + owner.present(controller, animated: true) + } + case .dismiss: + owner.navigationController?.popViewController(animated: true) + default: + break + } + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoarding/OnBoardingBaseView.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoarding/OnBoardingBaseView.swift new file mode 100644 index 00000000..d375e951 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoarding/OnBoardingBaseView.swift @@ -0,0 +1,44 @@ +import UIKit + +import MLSDesignSystem + +/// 온보딩 화면에서 사용하기 위한 헤더(타이틀 버튼 포함)를 가진 뷰 +public class OnBoardingBaseView: UIView { + // MARK: - Components + public let headerView: NavigationBar = { + let view = NavigationBar(type: .withUnderLine("다음에 하기")) + return view + }() + + // MARK: - init + init(leftButtonIsHidden: Bool = false, underlineTextButtonIsHidden: Bool = false) { + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI() + if leftButtonIsHidden { headerView.leftButton.isHidden = true } + if underlineTextButtonIsHidden { headerView.underlineTextButton.isHidden = true } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension OnBoardingBaseView { + func addViews() { + addSubview(headerView) + } + + func setupConstraints() { + headerView.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + } + } + + func configureUI() { + backgroundColor = .clearMLS + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingInput/OnBoardingInputFactoryImpl.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingInput/OnBoardingInputFactoryImpl.swift new file mode 100644 index 00000000..e1e04104 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingInput/OnBoardingInputFactoryImpl.swift @@ -0,0 +1,35 @@ +import MLSAuthFeatureInterface +import MLSCore + +public struct OnBoardingInputFactoryImpl: OnBoardingInputFactory { + private let checkEmptyUseCase: CheckEmptyLevelAndRoleUseCase + private let checkValidLevelUseCase: CheckValidLevelUseCase + private let authRepository: AuthAPIRepository + private let onBoardingNotificationFactory: OnBoardingNotificationFactory + private let appCoordinator: () -> AppCoordinatorProtocol + + public init( + checkEmptyUseCase: CheckEmptyLevelAndRoleUseCase, + checkValidLevelUseCase: CheckValidLevelUseCase, + authRepository: AuthAPIRepository, + onBoardingNotificationFactory: OnBoardingNotificationFactory, + appCoordinator: @escaping () -> AppCoordinatorProtocol + ) { + self.checkEmptyUseCase = checkEmptyUseCase + self.checkValidLevelUseCase = checkValidLevelUseCase + self.authRepository = authRepository + self.onBoardingNotificationFactory = onBoardingNotificationFactory + self.appCoordinator = appCoordinator + } + + public func make() -> BaseViewController { + let viewController = OnBoardingInputViewController(onBoardingNotificationFactory: onBoardingNotificationFactory, appCoordinator: appCoordinator()) + viewController.isBottomTabbarHidden = true + viewController.reactor = OnBoardingInputReactor( + checkEmptyUseCase: checkEmptyUseCase, + checkValidLevelUseCase: checkValidLevelUseCase, + authRepository: authRepository + ) + return viewController + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingInput/OnBoardingInputReactor.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingInput/OnBoardingInputReactor.swift new file mode 100644 index 00000000..547afdd3 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingInput/OnBoardingInputReactor.swift @@ -0,0 +1,113 @@ +import MLSAuthFeatureInterface + +import ReactorKit +import RxSwift + +public final class OnBoardingInputReactor: Reactor { + // MARK: - Reactor + public enum Route { + case none + case dismiss + case home + case notification + case error + } + + public enum Action { + case viewWillAppear + case backButtonTapped + case skipButtonTapped + case nextButtonTapped + case inputLevel(Int?) + case inputRole(Job?) + } + + public enum Mutation { + case setJobList(jobList: [Job]) + case setButtonEnabled(Bool) + case setLevelValid(Bool?) + case setLevel(Int?) + case setRole(Job?) + case navigateTo(route: Route) + } + + public struct State { + @Pulse var route: Route = .none + + var level: Int? + var job: Job? + var isButtonEnabled: Bool = false + var isLevelValid: Bool? + var jobList: [Job] = [] + } + + // MARK: - properties + public var initialState: State + var disposeBag = DisposeBag() + + private let checkEmptyUseCase: CheckEmptyLevelAndRoleUseCase + private let checkValidLevelUseCase: CheckValidLevelUseCase + private let authRepository: AuthAPIRepository + + // MARK: - init + public init( + checkEmptyUseCase: CheckEmptyLevelAndRoleUseCase, + checkValidLevelUseCase: CheckValidLevelUseCase, + authRepository: AuthAPIRepository + ) { + self.checkEmptyUseCase = checkEmptyUseCase + self.checkValidLevelUseCase = checkValidLevelUseCase + self.authRepository = authRepository + self.initialState = State() + } + + // MARK: - Reactor Methods + public func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear: + return authRepository.fetchJobList() + .map { response in + .setJobList(jobList: response.jobList) + } + .catchAndReturn(.navigateTo(route: .error)) + case .backButtonTapped: + return Observable.just(.navigateTo(route: .dismiss)) + case .skipButtonTapped: + return Observable.just(.navigateTo(route: .home)) + case .nextButtonTapped: + return Observable.just(.navigateTo(route: .notification)) + case .inputLevel(let level): + let isButtonEnabled = checkEmptyUseCase.execute(level: level, job: currentState.job?.name) + let isLevelValid = checkValidLevelUseCase.execute(level: level) + return .of( + .setLevel(level), + .setButtonEnabled(isButtonEnabled), + .setLevelValid(isLevelValid) + ) + case .inputRole(let job): + let isButtonEnabled = checkEmptyUseCase.execute(level: currentState.level, job: job?.name) + return .of(.setRole(job), .setButtonEnabled(isButtonEnabled)) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .setJobList(let jobList): + newState.jobList = jobList + case .setButtonEnabled(let isEnabled): + newState.isButtonEnabled = isEnabled + case .setLevelValid(let isValid): + newState.isLevelValid = isValid + case .setLevel(let level): + newState.level = level + case .setRole(let role): + newState.job = role + case .navigateTo(let route): + newState.route = route + } + + return newState + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingInput/OnBoardingInputView.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingInput/OnBoardingInputView.swift new file mode 100644 index 00000000..2629b4fb --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingInput/OnBoardingInputView.swift @@ -0,0 +1,57 @@ +import UIKit + +import MLSCore +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +public final class OnBoardingInputView: CharacterInputView { + // MARK: - Type + + // MARK: - Properties + + // MARK: - Components + public let headerView: NavigationBar = { + let view = NavigationBar(type: .withUnderLine("다음에 하기")) + return view + }() + + // MARK: - init + init(leftButtonIsHidden: Bool = false, underlineTextButtonIsHidden: Bool = false) { + super.init() + addViews() + setupConstraints() + configureUI() + if leftButtonIsHidden { headerView.leftButton.isHidden = true } + if underlineTextButtonIsHidden { headerView.underlineTextButton.isHidden = true } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension OnBoardingInputView { + func addViews() { + addSubview(headerView) + } + + func setupConstraints() { + headerView.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + } + + descriptionLabel.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom).offset(Constant.verticalInset) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + } + } + + func configureUI() { + backgroundColor = .clearMLS + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingInput/OnBoardingInputViewController.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingInput/OnBoardingInputViewController.swift new file mode 100644 index 00000000..0a2feebb --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingInput/OnBoardingInputViewController.swift @@ -0,0 +1,160 @@ +import UIKit + +import MLSAuthFeatureInterface +import MLSCore +import MLSDesignSystem + +import ReactorKit +import RxCocoa +import RxKeyboard +import RxSwift +import SnapKit + +public class OnBoardingInputViewController: BaseViewController, @preconcurrency View { + // MARK: - Properties + public typealias Reactor = OnBoardingInputReactor + + public var disposeBag = DisposeBag() + + private let onBoardingNotificationFactory: OnBoardingNotificationFactory + private let appCoordinator: AppCoordinatorProtocol + + // MARK: - Components + + private var mainView = OnBoardingInputView() + + init(onBoardingNotificationFactory: OnBoardingNotificationFactory, appCoordinator: AppCoordinatorProtocol) { + self.onBoardingNotificationFactory = onBoardingNotificationFactory + self.appCoordinator = appCoordinator + super.init() + } +} + +// MARK: - Life Cycle +public extension OnBoardingInputViewController { + override func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension OnBoardingInputViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } + + func configureUI() { + setupKeyboard() + } + + func setupKeyboard() { + setupKeyboard(inset: OnBoardingInputView.Constant.bottomInset) { [weak self] height in + self?.mainView.nextButtonBottomConstraint?.update(inset: height) + } + } +} + +// MARK: - Bind +public extension OnBoardingInputViewController { + func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) { + rx.viewWillAppear + .map { Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.nextButton.rx.tap + .map { Reactor.Action.nextButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.inputBox.textField.rx.text.orEmpty + .map { text -> Int? in + Int(text) + } + .map { Reactor.Action.inputLevel($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.dropDownBox.onItemSelected = { [weak self] job in + guard let self = self else { return } + self.reactor?.action.onNext(.inputRole(.init(name: job.name, id: job.id))) + } + + mainView.headerView.leftButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.headerView.underlineTextButton.rx.tap + .map { Reactor.Action.skipButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + func bindViewState(reactor: Reactor) { + reactor.state + .map { $0.jobList } + .observe(on: MainScheduler.instance) + .distinctUntilChanged() + .withUnretained(self) + .subscribe { owner, list in + owner.mainView.dropDownBox.items = list.map { .init(name: $0.name, id: $0.id) } + } + .disposed(by: disposeBag) + + reactor.state + .map { $0.isLevelValid } + .distinctUntilChanged() + .withUnretained(self) + .subscribe { owner, isLevelValid in + guard let isLevelValid = isLevelValid else { return } + owner.mainView.inputBox.setType(type: isLevelValid ? InputBoxType.edit : InputBoxType.error) + owner.mainView.errorMessage.isHidden = isLevelValid + } + .disposed(by: disposeBag) + + reactor.state + .map { $0.isButtonEnabled } + .bind(to: mainView.nextButton.rx.isEnabled) + .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .observe(on: MainScheduler.instance) + .withUnretained(self) + .subscribe(onNext: { owner, route in + switch route { + case .dismiss: + owner.navigationController?.popViewController(animated: true) + case .home: + owner.appCoordinator.showMainTab() + case .error: + let errorViewController = BaseErrorViewController() + owner.present(errorViewController, animated: true) + case .notification: + guard let selecteLevel = reactor.currentState.level, + let selectedJobID = reactor.currentState.job?.id else { return } + let viewController = owner.onBoardingNotificationFactory.make(selectedLevel: selecteLevel, selectedJobID: selectedJobID) + owner.navigationController?.pushViewController(viewController, animated: true) + default: + break + } + }) + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotification/OnBoardingNotificationFactoryImpl.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotification/OnBoardingNotificationFactoryImpl.swift new file mode 100644 index 00000000..bddcc1aa --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotification/OnBoardingNotificationFactoryImpl.swift @@ -0,0 +1,19 @@ +import MLSAuthFeatureInterface +import MLSCore + +public struct OnBoardingNotificationFactoryImpl: OnBoardingNotificationFactory { + private let onBoardingNotificationSheetFactory: OnBoardingNotificationSheetFactory + private let appCoordinator: () -> AppCoordinatorProtocol + + public init(onBoardingNotificationSheetFactory: OnBoardingNotificationSheetFactory, appCoordinator: @escaping () -> AppCoordinatorProtocol) { + self.onBoardingNotificationSheetFactory = onBoardingNotificationSheetFactory + self.appCoordinator = appCoordinator + } + + public func make(selectedLevel: Int, selectedJobID: Int) -> BaseViewController { + let viewController = OnBoardingNotificationViewController(onBoardingNotificationSheetFactory: onBoardingNotificationSheetFactory, appCoordinator: appCoordinator()) + viewController.isBottomTabbarHidden = true + viewController.reactor = OnBoardingNotificationReactor(selectedLevel: selectedLevel, selectedJobID: selectedJobID) + return viewController + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotification/OnBoardingNotificationReactor.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotification/OnBoardingNotificationReactor.swift new file mode 100644 index 00000000..042dc04e --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotification/OnBoardingNotificationReactor.swift @@ -0,0 +1,60 @@ +import ReactorKit +import RxSwift + +public final class OnBoardingNotificationReactor: Reactor { + // MARK: - Reactor + public enum Route { + case none + case notificationAlert + case pop + case home + } + + public enum Action { + case nextButtonTapped + case skipButtonTapped + case backButtonTapped + } + + public enum Mutation { + case navigateTo(route: Route) + } + + public struct State { + @Pulse var route: Route = .none + let selectedLevel: Int + let selectedJobID: Int + } + + // MARK: - properties + public var initialState: State + var disposeBag = DisposeBag() + + // MARK: - init + public init(selectedLevel: Int, selectedJobID: Int) { + self.initialState = State(selectedLevel: selectedLevel, selectedJobID: selectedJobID) + } + + // MARK: - Reactor Methods + public func mutate(action: Action) -> Observable { + switch action { + case .nextButtonTapped: + return .just(.navigateTo(route: .notificationAlert)) + case .skipButtonTapped: + return .just(.navigateTo(route: .home)) + case .backButtonTapped: + return .just(.navigateTo(route: .pop)) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .navigateTo(let route): + newState.route = route + } + + return newState + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotification/OnBoardingNotificationView.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotification/OnBoardingNotificationView.swift new file mode 100644 index 00000000..c727b032 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotification/OnBoardingNotificationView.swift @@ -0,0 +1,82 @@ +import UIKit + +import MLSDesignSystem + +import SnapKit + +public final class OnBoardingNotificationView: OnBoardingBaseView { + // MARK: - Type + private enum Constant { + static let horizontalInset = 16 + static let verticalInset = 16 + static let imgSize = 220 + static let resizeCenterY = 70 + } + + // MARK: - Components + private let imageView: UIImageView = { + let view = UIImageView() + view.image = DesignSystemAsset.image(named: "getNotify") + return view + }() + + private let boldTextLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .h_xxl_b, text: "메이플랜드에서 이벤트가 생기면\n알림을 보내드리고 있어요") + label.numberOfLines = 2 + return label + }() + + private lazy var contentView: UIView = { + let view = UIView() + view.addSubview(imageView) + view.addSubview(boldTextLabel) + + imageView.snp.makeConstraints { make in + make.top.equalToSuperview() + make.centerX.equalToSuperview() + make.size.equalTo(Constant.imgSize) + } + + boldTextLabel.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom).offset(4) + make.horizontalEdges.bottom.equalToSuperview() + } + + return view + }() + + public let nextButton = CommonButton(style: .normal, title: "다음", disabledTitle: "") + + // MARK: - init + init() { + super.init() + addViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension OnBoardingNotificationView { + func addViews() { + addSubview(contentView) + addSubview(nextButton) + } + + func setupConstraints() { + contentView.snp.makeConstraints { make in + make.centerY.equalToSuperview().offset(-Constant.resizeCenterY) + make.horizontalEdges.equalToSuperview() + } + + nextButton.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.bottom.equalTo(safeAreaLayoutGuide).inset(Constant.verticalInset) + } + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotification/OnBoardingNotificationViewController.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotification/OnBoardingNotificationViewController.swift new file mode 100644 index 00000000..027544fd --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotification/OnBoardingNotificationViewController.swift @@ -0,0 +1,111 @@ +import UIKit +import UserNotifications + +import MLSAuthFeatureInterface +import MLSCore + +import ReactorKit +import RxCocoa +import RxSwift +import SnapKit + +public class OnBoardingNotificationViewController: BaseViewController, @preconcurrency View { + // MARK: - Properties + public typealias Reactor = OnBoardingNotificationReactor + + public var disposeBag = DisposeBag() + + private let onBoardingNotificationSheetFactory: OnBoardingNotificationSheetFactory + private let appCoordinator: AppCoordinatorProtocol + + // MARK: - Components + + private var mainView = OnBoardingNotificationView() + + public init(onBoardingNotificationSheetFactory: OnBoardingNotificationSheetFactory, appCoordinator: AppCoordinatorProtocol) { + self.onBoardingNotificationSheetFactory = onBoardingNotificationSheetFactory + self.appCoordinator = appCoordinator + super.init() + } + + @available(*, unavailable) + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +public extension OnBoardingNotificationViewController { + override func viewDidLoad() { + super.viewDidLoad() + configureUI() + } +} + +// MARK: - SetUp +private extension OnBoardingNotificationViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } + + func configureUI() { + addViews() + setupConstraints() + } +} + +// MARK: - Private Methods +private extension OnBoardingNotificationViewController {} + +// MARK: - Bind +public extension OnBoardingNotificationViewController { + func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) { + mainView.nextButton.rx.tap + .map { Reactor.Action.nextButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.headerView.leftButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.headerView.underlineTextButton.rx.tap + .map { Reactor.Action.skipButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + func bindViewState(reactor: Reactor) { + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .observe(on: MainScheduler.instance) + .subscribe { owner, route in + switch route { + case .notificationAlert: + let viewController = owner.onBoardingNotificationSheetFactory.make(selectedLevel: reactor.currentState.selectedLevel, selectedJobID: reactor.currentState.selectedJobID) + owner.presentModal(viewController, hideTabBar: true) + case .home: + owner.appCoordinator.showMainTab() + case .pop: + owner.navigationController?.popViewController(animated: true) + default: + break + } + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotificationSheet/OnBoardingNotificationSheetFactoryImpl.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotificationSheet/OnBoardingNotificationSheetFactoryImpl.swift new file mode 100644 index 00000000..886ebcc4 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotificationSheet/OnBoardingNotificationSheetFactoryImpl.swift @@ -0,0 +1,27 @@ +import MLSAuthFeatureInterface +import MLSCore +import MLSDesignSystem + +public struct OnBoardingNotificationSheetFactoryImpl: OnBoardingNotificationSheetFactory { + private let authRepository: AuthAPIRepository + private let appCoordinator: () -> AppCoordinatorProtocol + + public init( + authRepository: AuthAPIRepository, + appCoordinator: @escaping () -> AppCoordinatorProtocol + ) { + self.authRepository = authRepository + self.appCoordinator = appCoordinator + } + + public func make(selectedLevel: Int, selectedJobID: Int) -> BaseViewController & ModalPresentable { + let viewController = OnBoardingNotificationSheetViewController(appCoordinator: appCoordinator()) + viewController.isBottomTabbarHidden = true + viewController.reactor = OnBoardingNotificationSheetReactor( + selectedLevel: selectedLevel, + selectedJobID: selectedJobID, + authRepository: authRepository + ) + return viewController + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotificationSheet/OnBoardingNotificationSheetReactor.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotificationSheet/OnBoardingNotificationSheetReactor.swift new file mode 100644 index 00000000..0220359d --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotificationSheet/OnBoardingNotificationSheetReactor.swift @@ -0,0 +1,116 @@ +import UIKit +import UserNotifications + +import MLSAuthFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift + +public final class OnBoardingNotificationSheetReactor: Reactor { + public enum Route { + case none + case dismiss + case home + case setting + } + + // MARK: - Reactor + public enum Action { + case viewWillAppear + case toggleSwitchButton(Bool) + case setButtonTapped + case cancelButtonTapped + case applyButtonTapped + case skipButtonTapped + case updateAuthorization(Bool) + case appWillEnterForeground + } + + public enum Mutation { + case navigateTo(route: Route) + case setLocalNotification(Bool) + case setRemoteNotification(Bool) + case setAuthorized(Bool) + } + + public struct State { + @Pulse var route: Route = .none + var selectedLevel: Int + var selectedJobID: Int + var isAgreeLocalNotification = false + var isAgreeRemoteNotification = true + } + + // MARK: - properties + public var initialState: State + var disposeBag = DisposeBag() + + private let authRepository: AuthAPIRepository + + // MARK: - init + public init( + selectedLevel: Int, + selectedJobID: Int, + authRepository: AuthAPIRepository + ) { + self.initialState = State(selectedLevel: selectedLevel, selectedJobID: selectedJobID) + self.authRepository = authRepository + } + + // MARK: - Reactor Methods + public func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear, .appWillEnterForeground: + return Single.create { single in + UNUserNotificationCenter.current().getNotificationSettings { settings in + let isAuthorized = settings.authorizationStatus == .authorized + || settings.authorizationStatus == .provisional + single(.success(isAuthorized)) + } + return Disposables.create() + } + .asObservable() + .map { .setLocalNotification($0) } + case .toggleSwitchButton(let isAgree): + return .just(.setRemoteNotification(isAgree)) + case .setButtonTapped: + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + return .just(.navigateTo(route: .setting)) + case .applyButtonTapped: + return authRepository.updateUserInfo(level: currentState.selectedLevel, selectedJobID: currentState.selectedJobID) + .andThen(authRepository.updateNotificationAgreement( + noticeAgreement: true, + patchNoteAgreement: true, + eventAgreement: true + )) + .andThen(Observable.just(.navigateTo(route: .home))) + .catchAndReturn(.navigateTo(route: .dismiss)) + case .cancelButtonTapped: + return .just(.navigateTo(route: .dismiss)) + case .skipButtonTapped: + return .just(.navigateTo(route: .home)) + case .updateAuthorization(let authorized): + return .just(.setAuthorized(authorized)) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .navigateTo(let route): + newState.route = route + case .setLocalNotification(let isAgree): + newState.isAgreeLocalNotification = isAgree + case .setRemoteNotification(let isAgree): + newState.isAgreeRemoteNotification = isAgree + case let .setAuthorized(isAuthorized): + newState.isAgreeLocalNotification = isAuthorized + } + + return newState + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotificationSheet/OnBoardingNotificationSheetView.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotificationSheet/OnBoardingNotificationSheetView.swift new file mode 100644 index 00000000..d0fe6065 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotificationSheet/OnBoardingNotificationSheetView.swift @@ -0,0 +1,100 @@ +import UIKit + +import MLSDesignSystem + +import SnapKit + +final class OnBoardingNotificationSheetView: UIView { + private enum Constant { + static let inset: CGFloat = 16 + static let spacing: CGFloat = 14 + static let buttonTopMargin: CGFloat = 20 + } + + // MARK: - Properties + let header: Header = { + let header = Header(style: .filter, title: "신규 이벤트 알림 설정") + return header + }() + + private let descriptionLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 2 + return label + }() + + private let buttonStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.spacing = Constant.spacing + return view + }() + + let notificationToggleBox = ToggleBox(text: "알림 설정") + let applyButton = CommonButton(style: .normal, title: "적용", disabledTitle: nil) + let settingButton = CommonButton(style: .normal, title: "변경하기", disabledTitle: nil) + let skipButton = CommonButton(style: .border, title: "나중에 하기", disabledTitle: nil) + + // MARK: - init + init() { + super.init(frame: .zero) + + addViews() + setupConstraints() + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension OnBoardingNotificationSheetView { + func addViews() { + addSubview(header) + addSubview(descriptionLabel) + addSubview(buttonStackView) + } + + func setupConstraints() { + header.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.inset) + make.horizontalEdges.equalToSuperview() + } + + descriptionLabel.snp.makeConstraints { make in + make.top.equalTo(header.snp.bottom).offset(Constant.spacing) + make.horizontalEdges.equalToSuperview().inset(Constant.inset) + } + + buttonStackView.snp.makeConstraints { make in + make.top.equalTo(descriptionLabel.snp.bottom).offset(Constant.buttonTopMargin) + make.horizontalEdges.bottom.equalToSuperview().inset(Constant.inset) + } + } + + func configureUI() { + notificationToggleBox.toggle.isOn = true + } +} + +// MARK: - Methods +extension OnBoardingNotificationSheetView { + func setUI(isAgree: Bool) { + buttonStackView.arrangedSubviews.forEach { view in + buttonStackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + + descriptionLabel.attributedText = .makeStyledString(font: .cp_s_r, text: isAgree ? "메이플랜드 이벤트 소식을\n푸시 알림으로 빠르게 받아보세요." : "기기 알림 설정을 변경해야 이벤트 소식을 받을 수 있어요.", color: .neutral700, alignment: .left) + if isAgree { + buttonStackView.addArrangedSubview(notificationToggleBox) + buttonStackView.addArrangedSubview(applyButton) + } else { + buttonStackView.addArrangedSubview(settingButton) + buttonStackView.addArrangedSubview(skipButton) + } + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotificationSheet/OnBoardingNotificationSheetViewController.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotificationSheet/OnBoardingNotificationSheetViewController.swift new file mode 100644 index 00000000..971d5098 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingNotificationSheet/OnBoardingNotificationSheetViewController.swift @@ -0,0 +1,157 @@ +import UIKit + +import MLSAuthFeatureInterface +import MLSCore +import MLSDesignSystem + +import ReactorKit +import RxCocoa +import RxSwift +import SnapKit + +public final class OnBoardingNotificationSheetViewController: BaseViewController, @preconcurrency ModalPresentable, @preconcurrency View { + public var modalHeight: CGFloat? + + public typealias Reactor = OnBoardingNotificationSheetReactor + + public var disposeBag = DisposeBag() + + // MARK: - Properties + + private let appCoordinator: AppCoordinatorProtocol + + // MARK: - Components + private var mainView = OnBoardingNotificationSheetView() + + init(appCoordinator: AppCoordinatorProtocol) { + self.appCoordinator = appCoordinator + super.init() + } +} + +// MARK: - Life Cycle +public extension OnBoardingNotificationSheetViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + } +} + +// MARK: - SetUp +private extension OnBoardingNotificationSheetViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } +} + +extension OnBoardingNotificationSheetViewController { + public func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) { + rx.viewWillAppear + .take(1) + .do(onNext: { [weak self] _ in + self?.checkNotificationAuthorization() + }) + .map { _ in Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + NotificationCenter.default.rx.notification(UIApplication.willEnterForegroundNotification) + .map { _ in Reactor.Action.appWillEnterForeground } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.header.firstIconButton.rx.tap + .map { Reactor.Action.cancelButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.skipButton.rx.tap + .map { Reactor.Action.skipButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.settingButton.rx.tap + .map { Reactor.Action.setButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.applyButton.rx.tap + .map { Reactor.Action.applyButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.notificationToggleBox.toggle.rx.isOn + .map { Reactor.Action.toggleSwitchButton($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + func bindViewState(reactor: Reactor) { + reactor.state + .observe(on: MainScheduler.instance) + .map { $0.isAgreeLocalNotification } + .withUnretained(self) + .subscribe { owner, isAgree in + owner.mainView.setUI(isAgree: isAgree) + } + .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .observe(on: MainScheduler.instance) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .subscribe { owner, route in + DispatchQueue.main.async { + switch route { + case .dismiss: + owner.dismissCurrentModal() + case .home: + owner.appCoordinator.showMainTab() + case .setting: + guard let url = URL(string: UIApplication.openSettingsURLString), + UIApplication.shared.canOpenURL(url) else { return } + UIApplication.shared.open(url, options: [:], completionHandler: nil) + default: + break + } + } + } + .disposed(by: disposeBag) + } +} + +// MARK: - Notification Authorization +private extension OnBoardingNotificationSheetViewController { + func checkNotificationAuthorization() { + guard let reactor = reactor else { return } + + NotificationPermissionManager.shared.getStatus { status in + switch status { + case .authorized, .provisional: + reactor.action.onNext(.updateAuthorization(true)) + case .denied: + reactor.action.onNext(.updateAuthorization(false)) + case .notDetermined: + NotificationPermissionManager.shared.requestIfNeeded { granted in + reactor.action.onNext(.updateAuthorization(granted)) + } + default: + reactor.action.onNext(.updateAuthorization(false)) + } + } + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingQuestion/OnBoardingQuestionFactoryImpl.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingQuestion/OnBoardingQuestionFactoryImpl.swift new file mode 100644 index 00000000..585129e3 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingQuestion/OnBoardingQuestionFactoryImpl.swift @@ -0,0 +1,18 @@ +import MLSAuthFeatureInterface +import MLSCore + +public struct OnBoardingQuestionFactoryImpl: OnBoardingQuestionFactory { + + private let onBoardingInputFactory: OnBoardingInputFactory + + public init(onBoardingInputFactory: OnBoardingInputFactory) { + self.onBoardingInputFactory = onBoardingInputFactory + } + + public func make() -> BaseViewController { + let viewController = OnBoardingQuestionViewController(factory: onBoardingInputFactory) + viewController.isBottomTabbarHidden = true + viewController.reactor = OnBoardingQuestionReactor() + return viewController + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingQuestion/OnBoardingQuestionReactor.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingQuestion/OnBoardingQuestionReactor.swift new file mode 100644 index 00000000..746674fc --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingQuestion/OnBoardingQuestionReactor.swift @@ -0,0 +1,65 @@ +import ReactorKit +import RxSwift + +public final class OnBoardingQuestionReactor: Reactor { + // MARK: - Reactor + public enum Route { + case none + case dismiss + case home + case input + } + + public enum Action { + case viewDidLoad + case nextButtonTapped + case backButtonTapped + case skipButtonTapped + } + + public enum Mutation { + case showToast + case navigateTo(route: Route) + } + + public struct State { + @Pulse var route: Route = .none + @Pulse var isShowToast: Bool = false + } + + // MARK: - properties + public var initialState: State + var disposeBag = DisposeBag() + + // MARK: - init + public init() { + self.initialState = State() + } + + // MARK: - Reactor Methods + public func mutate(action: Action) -> Observable { + switch action { + case .viewDidLoad: + return Observable.just(.showToast) + case .nextButtonTapped: + return Observable.just(.navigateTo(route: .input)) + case .backButtonTapped: + return Observable.just(.navigateTo(route: .dismiss)) + case .skipButtonTapped: + return Observable.just(.navigateTo(route: .home)) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .showToast: + newState.isShowToast.toggle() + case .navigateTo(let route): + newState.route = route + } + + return newState + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingQuestion/OnBoardingQuestionView.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingQuestion/OnBoardingQuestionView.swift new file mode 100644 index 00000000..04c58616 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingQuestion/OnBoardingQuestionView.swift @@ -0,0 +1,94 @@ +import UIKit + +import MLSDesignSystem + +import SnapKit + +public final class OnBoardingQuestionView: OnBoardingBaseView { + // MARK: - Type + private enum Constant { + static let horizontalInset = 16 + static let verticalInset = 16 + static let imgSize = 220 + static let resizeCenterY = 70 + } + + // MARK: - Components + private let imageView: UIImageView = { + let view = UIImageView() + view.image = DesignSystemAsset.image(named: "questionNotify") + return view + }() + + private let boldTextLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .h_xxxl_b, text: "효율적인 메이플랜드 플레이를\n위해 몇가지만 물어볼게요") + label.numberOfLines = 2 + return label + }() + + private let regularTeextLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .b_m_r, text: "나도 예티를 잡을 수 있을까?", color: .neutral700) + return label + }() + + private lazy var contentView: UIView = { + let view = UIView() + view.addSubview(imageView) + view.addSubview(boldTextLabel) + view.addSubview(regularTeextLabel) + + imageView.snp.makeConstraints { make in + make.top.equalToSuperview() + make.centerX.equalToSuperview() + make.size.equalTo(Constant.imgSize) + } + + boldTextLabel.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom).offset(4) + make.horizontalEdges.equalToSuperview() + } + + regularTeextLabel.snp.makeConstraints { make in + make.top.equalTo(boldTextLabel.snp.bottom).offset(Constant.verticalInset) + make.horizontalEdges.bottom.equalToSuperview() + } + + return view + }() + + public let nextButton = CommonButton(style: .normal, title: "다음", disabledTitle: "") + + // MARK: - init + init() { + super.init(leftButtonIsHidden: true, underlineTextButtonIsHidden: true) + addViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension OnBoardingQuestionView { + func addViews() { + addSubview(contentView) + addSubview(nextButton) + } + + func setupConstraints() { + contentView.snp.makeConstraints { make in + make.centerY.equalToSuperview().offset(-Constant.resizeCenterY) + make.horizontalEdges.equalToSuperview() + } + + nextButton.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.bottom.equalTo(safeAreaLayoutGuide).inset(Constant.verticalInset) + } + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingQuestion/OnBoardingQuestionViewController.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingQuestion/OnBoardingQuestionViewController.swift new file mode 100644 index 00000000..36c5b735 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/OnBoardingQuestion/OnBoardingQuestionViewController.swift @@ -0,0 +1,120 @@ +import UIKit + +import MLSAuthFeatureInterface +import MLSCore +import MLSDesignSystem + +import ReactorKit +import RxCocoa +import RxSwift +import SnapKit + +public class OnBoardingQuestionViewController: BaseViewController, @preconcurrency View { + // MARK: - Properties + public typealias Reactor = OnBoardingQuestionReactor + + public var disposeBag = DisposeBag() + + private let onBoardingInputFactory: OnBoardingInputFactory + + // MARK: - Components + + private var mainView = OnBoardingQuestionView() + + public init(factory: OnBoardingInputFactory) { + self.onBoardingInputFactory = factory + super.init() + } + + @available(*, unavailable) + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +public extension OnBoardingQuestionViewController { + override func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstraints() + } +} + +// MARK: - SetUp +private extension OnBoardingQuestionViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } +} + +// MARK: - Bind +public extension OnBoardingQuestionViewController { + func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) { + rx.viewDidLoad + .map { Reactor.Action.viewDidLoad } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.nextButton.rx.tap + .map { Reactor.Action.nextButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.headerView.leftButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.headerView.underlineTextButton.rx.tap + .map { Reactor.Action.skipButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + func bindViewState(reactor: Reactor) { + reactor.pulse(\.$isShowToast) + .subscribe(onNext: { isShowToast in + if isShowToast { + let currentDate = Date() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy.MM.dd" + let formattedDate = dateFormatter.string(from: currentDate) + ToastFactory.createToast(message: "\(formattedDate) 약관에 동의했어요.") + } + }) + .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .subscribe { owner, route in + switch route { + case .dismiss: + owner.navigationController?.popViewController(animated: true) + case .home: + let homeViewController = UIViewController() + homeViewController.view.backgroundColor = .green + owner.navigationController?.pushViewController(homeViewController, animated: true) + case .input: + let inputViewController = owner.onBoardingInputFactory.make() + owner.navigationController?.pushViewController(inputViewController, animated: true) + default: + break + } + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/TermsAgreement/TermsAgreementFactoryImpl.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/TermsAgreement/TermsAgreementFactoryImpl.swift new file mode 100644 index 00000000..2fa1374c --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/TermsAgreement/TermsAgreementFactoryImpl.swift @@ -0,0 +1,30 @@ +import MLSAuthFeatureInterface +import MLSCore + +public struct TermsAgreementFactoryImpl: TermsAgreementFactory { + private let onBoardingQuestionFactory: OnBoardingQuestionFactory + private let socialSignUpUseCase: SocialSignUpUseCase + private let tokenRepository: TokenRepository + + public init( + onBoardingQuestionFactory: OnBoardingQuestionFactory, + socialSignUpUseCase: SocialSignUpUseCase, + tokenRepository: TokenRepository + ) { + self.onBoardingQuestionFactory = onBoardingQuestionFactory + self.socialSignUpUseCase = socialSignUpUseCase + self.tokenRepository = tokenRepository + } + + public func make(credential: Credential, platform: LoginPlatform) -> BaseViewController { + let viewController = TermsAgreementViewController(onBoardingQuestionFactory: onBoardingQuestionFactory) + viewController.isBottomTabbarHidden = true + viewController.reactor = TermsAgreementReactor( + credential: credential, + socialPlatform: platform, + socialSignUpUseCase: socialSignUpUseCase, + tokenRepository: tokenRepository + ) + return viewController + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/TermsAgreement/TermsAgreementReactor.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/TermsAgreement/TermsAgreementReactor.swift new file mode 100644 index 00000000..5017067f --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/TermsAgreement/TermsAgreementReactor.swift @@ -0,0 +1,139 @@ +import MLSAuthFeatureInterface + +import ReactorKit +import RxSwift + +public final class TermsAgreementReactor: Reactor { + // MARK: - Type + public enum AgreeType { + case total, age, serviceTerms, personalInfo, marketing + } + + public enum Route { + case none + case dismiss + case onBoarding + case error + case ageAgreement + case serviceAgreement + case personalAgreement + case marketingAgreement + } + + // MARK: - Reactor + public enum Action { + case backButtonTapped + case toggleAgree(type: AgreeType) + case bottomButtonTapped + case navigateTo(route: Route) + } + + public enum Mutation { + case setAgreeState(type: AgreeType, isOn: Bool) + case navigateTo(route: Route) + } + + public struct State { + @Pulse var route: Route = .none + + var isTotalAgree: Bool = false + var isAgeAgree: Bool = false + var isServiceTermsAgree: Bool = false + var isPersonalInformationAgree: Bool = false + var isMarketingAgree: Bool = false + var bottomButtonIsEnabled: Bool = false + } + + // MARK: - properties + public var initialState: State + var disposeBag = DisposeBag() + private let credential: Credential + private let socialPlatform: LoginPlatform + private let socialSignUpUseCase: SocialSignUpUseCase + private let tokenRepository: TokenRepository + + // MARK: - init + public init( + credential: Credential, + socialPlatform: LoginPlatform, + socialSignUpUseCase: SocialSignUpUseCase, + tokenRepository: TokenRepository + ) { + self.credential = credential + self.socialPlatform = socialPlatform + self.socialSignUpUseCase = socialSignUpUseCase + self.tokenRepository = tokenRepository + self.initialState = State() + } + + // MARK: - Reactor Methods + public func mutate(action: Action) -> Observable { + switch action { + case .backButtonTapped: + return .just(.navigateTo(route: .dismiss)) + case .toggleAgree(let type): + let isOn: Bool + switch type { + case .total: + isOn = !currentState.isTotalAgree + case .age: + isOn = !currentState.isAgeAgree + case .serviceTerms: + isOn = !currentState.isServiceTermsAgree + case .personalInfo: + isOn = !currentState.isPersonalInformationAgree + case .marketing: + isOn = !currentState.isMarketingAgree + } + return .just(.setAgreeState(type: type, isOn: isOn)) + case .bottomButtonTapped: + let fcmToken: String? = { + if case .success(let token) = tokenRepository.fetchToken(type: .fcmToken) { + return token + } else { + return nil + } + }() + + return socialSignUpUseCase + .execute(credential: credential, platform: socialPlatform, isMarketingAgreement: currentState.isMarketingAgree, fcmToken: fcmToken) + .map { _ in .navigateTo(route: .onBoarding) } + .catchAndReturn(.navigateTo(route: .error)) + + case .navigateTo(let route): + return .just(.navigateTo(route: route)) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case .setAgreeState(let type, let isOn): + switch type { + case .total: + newState.isTotalAgree = isOn + newState.isAgeAgree = isOn + newState.isServiceTermsAgree = isOn + newState.isPersonalInformationAgree = isOn + newState.isMarketingAgree = isOn + case .age: + newState.isAgeAgree = isOn + case .serviceTerms: + newState.isServiceTermsAgree = isOn + case .personalInfo: + newState.isPersonalInformationAgree = isOn + case .marketing: + newState.isMarketingAgree = isOn + } + case .navigateTo(let route): + newState.route = route + } + + // bottomButton 활성화 체크 + let allRequiredAgreed = newState.isAgeAgree && newState.isServiceTermsAgree && newState.isPersonalInformationAgree + newState.bottomButtonIsEnabled = allRequiredAgreed + newState.isTotalAgree = allRequiredAgreed && newState.isMarketingAgree + + return newState + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/TermsAgreement/TermsAgreementView.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/TermsAgreement/TermsAgreementView.swift new file mode 100644 index 00000000..18a4099f --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/TermsAgreement/TermsAgreementView.swift @@ -0,0 +1,148 @@ +import UIKit + +import MLSDesignSystem + +import SnapKit + +public final class TermsAgreementView: UIView { + // MARK: - Type + private enum Constant { + static let imageTopSpacing: CGFloat = 20 + static let imageSize: CGFloat = 60 + static let horizontalInset: CGFloat = 16 + static let totalButtonBottomSpacing: CGFloat = -14 + static let titleLabelTopSpacing: CGFloat = 16 + static let titleLabelHeight: CGFloat = 30 + static let subTitleLabelTopSpacing: CGFloat = 4 + static let subTitleLabelHeight: CGFloat = 21 + static let stackViewBottomSpacing: CGFloat = -26 + static let bottomButtonBottomSpacing: CGFloat = 16 + static let termsSpacing: CGFloat = 4 + } + + // MARK: - Properties + let headerView: NavigationBar = { + let view = NavigationBar(type: .arrowLeft) + view.rightButton.isHidden = true + return view + }() + + private let logoImageView: UIImageView = { + let image = DesignSystemAsset.image(named: "logo") + let view = UIImageView(image: image) + view.contentMode = .scaleAspectFill + return view + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .h_xxxl_b, text: "필수약관에 동의해주세요") + return label + }() + + private let subTitleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .sub_m_m, text: "메랜사를 더 편하게 즐기기 위해 필요한 항목이에요", color: .neutral700) + return label + }() + + public let totalAgreeButton: CheckBoxButton = { + let button = CheckBoxButton(style: .normal, mainTitle: "전체동의", subTitle: "(선택 약관 포함)") + return button + }() + + private let termsStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.isUserInteractionEnabled = true + view.spacing = Constant.termsSpacing + return view + }() + + public let ageAgreeButton: CheckBoxButton = { + let button = CheckBoxButton(style: .listMedium, mainTitle: "(필수) 만 14세 이상", subTitle: nil) + return button + }() + + public let serviceTermsAgreeButton: CheckBoxButton = { + let button = CheckBoxButton(style: .listMedium, mainTitle: "(필수) 메랜사 서비스 이용약관 동의", subTitle: nil) + return button + }() + + public let personalInformationAgreeButton: CheckBoxButton = { + let button = CheckBoxButton(style: .listMedium, mainTitle: "(필수) 개인정보 수집 및 이용 동의", subTitle: nil) + return button + }() + + public let marketingAgreeButton: CheckBoxButton = { + let button = CheckBoxButton(style: .listMedium, mainTitle: "(선택) 마케팅 정보 수신 동의", subTitle: nil) + return button + }() + + public let bottomButton: CommonButton = { + let button = CommonButton(style: .normal, title: "다음", disabledTitle: "다음") + return button + }() + + // MARK: - init + init() { + super.init(frame: .zero) + addViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension TermsAgreementView { + func addViews() { + addSubview(headerView) + addSubview(logoImageView) + addSubview(titleLabel) + addSubview(subTitleLabel) + addSubview(totalAgreeButton) + addSubview(bottomButton) + addSubview(termsStackView) + termsStackView.addArrangedSubview(ageAgreeButton) + termsStackView.addArrangedSubview(serviceTermsAgreeButton) + termsStackView.addArrangedSubview(personalInformationAgreeButton) + termsStackView.addArrangedSubview(marketingAgreeButton) + } + + func setupConstraints() { + headerView.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + } + logoImageView.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom).offset(Constant.imageTopSpacing) + make.size.equalTo(Constant.imageSize) + make.leading.equalToSuperview().inset(Constant.horizontalInset) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(logoImageView.snp.bottom).offset(Constant.titleLabelTopSpacing) + make.height.equalTo(Constant.titleLabelHeight) + make.leading.equalTo(logoImageView) + } + subTitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Constant.subTitleLabelTopSpacing) + make.height.equalTo(Constant.subTitleLabelHeight) + make.leading.equalTo(logoImageView) + } + bottomButton.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.bottom.equalToSuperview().inset(Constant.bottomButtonBottomSpacing) + } + termsStackView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.bottom.equalTo(bottomButton.snp.top).offset(Constant.stackViewBottomSpacing) + } + totalAgreeButton.snp.makeConstraints { make in + make.bottom.equalTo(termsStackView.snp.top).offset(Constant.totalButtonBottomSpacing) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + } + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/TermsAgreement/TermsAgreementViewController.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/TermsAgreement/TermsAgreementViewController.swift new file mode 100644 index 00000000..d97ed6cc --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeature/Presentation/TermsAgreement/TermsAgreementViewController.swift @@ -0,0 +1,182 @@ +import UIKit + +import MLSAuthFeatureInterface +import MLSCore +import MLSDesignSystem + +import ReactorKit +import RxCocoa +import RxSwift +import SnapKit + +public class TermsAgreementViewController: BaseViewController, @preconcurrency View { + public typealias Reactor = TermsAgreementReactor + + // MARK: - Properties + public var disposeBag = DisposeBag() + + private let onBoardingQuestionFactory: OnBoardingQuestionFactory + + private var mainView = TermsAgreementView() + + public init(onBoardingQuestionFactory: OnBoardingQuestionFactory) { + self.onBoardingQuestionFactory = onBoardingQuestionFactory + super.init() + } + + @available(*, unavailable) + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +public extension TermsAgreementViewController { + override func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension TermsAgreementViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } + + func configureUI() { + view.backgroundColor = .systemBackground + navigationController?.navigationBar.isHidden = true + } +} + +public extension TermsAgreementViewController { + func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) { + mainView.headerView.leftButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + let agreeButtons: [(button: UIButton, type: TermsAgreementReactor.AgreeType, isRightButton: Bool)] = [ + (mainView.totalAgreeButton, .total, false), + (mainView.ageAgreeButton, .age, false), + (mainView.ageAgreeButton.rightButton, .age, true), + (mainView.serviceTermsAgreeButton, .serviceTerms, false), + (mainView.serviceTermsAgreeButton.rightButton, .serviceTerms, true), + (mainView.personalInformationAgreeButton, .personalInfo, false), + (mainView.personalInformationAgreeButton.rightButton, .personalInfo, true), + (mainView.marketingAgreeButton, .marketing, false), + (mainView.marketingAgreeButton.rightButton, .marketing, true) + ] + + agreeButtons.forEach { button, type, _ in + button.rx.tap + .map { Reactor.Action.toggleAgree(type: type) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + mainView.bottomButton.rx.tap + .map { Reactor.Action.bottomButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + func bindViewState(reactor: Reactor) { + reactor.state + .map { $0.isTotalAgree } + .distinctUntilChanged() + .withUnretained(self) + .subscribe { owner, isAgree in + owner.mainView.totalAgreeButton.isSelected = isAgree + } + .disposed(by: disposeBag) + + reactor.state + .map { $0.isAgeAgree } + .distinctUntilChanged() + .withUnretained(self) + .subscribe { owner, isAgree in + owner.mainView.ageAgreeButton.isSelected = isAgree + } + .disposed(by: disposeBag) + + reactor.state + .map { $0.isServiceTermsAgree } + .distinctUntilChanged() + .withUnretained(self) + .subscribe { owner, isAgree in + owner.mainView.serviceTermsAgreeButton.isSelected = isAgree + } + .disposed(by: disposeBag) + + reactor.state + .map { $0.isPersonalInformationAgree } + .distinctUntilChanged() + .withUnretained(self) + .subscribe { owner, isAgree in + owner.mainView.personalInformationAgreeButton.isSelected = isAgree + } + .disposed(by: disposeBag) + + reactor.state + .map { $0.isMarketingAgree } + .distinctUntilChanged() + .withUnretained(self) + .subscribe { owner, isAgree in + owner.mainView.marketingAgreeButton.isSelected = isAgree + } + .disposed(by: disposeBag) + + reactor.state + .map { $0.bottomButtonIsEnabled } + .distinctUntilChanged() + .withUnretained(self) + .subscribe { owner, isEnabled in + owner.mainView.bottomButton.isEnabled = isEnabled + } + .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .observe(on: MainScheduler.instance) + .subscribe { owner, route in + switch route { + case .dismiss: + owner.navigationController?.popViewController(animated: true) + case .onBoarding: + let questionViewController = owner.onBoardingQuestionFactory.make() + owner.navigationController?.setViewControllers([questionViewController], animated: true) + case .error: + let errorViewController = BaseErrorViewController() + owner.present(errorViewController, animated: true) + case .ageAgreement: + break + case .serviceAgreement: + break + case .personalAgreement: + break + case .marketingAgreement: + break + default: + break + } + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/AppCoordinatorProtocol.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/AppCoordinatorProtocol.swift new file mode 100644 index 00000000..24f9b2ca --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/AppCoordinatorProtocol.swift @@ -0,0 +1,7 @@ +import UIKit + +public protocol AppCoordinatorProtocol: AnyObject { + var window: UIWindow? { get set } + func showMainTab() + func showLogin(exitRoute: LoginExitRoute) +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/Credential.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/Credential.swift new file mode 100644 index 00000000..b5963583 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/Credential.swift @@ -0,0 +1,9 @@ +public struct Credential { + public let token: String + public let providerID: String + + public init(token: String, providerID: String) { + self.token = token + self.providerID = providerID + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/JobListResponse.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/JobListResponse.swift new file mode 100644 index 00000000..9ffb71b6 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/JobListResponse.swift @@ -0,0 +1,17 @@ +public struct JobListResponse { + public var jobList: [Job] + + public init(jobList: [Job]) { + self.jobList = jobList + } +} + +public struct Job: Equatable { + public let name: String + public let id: Int + + public init(name: String, id: Int) { + self.name = name + self.id = id + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/LoginExitRoute.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/LoginExitRoute.swift new file mode 100644 index 00000000..597ae3e7 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/LoginExitRoute.swift @@ -0,0 +1,4 @@ +public enum LoginExitRoute { + case home + case pop +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/LoginPlatform.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/LoginPlatform.swift new file mode 100644 index 00000000..5bc534e1 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/LoginPlatform.swift @@ -0,0 +1,4 @@ +public enum LoginPlatform: String { + case kakao + case apple +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/LoginResponse.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/LoginResponse.swift new file mode 100644 index 00000000..62338dd7 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/LoginResponse.swift @@ -0,0 +1,11 @@ +public struct LoginResponse { + public var isRegister: Bool + public var accessToken: String + public var refreshToken: String + + public init(isRegister: Bool, accessToken: String, refreshToken: String) { + self.isRegister = isRegister + self.accessToken = accessToken + self.refreshToken = refreshToken + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/SignUpResponse.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/SignUpResponse.swift new file mode 100644 index 00000000..91a79260 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Entities/SignUpResponse.swift @@ -0,0 +1,9 @@ +public struct SignUpResponse { + public var accessToken: String + public var refreshToken: String + + public init(accessToken: String, refreshToken: String) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Errors/AuthError.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Errors/AuthError.swift new file mode 100644 index 00000000..abc34978 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Errors/AuthError.swift @@ -0,0 +1,5 @@ +public enum AuthError: Error { + case unknown(message: String) + case userNotFound(credential: Credential) + case tokenExpired +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Errors/TokenRepositoryError.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Errors/TokenRepositoryError.swift new file mode 100644 index 00000000..d0281877 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Errors/TokenRepositoryError.swift @@ -0,0 +1,14 @@ +import Foundation +import Security + +public enum TokenRepositoryError: Error { + case noValueFound(message: String) + case unhandledError(status: OSStatus) + case dataConversionError(message: String) +} + +public enum TokenType: String { + case accessToken + case refreshToken + case fcmToken +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/LoginFactory.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/LoginFactory.swift new file mode 100644 index 00000000..a7a7cd36 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/LoginFactory.swift @@ -0,0 +1,11 @@ +import MLSCore + +public protocol LoginFactory { + func make(exitRoute: LoginExitRoute, onLoginCompleted: (() -> Void)?) -> BaseViewController +} + +public extension LoginFactory { + func make(exitRoute: LoginExitRoute) -> BaseViewController { + make(exitRoute: exitRoute, onLoginCompleted: nil) + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingInputFactory.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingInputFactory.swift new file mode 100644 index 00000000..2963bab2 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingInputFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol OnBoardingInputFactory { + func make() -> BaseViewController +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingModalFactory.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingModalFactory.swift new file mode 100644 index 00000000..22269e8f --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingModalFactory.swift @@ -0,0 +1,6 @@ +import MLSCore +import MLSDesignSystem + +public protocol OnBoardingModalFactory { + func make() -> BaseViewController & ModalPresentable +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingNotificationFactory.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingNotificationFactory.swift new file mode 100644 index 00000000..e2672997 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingNotificationFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol OnBoardingNotificationFactory { + func make(selectedLevel: Int, selectedJobID: Int) -> BaseViewController +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingNotificationSheetFactory.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingNotificationSheetFactory.swift new file mode 100644 index 00000000..a5acd2c3 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingNotificationSheetFactory.swift @@ -0,0 +1,6 @@ +import MLSCore +import MLSDesignSystem + +public protocol OnBoardingNotificationSheetFactory { + func make(selectedLevel: Int, selectedJobID: Int) -> BaseViewController & ModalPresentable +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingQuestionFactory.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingQuestionFactory.swift new file mode 100644 index 00000000..6424bd06 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/OnBoardingQuestionFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol OnBoardingQuestionFactory { + func make() -> BaseViewController +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/TermsAgreementFactory.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/TermsAgreementFactory.swift new file mode 100644 index 00000000..b8912c9b --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Factories/TermsAgreementFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol TermsAgreementFactory { + func make(credential: Credential, platform: LoginPlatform) -> BaseViewController +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Providers/SocialCredentialProvider.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Providers/SocialCredentialProvider.swift new file mode 100644 index 00000000..699ce075 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Providers/SocialCredentialProvider.swift @@ -0,0 +1,5 @@ +import RxSwift + +public protocol SocialCredentialProvider { + func getCredential() -> Observable +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Repositories/AuthAPIRepository.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Repositories/AuthAPIRepository.swift new file mode 100644 index 00000000..0be26ed2 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Repositories/AuthAPIRepository.swift @@ -0,0 +1,14 @@ +import RxSwift + +public protocol AuthAPIRepository { + func loginWithKakao(credential: Credential) -> Observable + func loginWithApple(credential: Credential) -> Observable + func signUpWithKakao(credential: Credential, isMarketingAgreement: Bool, fcmToken: String?) -> Observable + func signUpWithApple(credential: Credential, isMarketingAgreement: Bool, fcmToken: String?) -> Observable + func withdraw() -> Completable + func fetchJobList() -> Observable + func updateUserInfo(level: Int, selectedJobID: Int) -> Completable + func reissueToken(refreshToken: String) -> Observable + func fcmToken(fcmToken: String?) -> Completable + func updateNotificationAgreement(noticeAgreement: Bool, patchNoteAgreement: Bool, eventAgreement: Bool) -> Completable +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Repositories/TokenRepository.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Repositories/TokenRepository.swift new file mode 100644 index 00000000..a24a3e51 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Repositories/TokenRepository.swift @@ -0,0 +1,5 @@ +public protocol TokenRepository { + func fetchToken(type: TokenType) -> Result + func saveToken(type: TokenType, value: String) -> Result + func deleteToken(type: TokenType) -> Result +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Repositories/UserDefaultsRepository.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Repositories/UserDefaultsRepository.swift new file mode 100644 index 00000000..42cea260 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/Repositories/UserDefaultsRepository.swift @@ -0,0 +1,6 @@ +import RxSwift + +public protocol UserDefaultsRepository { + func fetchPlatform() -> Observable + func savePlatform(platform: LoginPlatform) -> Completable +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/UseCases/CheckEmptyLevelAndRoleUseCase.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/UseCases/CheckEmptyLevelAndRoleUseCase.swift new file mode 100644 index 00000000..8bb00642 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/UseCases/CheckEmptyLevelAndRoleUseCase.swift @@ -0,0 +1,3 @@ +public protocol CheckEmptyLevelAndRoleUseCase { + func execute(level: Int?, job: String?) -> Bool +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/UseCases/CheckValidLevelUseCase.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/UseCases/CheckValidLevelUseCase.swift new file mode 100644 index 00000000..b02d0866 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/UseCases/CheckValidLevelUseCase.swift @@ -0,0 +1,3 @@ +public protocol CheckValidLevelUseCase { + func execute(level: Int?) -> Bool? +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/UseCases/SocialLoginUseCase.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/UseCases/SocialLoginUseCase.swift new file mode 100644 index 00000000..64ba6b85 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/UseCases/SocialLoginUseCase.swift @@ -0,0 +1,5 @@ +import RxSwift + +public protocol SocialLoginUseCase { + func execute(credential: Credential, platform: LoginPlatform) -> Observable +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/UseCases/SocialSignUpUseCase.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/UseCases/SocialSignUpUseCase.swift new file mode 100644 index 00000000..0f0932d9 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureInterface/UseCases/SocialSignUpUseCase.swift @@ -0,0 +1,5 @@ +import RxSwift + +public protocol SocialSignUpUseCase { + func execute(credential: Credential, platform: LoginPlatform, isMarketingAgreement: Bool, fcmToken: String?) -> Observable +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/Credential+Mock.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/Credential+Mock.swift new file mode 100644 index 00000000..bf29cf5f --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/Credential+Mock.swift @@ -0,0 +1,5 @@ +import MLSAuthFeatureInterface + +public extension Credential { + static let mock = Credential(token: "test_token", providerID: "test_provider_id") +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/FCMFailingMockAuthAPIRepository.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/FCMFailingMockAuthAPIRepository.swift new file mode 100644 index 00000000..3feca629 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/FCMFailingMockAuthAPIRepository.swift @@ -0,0 +1,23 @@ +import MLSAuthFeatureInterface + +import RxSwift + +/// FCM 토큰 등록만 실패하는 Mock. 나머지는 MockAuthAPIRepository에 위임. +public final class FCMFailingMockAuthAPIRepository: AuthAPIRepository { + private let base = MockAuthAPIRepository() + + public init() {} + + public func loginWithKakao(credential: Credential) -> Observable { base.loginWithKakao(credential: credential) } + public func loginWithApple(credential: Credential) -> Observable { base.loginWithApple(credential: credential) } + public func signUpWithKakao(credential: Credential, isMarketingAgreement: Bool, fcmToken: String?) -> Observable { base.signUpWithKakao(credential: credential, isMarketingAgreement: isMarketingAgreement, fcmToken: fcmToken) } + public func signUpWithApple(credential: Credential, isMarketingAgreement: Bool, fcmToken: String?) -> Observable { base.signUpWithApple(credential: credential, isMarketingAgreement: isMarketingAgreement, fcmToken: fcmToken) } + public func withdraw() -> Completable { base.withdraw() } + public func fetchJobList() -> Observable { base.fetchJobList() } + public func updateUserInfo(level: Int, selectedJobID: Int) -> Completable { base.updateUserInfo(level: level, selectedJobID: selectedJobID) } + public func reissueToken(refreshToken: String) -> Observable { base.reissueToken(refreshToken: refreshToken) } + public func fcmToken(fcmToken: String?) -> Completable { .error(FCMError.failed) } + public func updateNotificationAgreement(noticeAgreement: Bool, patchNoteAgreement: Bool, eventAgreement: Bool) -> Completable { base.updateNotificationAgreement(noticeAgreement: noticeAgreement, patchNoteAgreement: patchNoteAgreement, eventAgreement: eventAgreement) } + + private enum FCMError: Error { case failed } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/FailingMockTokenRepository.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/FailingMockTokenRepository.swift new file mode 100644 index 00000000..477b2626 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/FailingMockTokenRepository.swift @@ -0,0 +1,17 @@ +import MLSAuthFeatureInterface + +public final class FailingMockTokenRepository: TokenRepository { + public init() {} + + public func fetchToken(type: TokenType) -> Result { + .failure(TokenRepositoryError.noValueFound(message: "")) + } + + public func saveToken(type: TokenType, value: String) -> Result { + .failure(TokenRepositoryError.dataConversionError(message: "forced failure")) + } + + public func deleteToken(type: TokenType) -> Result { + .failure(TokenRepositoryError.noValueFound(message: "")) + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/LoginResponse+Mock.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/LoginResponse+Mock.swift new file mode 100644 index 00000000..7b25f1ab --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/LoginResponse+Mock.swift @@ -0,0 +1,6 @@ +import MLSAuthFeatureInterface + +public extension LoginResponse { + static let registered = LoginResponse(isRegister: true, accessToken: "", refreshToken: "") + static let unregistered = LoginResponse(isRegister: false, accessToken: "", refreshToken: "") +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockAuthAPIRepository.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockAuthAPIRepository.swift new file mode 100644 index 00000000..911e99e4 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockAuthAPIRepository.swift @@ -0,0 +1,46 @@ +import MLSAuthFeatureInterface + +import RxSwift + +public final class MockAuthAPIRepository: AuthAPIRepository { + + public init() {} + + public func loginWithKakao(credential: Credential) -> Observable { + return .just(LoginResponse(isRegister: false, accessToken: "mock_access", refreshToken: "mock_refresh")) + } + + public func loginWithApple(credential: Credential) -> Observable { + return .just(LoginResponse(isRegister: true, accessToken: "mock_access", refreshToken: "mock_refresh")) + } + + public func signUpWithKakao(credential: Credential, isMarketingAgreement: Bool, fcmToken: String?) -> Observable { + return .just(SignUpResponse(accessToken: "mock_access", refreshToken: "mock_refresh")) + } + + public func signUpWithApple(credential: Credential, isMarketingAgreement: Bool, fcmToken: String?) -> Observable { + return .just(SignUpResponse(accessToken: "mock_access", refreshToken: "mock_refresh")) + } + + public func withdraw() -> Completable { return .empty() } + + public func fetchJobList() -> Observable { + return .just(JobListResponse(jobList: [ + Job(name: "전사", id: 1), + Job(name: "마법사", id: 2), + Job(name: "궁수", id: 3), + Job(name: "도적", id: 4), + Job(name: "해적", id: 5) + ])) + } + + public func updateUserInfo(level: Int, selectedJobID: Int) -> Completable { return .empty() } + + public func reissueToken(refreshToken: String) -> Observable { + return .just(LoginResponse(isRegister: true, accessToken: "mock_access", refreshToken: "mock_refresh")) + } + + public func fcmToken(fcmToken: String?) -> Completable { return .empty() } + + public func updateNotificationAgreement(noticeAgreement: Bool, patchNoteAgreement: Bool, eventAgreement: Bool) -> Completable { return .empty() } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockSocialCredentialProviders.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockSocialCredentialProviders.swift new file mode 100644 index 00000000..9cd1e2dd --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockSocialCredentialProviders.swift @@ -0,0 +1,19 @@ +import MLSAuthFeatureInterface + +import RxSwift + +public final class MockKakaoCredentialProvider: SocialCredentialProvider { + public init() {} + + public func getCredential() -> Observable { + return .just(Credential(token: "mock_kakao_token", providerID: "mock_kakao_provider_id")) + } +} + +public final class MockAppleCredentialProvider: SocialCredentialProvider { + public init() {} + + public func getCredential() -> Observable { + return .just(Credential(token: "mock_apple_token", providerID: "mock_apple_provider_id")) + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockSocialLoginUseCase.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockSocialLoginUseCase.swift new file mode 100644 index 00000000..339ea8a2 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockSocialLoginUseCase.swift @@ -0,0 +1,18 @@ +import MLSAuthFeatureInterface + +import RxSwift + +public final class MockSocialLoginUseCase: SocialLoginUseCase { + private let result: Result + + public init(result: Result) { + self.result = result + } + + public func execute(credential: Credential, platform: LoginPlatform) -> Observable { + switch result { + case .success(let response): return .just(response) + case .failure(let error): return .error(error) + } + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockSocialSignUpUseCase.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockSocialSignUpUseCase.swift new file mode 100644 index 00000000..23335fc3 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockSocialSignUpUseCase.swift @@ -0,0 +1,18 @@ +import MLSAuthFeatureInterface + +import RxSwift + +public final class MockSocialSignUpUseCase: SocialSignUpUseCase { + private let result: Result + + public init(result: Result = .success(SignUpResponse(accessToken: "mock", refreshToken: "mock"))) { + self.result = result + } + + public func execute(credential: Credential, platform: LoginPlatform, isMarketingAgreement: Bool, fcmToken: String?) -> Observable { + switch result { + case .success(let response): return .just(response) + case .failure(let error): return .error(error) + } + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockTermsAgreementFactory.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockTermsAgreementFactory.swift new file mode 100644 index 00000000..f4cb9335 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockTermsAgreementFactory.swift @@ -0,0 +1,12 @@ +import MLSAuthFeatureInterface +import MLSCore + +public final class MockTermsAgreementFactory: TermsAgreementFactory { + public init() {} + + public func make(credential: Credential, platform: LoginPlatform) -> BaseViewController { + let vc = BaseViewController() + vc.view.backgroundColor = .systemBackground + return vc + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockTokenRepository.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockTokenRepository.swift new file mode 100644 index 00000000..2b529333 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockTokenRepository.swift @@ -0,0 +1,24 @@ +import MLSAuthFeatureInterface + +public final class MockTokenRepository: TokenRepository { + private var storage: [String: String] = [:] + + public init() {} + + public func fetchToken(type: TokenType) -> Result { + if let value = storage[type.rawValue] { + return .success(value) + } + return .failure(TokenRepositoryError.noValueFound(message: "\(type.rawValue) not found")) + } + + public func saveToken(type: TokenType, value: String) -> Result { + storage[type.rawValue] = value + return .success(()) + } + + public func deleteToken(type: TokenType) -> Result { + storage[type.rawValue] = nil + return .success(()) + } +} diff --git a/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockUserDefaultsRepository.swift b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockUserDefaultsRepository.swift new file mode 100644 index 00000000..6b8c1896 --- /dev/null +++ b/MLS/MLSAuthFeature/Sources/MLSAuthFeatureTesting/Mock/MockUserDefaultsRepository.swift @@ -0,0 +1,18 @@ +import MLSAuthFeatureInterface + +import RxSwift + +public final class MockUserDefaultsRepository: UserDefaultsRepository { + private var platform: LoginPlatform? + + public init() {} + + public func fetchPlatform() -> Observable { + return .just(platform) + } + + public func savePlatform(platform: LoginPlatform) -> Completable { + self.platform = platform + return .empty() + } +} diff --git a/MLS/MLSAuthFeature/Tests/MLSAuthFeatureTests/CheckUseCaseTests.swift b/MLS/MLSAuthFeature/Tests/MLSAuthFeatureTests/CheckUseCaseTests.swift new file mode 100644 index 00000000..39663fe2 --- /dev/null +++ b/MLS/MLSAuthFeature/Tests/MLSAuthFeatureTests/CheckUseCaseTests.swift @@ -0,0 +1,53 @@ +import Testing + +@testable import MLSAuthFeature + +@Suite("CheckValidLevelUseCase") +struct CheckValidLevelUseCaseTests { + private let sut = CheckValidLevelUseCaseImpl() + + @Test("유효 레벨(1·100·200): true 반환", arguments: [1, 100, 200]) + func validLevel_returnsTrue(level: Int) { + #expect(sut.execute(level: level) == true) + } + + @Test("범위 외 레벨(0·201·-1): false 반환", arguments: [0, 201, -1]) + func outOfRangeLevel_returnsFalse(level: Int) { + #expect(sut.execute(level: level) == false) + } + + @Test("nil 레벨: nil 반환") + func nilLevel_returnsNil() { + #expect(sut.execute(level: nil) == nil) + } +} + +@Suite("CheckEmptyLevelAndRoleUseCase") +struct CheckEmptyLevelAndRoleUseCaseTests { + private let sut = CheckEmptyLevelAndRoleUseCaseImpl() + + @Test("레벨·직업 모두 유효: true 반환") + func bothValid_returnsTrue() { + #expect(sut.execute(level: 50, job: "전사") == true) + } + + @Test("레벨 nil: false 반환") + func nilLevel_returnsFalse() { + #expect(sut.execute(level: nil, job: "전사") == false) + } + + @Test("레벨 범위 초과(0): false 반환") + func outOfRangeLevel_returnsFalse() { + #expect(sut.execute(level: 0, job: "전사") == false) + } + + @Test("직업 nil: false 반환") + func nilJob_returnsFalse() { + #expect(sut.execute(level: 50, job: nil) == false) + } + + @Test("직업 빈 문자열: false 반환") + func emptyJob_returnsFalse() { + #expect(sut.execute(level: 50, job: "") == false) + } +} diff --git a/MLS/MLSAuthFeature/Tests/MLSAuthFeatureTests/LoginReactorTests.swift b/MLS/MLSAuthFeature/Tests/MLSAuthFeatureTests/LoginReactorTests.swift new file mode 100644 index 00000000..2151f5ea --- /dev/null +++ b/MLS/MLSAuthFeature/Tests/MLSAuthFeatureTests/LoginReactorTests.swift @@ -0,0 +1,119 @@ +import Testing + +@testable import MLSAuthFeature +import MLSAuthFeatureInterface +import MLSAuthFeatureTesting + +import RxBlocking +import RxSwift + +@Suite("LoginReactor - 로그인 라우팅") +struct LoginReactorTests { + + // MARK: - Apple 로그인 + + @Test("Apple 로그인 + isRegister true: 홈으로 이동") + func appleLogin_registeredUser_navigatesToHome() throws { + let reactor = makeSUT(response: .registered) + let mutation = try reactor.mutate(action: .appleLoginButtonTapped).toBlocking().first()! + let state = reactor.reduce(state: reactor.initialState, mutation: mutation) + + if case .home = state.route {} else { + Issue.record("Expected .home, got \(state.route)") + } + } + + @Test("Apple 로그인 + isRegister false: 약관 동의 화면으로 이동") + func appleLogin_unregisteredUser_navigatesToTerms() throws { + let reactor = makeSUT(response: .unregistered) + let mutation = try reactor.mutate(action: .appleLoginButtonTapped).toBlocking().first()! + let state = reactor.reduce(state: reactor.initialState, mutation: mutation) + + if case .termsAgreements = state.route {} else { + Issue.record("Expected .termsAgreements, got \(state.route)") + } + } + + // MARK: - Kakao 로그인 + + @Test("Kakao 로그인 + isRegister false: 약관 동의 화면으로 이동") + func kakaoLogin_unregisteredUser_navigatesToTerms() throws { + let reactor = makeSUT(response: .unregistered) + let mutation = try reactor.mutate(action: .kakaoLoginButtonTapped).toBlocking().first()! + let state = reactor.reduce(state: reactor.initialState, mutation: mutation) + + if case .termsAgreements = state.route {} else { + Issue.record("Expected .termsAgreements, got \(state.route)") + } + } + + // MARK: - 에러 라우팅 + + @Test("userNotFound 에러: 약관 동의 화면으로 이동") + func userNotFound_navigatesToTerms() throws { + let reactor = makeSUT(error: AuthError.userNotFound(credential: Credential(token: "", providerID: ""))) + let mutation = try reactor.mutate(action: .appleLoginButtonTapped).toBlocking().first()! + let state = reactor.reduce(state: reactor.initialState, mutation: mutation) + + if case .termsAgreements = state.route {} else { + Issue.record("Expected .termsAgreements on userNotFound, got \(state.route)") + } + } + + @Test("그 외 에러: 에러 화면으로 이동") + func otherError_navigatesToError() throws { + let reactor = makeSUT(error: AuthError.tokenExpired) + let mutation = try reactor.mutate(action: .appleLoginButtonTapped).toBlocking().first()! + let state = reactor.reduce(state: reactor.initialState, mutation: mutation) + + if case .error = state.route {} else { + Issue.record("Expected .error, got \(state.route)") + } + } + + // MARK: - 기타 액션 + + @Test("게스트 로그인: 홈으로 이동") + func guestLogin_navigatesToHome() throws { + let reactor = makeSUT() + let mutation = try reactor.mutate(action: .guestLoginButtonTapped).toBlocking().first()! + let state = reactor.reduce(state: reactor.initialState, mutation: mutation) + + if case .home = state.route {} else { + Issue.record("Expected .home, got \(state.route)") + } + } + + @Test("뒤로가기: dismiss") + func backButton_navigatesToDismiss() throws { + let reactor = makeSUT() + let mutation = try reactor.mutate(action: .backButtonTapped).toBlocking().first()! + let state = reactor.reduce(state: reactor.initialState, mutation: mutation) + + if case .dismiss = state.route {} else { + Issue.record("Expected .dismiss, got \(state.route)") + } + } +} + +// MARK: - Helpers + +private func makeSUT( + response: LoginResponse = .registered +) -> LoginReactor { + LoginReactor( + appleProvider: MockAppleCredentialProvider(), + kakaoProvider: MockKakaoCredentialProvider(), + socialLoginUseCase: MockSocialLoginUseCase(result: .success(response)), + userDefaultsRepository: MockUserDefaultsRepository() + ) +} + +private func makeSUT(error: Error) -> LoginReactor { + LoginReactor( + appleProvider: MockAppleCredentialProvider(), + kakaoProvider: MockKakaoCredentialProvider(), + socialLoginUseCase: MockSocialLoginUseCase(result: .failure(error)), + userDefaultsRepository: MockUserDefaultsRepository() + ) +} diff --git a/MLS/MLSAuthFeature/Tests/MLSAuthFeatureTests/SocialLoginUseCaseTests.swift b/MLS/MLSAuthFeature/Tests/MLSAuthFeatureTests/SocialLoginUseCaseTests.swift new file mode 100644 index 00000000..20b58508 --- /dev/null +++ b/MLS/MLSAuthFeature/Tests/MLSAuthFeatureTests/SocialLoginUseCaseTests.swift @@ -0,0 +1,77 @@ +import Testing + +@testable import MLSAuthFeature +import MLSAuthFeatureInterface +import MLSAuthFeatureTesting + +import RxBlocking +import RxSwift + +@Suite("SocialLoginUseCase") +struct SocialLoginUseCaseTests { + + // MARK: - 플랫폼 저장 + + @Test("Apple 로그인 성공: UserDefaults에 .apple 저장") + func appleLogin_savesApplePlatform() throws { + let userDefaultsRepo = MockUserDefaultsRepository() + let sut = makeSUT(userDefaultsRepository: userDefaultsRepo) + + _ = try sut.execute(credential: .mock, platform: .apple).toBlocking().first() + + let saved = try userDefaultsRepo.fetchPlatform().toBlocking().first() + #expect(saved == .apple) + } + + @Test("Kakao 로그인 성공: UserDefaults에 .kakao 저장") + func kakaoLogin_savesKakaoPlatform() throws { + let userDefaultsRepo = MockUserDefaultsRepository() + let sut = makeSUT(userDefaultsRepository: userDefaultsRepo) + + _ = try sut.execute(credential: .mock, platform: .kakao).toBlocking().first() + + let saved = try userDefaultsRepo.fetchPlatform().toBlocking().first() + #expect(saved == .kakao) + } + + // MARK: - 토큰 저장 실패 + + @Test("토큰 저장 실패: 에러 전파") + func tokenSaveFailure_propagatesError() { + let sut = makeSUT(tokenRepository: FailingMockTokenRepository()) + + #expect(throws: (any Error).self) { + _ = try sut.execute(credential: .mock, platform: .apple).toBlocking().first() + } + } + + // MARK: - FCM 등록 실패 + + @Test("FCM 등록 실패: 로그인 결과는 정상 반환") + func fcmFailure_doesNotBlockLoginResult() throws { + let tokenRepo = MockTokenRepository() + _ = tokenRepo.saveToken(type: .fcmToken, value: "fcm_token") + + let sut = makeSUT( + authRepository: FCMFailingMockAuthAPIRepository(), + tokenRepository: tokenRepo + ) + + let result = try sut.execute(credential: .mock, platform: .apple).toBlocking().first() + #expect(result != nil) + } +} + +// MARK: - Helpers + +private func makeSUT( + authRepository: AuthAPIRepository = MockAuthAPIRepository(), + tokenRepository: TokenRepository = MockTokenRepository(), + userDefaultsRepository: UserDefaultsRepository = MockUserDefaultsRepository() +) -> SocialLoginUseCaseImpl { + SocialLoginUseCaseImpl( + authRepository: authRepository, + tokenRepository: tokenRepository, + userDefaultsRepository: userDefaultsRepository + ) +} diff --git a/MLS/MLSAuthFeature/Tests/MLSAuthFeatureTests/TermsAgreementReactorTests.swift b/MLS/MLSAuthFeature/Tests/MLSAuthFeatureTests/TermsAgreementReactorTests.swift new file mode 100644 index 00000000..4819524e --- /dev/null +++ b/MLS/MLSAuthFeature/Tests/MLSAuthFeatureTests/TermsAgreementReactorTests.swift @@ -0,0 +1,102 @@ +import Testing + +@testable import MLSAuthFeature +import MLSAuthFeatureInterface +import MLSAuthFeatureTesting + +import RxSwift + +@Suite("TermsAgreementReactor - 동의 상태 관리") +struct TermsAgreementReactorTests { + + // MARK: - 전체 동의 토글 + + @Test("전체동의 ON: 모든 항목 true") + func toggleTotalOn_setsAllAgreementsTrue() { + let reactor = makeSUT() + let state = reactor.reduce(state: reactor.initialState, mutation: .setAgreeState(type: .total, isOn: true)) + + #expect(state.isTotalAgree == true) + #expect(state.isAgeAgree == true) + #expect(state.isServiceTermsAgree == true) + #expect(state.isPersonalInformationAgree == true) + #expect(state.isMarketingAgree == true) + } + + @Test("전체동의 OFF: 모든 항목 false") + func toggleTotalOff_setsAllAgreementsFalse() { + let reactor = makeSUT() + var state = reactor.reduce(state: reactor.initialState, mutation: .setAgreeState(type: .total, isOn: true)) + state = reactor.reduce(state: state, mutation: .setAgreeState(type: .total, isOn: false)) + + #expect(state.isTotalAgree == false) + #expect(state.isAgeAgree == false) + #expect(state.isServiceTermsAgree == false) + #expect(state.isPersonalInformationAgree == false) + #expect(state.isMarketingAgree == false) + } + + // MARK: - bottomButton 활성화 + + @Test("필수 3개(나이·서비스·개인정보) 동의: bottomButton 활성화") + func requiredFieldsAgreed_enablesBottomButton() { + let reactor = makeSUT() + var state = reactor.initialState + state = reactor.reduce(state: state, mutation: .setAgreeState(type: .age, isOn: true)) + state = reactor.reduce(state: state, mutation: .setAgreeState(type: .serviceTerms, isOn: true)) + state = reactor.reduce(state: state, mutation: .setAgreeState(type: .personalInfo, isOn: true)) + + #expect(state.bottomButtonIsEnabled == true) + } + + @Test("마케팅만 동의: bottomButton 비활성화") + func onlyMarketingAgreed_disablesBottomButton() { + let reactor = makeSUT() + let state = reactor.reduce(state: reactor.initialState, mutation: .setAgreeState(type: .marketing, isOn: true)) + + #expect(state.bottomButtonIsEnabled == false) + } + + @Test("필수 항목 하나 누락: bottomButton 비활성화") + func missingOneRequired_disablesBottomButton() { + let reactor = makeSUT() + var state = reactor.initialState + state = reactor.reduce(state: state, mutation: .setAgreeState(type: .age, isOn: true)) + state = reactor.reduce(state: state, mutation: .setAgreeState(type: .serviceTerms, isOn: true)) + // personalInfo 미동의 + + #expect(state.bottomButtonIsEnabled == false) + } + + // MARK: - isTotalAgree 조건 + + @Test("필수 3개만 동의: isTotalAgree false") + func requiredOnlyAgreed_isTotalAgreeFalse() { + let reactor = makeSUT() + var state = reactor.initialState + state = reactor.reduce(state: state, mutation: .setAgreeState(type: .age, isOn: true)) + state = reactor.reduce(state: state, mutation: .setAgreeState(type: .serviceTerms, isOn: true)) + state = reactor.reduce(state: state, mutation: .setAgreeState(type: .personalInfo, isOn: true)) + + #expect(state.isTotalAgree == false) + } + + @Test("필수 3개 + 마케팅 동의: isTotalAgree true") + func allIncludingMarketing_isTotalAgreeTrue() { + let reactor = makeSUT() + let state = reactor.reduce(state: reactor.initialState, mutation: .setAgreeState(type: .total, isOn: true)) + + #expect(state.isTotalAgree == true) + } +} + +// MARK: - Helpers + +private func makeSUT() -> TermsAgreementReactor { + TermsAgreementReactor( + credential: Credential(token: "", providerID: ""), + socialPlatform: .kakao, + socialSignUpUseCase: MockSocialSignUpUseCase(), + tokenRepository: MockTokenRepository() + ) +} diff --git a/MLS/MLSAuthFeatureExample/AppDelegate.swift b/MLS/MLSAuthFeatureExample/AppDelegate.swift new file mode 100644 index 00000000..55e88a8e --- /dev/null +++ b/MLS/MLSAuthFeatureExample/AppDelegate.swift @@ -0,0 +1,18 @@ +import UIKit + +import MLSDesignSystem + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + FontManager.registerFonts() + return true + } + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + } +} diff --git a/MLS/MLSAuthFeatureExample/Assets.xcassets/AccentColor.colorset/Contents.json b/MLS/MLSAuthFeatureExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/MLS/MLSAuthFeatureExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSAuthFeatureExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/MLS/MLSAuthFeatureExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/MLS/MLSAuthFeatureExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSAuthFeatureExample/Assets.xcassets/Contents.json b/MLS/MLSAuthFeatureExample/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/MLS/MLSAuthFeatureExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSAuthFeatureExample/Base.lproj/LaunchScreen.storyboard b/MLS/MLSAuthFeatureExample/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..865e9329 --- /dev/null +++ b/MLS/MLSAuthFeatureExample/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MLS/MLSAuthFeatureExample/Base.lproj/Main.storyboard b/MLS/MLSAuthFeatureExample/Base.lproj/Main.storyboard new file mode 100644 index 00000000..25a76385 --- /dev/null +++ b/MLS/MLSAuthFeatureExample/Base.lproj/Main.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MLS/MLSAuthFeatureExample/Info.plist b/MLS/MLSAuthFeatureExample/Info.plist new file mode 100644 index 00000000..0eb786dc --- /dev/null +++ b/MLS/MLSAuthFeatureExample/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/MLS/MLSAuthFeatureExample/SceneDelegate.swift b/MLS/MLSAuthFeatureExample/SceneDelegate.swift new file mode 100644 index 00000000..0e253ecd --- /dev/null +++ b/MLS/MLSAuthFeatureExample/SceneDelegate.swift @@ -0,0 +1,112 @@ +import UIKit + +import MLSAuthFeature +import MLSAuthFeatureInterface +import MLSAuthFeatureTesting + +class SceneDelegate: UIResponder, UIWindowSceneDelegate, AppCoordinatorProtocol { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + let window = UIWindow(windowScene: windowScene) + self.window = window + + let loginVC = makeLoginViewController() + let nav = UINavigationController(rootViewController: loginVC) + nav.navigationBar.isHidden = true + window.rootViewController = nav + window.makeKeyAndVisible() + } + + // MARK: - AppCoordinatorProtocol + + func showMainTab() { + let alert = UIAlertController(title: "로그인 성공 🎉", message: "메인 화면으로 이동합니다.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + window?.rootViewController?.present(alert, animated: true) + } + + func showLogin(exitRoute: LoginExitRoute) { + guard let nav = window?.rootViewController as? UINavigationController else { return } + let loginVC = makeLoginViewController() + nav.setViewControllers([loginVC], animated: true) + } + + // MARK: - Private + + private func makeLoginViewController() -> UIViewController { + let tokenRepository = MockTokenRepository() + let userDefaultsRepository = MockUserDefaultsRepository() + let authRepository = MockAuthAPIRepository() + + let appleProvider = MockAppleCredentialProvider() + let kakaoProvider = MockKakaoCredentialProvider() + + let factory = LoginFactoryImpl( + termsAgreementsFactory: makeTermsAgreementFactory( + authRepository: authRepository, + tokenRepository: tokenRepository, + userDefaultsRepository: userDefaultsRepository + ), + appleProvider: appleProvider, + kakaoProvider: kakaoProvider, + socialLoginUseCase: SocialLoginUseCaseImpl( + authRepository: authRepository, + tokenRepository: tokenRepository, + userDefaultsRepository: userDefaultsRepository + ), + userDefaultsRepository: userDefaultsRepository + ) + + return factory.make(exitRoute: .home, onLoginCompleted: { [weak self] in + self?.showMainTab() + }) + } + + private func makeTermsAgreementFactory( + authRepository: AuthAPIRepository, + tokenRepository: TokenRepository, + userDefaultsRepository: UserDefaultsRepository + ) -> TermsAgreementFactory { + let onBoardingInputFactory = OnBoardingInputFactoryImpl( + checkEmptyUseCase: CheckEmptyLevelAndRoleUseCaseImpl(), + checkValidLevelUseCase: CheckValidLevelUseCaseImpl(), + authRepository: authRepository, + onBoardingNotificationFactory: makeOnBoardingNotificationFactory( + authRepository: authRepository + ), + appCoordinator: { [weak self] in self! } + ) + + let onBoardingQuestionFactory = OnBoardingQuestionFactoryImpl( + onBoardingInputFactory: onBoardingInputFactory + ) + + return TermsAgreementFactoryImpl( + onBoardingQuestionFactory: onBoardingQuestionFactory, + socialSignUpUseCase: SocialSignUpUseCaseImpl( + authRepository: authRepository, + tokenRepository: tokenRepository, + userDefaultsRepository: userDefaultsRepository + ), + tokenRepository: tokenRepository + ) + } + + private func makeOnBoardingNotificationFactory( + authRepository: AuthAPIRepository + ) -> OnBoardingNotificationFactory { + let sheetFactory = OnBoardingNotificationSheetFactoryImpl( + authRepository: authRepository, + appCoordinator: { [weak self] in self! } + ) + + return OnBoardingNotificationFactoryImpl( + onBoardingNotificationSheetFactory: sheetFactory, + appCoordinator: { [weak self] in self! } + ) + } +} diff --git a/MLS/MLSAuthFeatureExample/ViewController.swift b/MLS/MLSAuthFeatureExample/ViewController.swift new file mode 100644 index 00000000..4fcaa0d4 --- /dev/null +++ b/MLS/MLSAuthFeatureExample/ViewController.swift @@ -0,0 +1,4 @@ +import UIKit + +// SceneDelegate에서 직접 LoginViewController를 띄우기 때문에 사용하지 않습니다. +class ViewController: UIViewController {} diff --git a/MLS/MLSCore/.gitignore b/MLS/MLSCore/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/MLS/MLSCore/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/MLS/MLSCore/Package.swift b/MLS/MLSCore/Package.swift new file mode 100644 index 00000000..57a153fb --- /dev/null +++ b/MLS/MLSCore/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MLSCore", + platforms: [.iOS(.v15)], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "MLSCore", + targets: ["MLSCore"] + ) + ], + dependencies: [ + .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.7.0"), + .package(url: "https://github.com/RxSwiftCommunity/RxKeyboard.git", from: "2.0.0") + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "MLSCore", + dependencies: [ + .product(name: "RxSwift", package: "RxSwift"), + .product(name: "RxCocoa", package: "RxSwift"), + .product(name: "RxRelay", package: "RxSwift"), + .product(name: "RxKeyboard", package: "RxKeyboard") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .testTarget( + name: "MLSCoreTests", + dependencies: ["MLSCore"] + ) + ] +) diff --git a/MLS/MLSCore/Sources/MLSCore/BaseController/BaseViewController.swift b/MLS/MLSCore/Sources/MLSCore/BaseController/BaseViewController.swift new file mode 100644 index 00000000..d8f66210 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/BaseController/BaseViewController.swift @@ -0,0 +1,57 @@ +import os +import UIKit + +import RxKeyboard +import RxSwift + +open class BaseViewController: UIViewController, Loggable { + private let disposeBag = DisposeBag() + + public var isBottomTabbarHidden: Bool = false + + public init() { + super.init(nibName: nil, bundle: nil) + logDebug("init \(String(describing: self))") + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + logDebug("deinit: \(String(describing: self))") + } +} + +// MARK: - Life Cycle +extension BaseViewController { + override open func viewDidLoad() { + super.viewDidLoad() + configureUI() + } +} + +// MARK: - SetUp +private extension BaseViewController { + func configureUI() { + navigationController?.navigationBar.isHidden = true + view.backgroundColor = .systemBackground + } +} + +// MARK: - Methods +public extension BaseViewController { + func setupKeyboard(inset: CGFloat = 0, completion: @escaping (CGFloat) -> Void) { + RxKeyboard.instance.visibleHeight + .drive(onNext: { [weak self] height in + guard let self = self else { return } + let safeBottom = self.view.safeAreaInsets.bottom + let inset = height > 0 + ? height - safeBottom + inset + : inset + completion(inset) + }) + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/CompositionalLayoutBuilder/CompositionalLayoutBuilder.swift b/MLS/MLSCore/Sources/MLSCore/CompositionalLayoutBuilder/CompositionalLayoutBuilder.swift new file mode 100644 index 00000000..bf0562a0 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/CompositionalLayoutBuilder/CompositionalLayoutBuilder.swift @@ -0,0 +1,29 @@ +import UIKit + +public final class CompositionalLayoutBuilder { + private var sections: [NSCollectionLayoutSection] = [] + + public init() {} + + @discardableResult + public func section(_ build: (CompositionalSectionBuilder) -> CompositionalSectionBuilder) -> Self { + let builder = CompositionalSectionBuilder() + if let section = build(builder).build() { + sections.append(section) + } + return self + } + + @MainActor + public func build() -> UICollectionViewCompositionalLayout { + return UICollectionViewCompositionalLayout { index, _ in + guard index < self.sections.count else { return nil } + return self.sections[index] + } + } + + public func setSections(_ sections: [NSCollectionLayoutSection]) -> Self { + self.sections = sections + return self + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/CompositionalLayoutBuilder/CompositionalSectionBuilder.swift b/MLS/MLSCore/Sources/MLSCore/CompositionalLayoutBuilder/CompositionalSectionBuilder.swift new file mode 100644 index 00000000..98a39810 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/CompositionalLayoutBuilder/CompositionalSectionBuilder.swift @@ -0,0 +1,201 @@ +import UIKit + +public final class CompositionalSectionBuilder { + private var item: NSCollectionLayoutItem? + private var group: NSCollectionLayoutGroup? + private var section: NSCollectionLayoutSection? + + public enum Direction { + case horizontal, vertical + } + + public init(item: NSCollectionLayoutItem? = nil, group: NSCollectionLayoutGroup? = nil, section: NSCollectionLayoutSection? = nil) { + self.item = item + self.group = group + self.section = section + } + + @discardableResult + public func item(width: NSCollectionLayoutDimension, height: NSCollectionLayoutDimension) -> Self { + let size = NSCollectionLayoutSize(widthDimension: width, heightDimension: height) + item = NSCollectionLayoutItem(layoutSize: size) + return self + } + + @discardableResult + public func group(_ direction: Direction, width: NSCollectionLayoutDimension, height: NSCollectionLayoutDimension, count: Int? = nil) -> Self { + guard let item = item else { return self } + let size = NSCollectionLayoutSize(widthDimension: width, heightDimension: height) + switch direction { + case .horizontal: + if let count = count { + group = NSCollectionLayoutGroup.horizontal(layoutSize: size, subitem: item, count: count) + } else { + group = NSCollectionLayoutGroup.horizontal(layoutSize: size, subitems: [item]) + } + case .vertical: + if let count = count { + group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitem: item, count: count) + } else { + group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item]) + } + } + return self + } + + @discardableResult + public func customGroup(group: (NSCollectionLayoutItem) -> NSCollectionLayoutGroup) -> Self { + guard let item = item else { return self } + self.group = group(item) + return self + } + + @discardableResult + public func buildSection() -> Self { + guard let group = group else { return self } + section = NSCollectionLayoutSection(group: group) + return self + } + + @discardableResult + public func visibleItemsInvalidationHandler( + handler: @escaping ([any NSCollectionLayoutVisibleItem], CGPoint, any NSCollectionLayoutEnvironment) -> Void + ) -> Self { + section?.visibleItemsInvalidationHandler = { visibleItems, scrollOffset, environment in + handler(visibleItems, scrollOffset, environment) + } + return self + } + + @discardableResult + public func orthogonalScrolling(_ behavior: UICollectionLayoutSectionOrthogonalScrollingBehavior) -> Self { + section?.orthogonalScrollingBehavior = behavior + return self + } + + @discardableResult + public func contentInsets(_ insets: NSDirectionalEdgeInsets) -> Self { + section?.contentInsets = insets + return self + } + + @discardableResult + public func header(height: CGFloat, isSticky: Bool = false) -> Self { + guard let section = section else { return self } + + let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(height)) + let header = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: size, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ) + + header.pinToVisibleBounds = isSticky + section.boundarySupplementaryItems = [header] + return self + } + + public func build() -> NSCollectionLayoutSection? { + return section + } + + @discardableResult + public func interGroupSpacing(_ spacing: CGFloat) -> Self { + section?.interGroupSpacing = spacing + return self + } + + @discardableResult + public func interItemSpacing(_ spacing: NSCollectionLayoutSpacing) -> Self { + group?.interItemSpacing = spacing + return self + } + + @discardableResult + public func footer(height: CGFloat) -> Self { + let footer = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: .init( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(height) + ), + elementKind: UICollectionView.elementKindSectionFooter, + alignment: .bottom + ) + section?.boundarySupplementaryItems.append(footer) + return self + } + + @discardableResult + public func supplementaryItem( + kind: String, + height: CGFloat, + alignment: NSRectAlignment + ) -> Self { + let header = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: .init( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(height) + ), + elementKind: kind, + alignment: alignment + ) + section?.boundarySupplementaryItems.append(header) + return self + } + + @discardableResult + public func decorationItem( + kind: String, + insets: NSDirectionalEdgeInsets = .zero + ) -> Self { + let decoration = NSCollectionLayoutDecorationItem.background(elementKind: kind) + decoration.contentInsets = insets + section?.decorationItems.append(decoration) + return self + } + + @discardableResult + public func nestedGroup( + outerDirection: Direction, + outerWidth: NSCollectionLayoutDimension, + outerHeight: NSCollectionLayoutDimension, + innerDirection: Direction, + innerWidth: NSCollectionLayoutDimension, + innerHeight: NSCollectionLayoutDimension, + innerCount: Int? = nil, + innerSpacing: CGFloat + ) -> Self { + guard let item = item else { return self } + + // 내부 그룹 + let innerSize = NSCollectionLayoutSize(widthDimension: innerWidth, heightDimension: innerHeight) + let innerGroup: NSCollectionLayoutGroup + switch innerDirection { + case .horizontal: + if let count = innerCount { + innerGroup = NSCollectionLayoutGroup.horizontal(layoutSize: innerSize, subitem: item, count: count) + } else { + innerGroup = NSCollectionLayoutGroup.horizontal(layoutSize: innerSize, subitems: [item]) + } + innerGroup.interItemSpacing = .fixed(innerSpacing) + case .vertical: + if let count = innerCount { + innerGroup = NSCollectionLayoutGroup.vertical(layoutSize: innerSize, subitem: item, count: count) + } else { + innerGroup = NSCollectionLayoutGroup.vertical(layoutSize: innerSize, subitems: [item]) + } + innerGroup.interItemSpacing = .fixed(innerSpacing) + } + + // 외부 그룹 + let outerSize = NSCollectionLayoutSize(widthDimension: outerWidth, heightDimension: outerHeight) + switch outerDirection { + case .horizontal: + group = NSCollectionLayoutGroup.horizontal(layoutSize: outerSize, subitems: [innerGroup]) + case .vertical: + group = NSCollectionLayoutGroup.vertical(layoutSize: outerSize, subitems: [innerGroup]) + } + + return self + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/DIContainer/DIContainer.swift b/MLS/MLSCore/Sources/MLSCore/DIContainer/DIContainer.swift new file mode 100644 index 00000000..9829eb20 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/DIContainer/DIContainer.swift @@ -0,0 +1,82 @@ +import Foundation + +/// 의존성 주입을 위한 컨테이너 +/// NSRecursiveLock을 사용하여 Thread-safe하고 재진입 가능하게 동작 +/// 싱글톤 인스턴스를 캐싱하여 재사용 +public final class DIContainer: Loggable, @unchecked Sendable { + static let shared = DIContainer() + + /// 등록된 서비스 팩토리 클로저: [타입 식별자: [이름 식별자: 팩토리 클로저]] + private var services: [ObjectIdentifier: [String: () -> Any]] = [:] + + /// 캐싱된 싱글톤 인스턴스: [타입 식별자: [이름 식별자: 인스턴스]] + private var instances: [ObjectIdentifier: [String: Any]] = [:] + + /// 동시성 제어를 위한 재진입 가능한 Lock + /// register 내부에서 resolve를 호출해도 데드락이 발생하지 않음 + private let lock = NSRecursiveLock() + + /// 의존성 구현체 등록 + /// - Parameters: + /// - type: 등록할 타입 + /// - name: 같은 타입의 서로 다른 구현체를 구분하기 위한 식별자 (기본값: "default") + /// - object: 인스턴스를 생성하는 팩토리 클로저 + public static func register(type: T.Type, name: String? = nil, object: @escaping () -> T) { + shared.register(type: type, name: name, object: object) + } + + /// 등록된 의존성 인스턴스 반환 + /// 싱글톤으로 동작하여 같은 타입과 이름으로 요청 시 동일한 인스턴스 반환 + /// - Parameters: + /// - type: 가져올 타입 + /// - name: 구현체 식별자 (기본값: "default") + /// - Returns: 등록된 싱글톤 인스턴스 + public static func resolve(type: T.Type, name: String? = nil) -> T { + shared.resolve(type: type, name: name) + } +} + +private extension DIContainer { + private func register(type: T.Type, name: String?, object: @escaping () -> T) { + lock.lock() + defer { lock.unlock() } + + let key = ObjectIdentifier(type) + let nameKey = name ?? "default" + var namedServices = services[key] ?? [:] + + if namedServices[nameKey] != nil { + logWarning("[\(type)] '\(nameKey)' 이름으로 이미 등록되어 있습니다. 새로운 구현체로 덮어씁니다.") + } + + namedServices[nameKey] = { object() } + services[key] = namedServices + } + + private func resolve(type: T.Type, name: String?) -> T { + lock.lock() + defer { lock.unlock() } + + let key = ObjectIdentifier(type) + let nameKey = name ?? "default" + + if let instance = instances[key]?[nameKey] as? T { + return instance + } + + guard let serviceFactory = services[key]?[nameKey] else { + preconditionFailure("[\(type)] '\(nameKey)' 이름으로 등록되지 않았습니다. register를 먼저 호출하세요.") + } + + let newInstance = serviceFactory() + var namedInstances = instances[key] ?? [:] + namedInstances[nameKey] = newInstance + instances[key] = namedInstances + + guard let typedInstance = newInstance as? T else { + preconditionFailure("[\(type)] 타입 캐스팅에 실패했습니다.") + } + + return typedInstance + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/Extension/Reactive+.swift b/MLS/MLSCore/Sources/MLSCore/Extension/Reactive+.swift new file mode 100644 index 00000000..68a22b6c --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Extension/Reactive+.swift @@ -0,0 +1,32 @@ +import UIKit + +import RxCocoa +import RxSwift + +public extension Reactive where Base: UIViewController { + + var viewDidLoad: ControlEvent { + let source = self.methodInvoked(#selector(Base.viewDidLoad)).map( { _ in }) + return ControlEvent(events: source) + } + + var viewWillAppear: ControlEvent { + let source = self.methodInvoked(#selector(Base.viewWillAppear)).map( { _ in }) + return ControlEvent(events: source) + } + + var viewDidAppear: ControlEvent { + let source = self.methodInvoked(#selector(Base.viewDidAppear)).map( { _ in }) + return ControlEvent(events: source) + } + + var viewWillDisappear: ControlEvent { + let source = self.methodInvoked(#selector(Base.viewWillDisappear)).map( { _ in }) + return ControlEvent(events: source) + } + + var viewDidDisappear: ControlEvent { + let source = self.methodInvoked(#selector(Base.viewDidDisappear)).map( { _ in }) + return ControlEvent(events: source) + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/Extension/UICollectionReusableView+.swift b/MLS/MLSCore/Sources/MLSCore/Extension/UICollectionReusableView+.swift new file mode 100644 index 00000000..19232f48 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Extension/UICollectionReusableView+.swift @@ -0,0 +1,7 @@ +import UIKit + +extension UICollectionReusableView { + public static var identifier: String { + return String(describing: self) + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/ImageLoader/ImageLoader.swift b/MLS/MLSCore/Sources/MLSCore/ImageLoader/ImageLoader.swift new file mode 100644 index 00000000..3e2bbc3e --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/ImageLoader/ImageLoader.swift @@ -0,0 +1,173 @@ +import UIKit + +public enum ImageLoaderError: Error { + case invalidURL + case networkError(description: String?) + case convertError(description: String?) + case cacheNotFoundError +} + +/// 이미지 로더 설정 클래스 +public final class ImageLoaderConfigure { + /// 메모리 캐시 만료 시간 (기본값 300초) + public var memoryCacheExpiration: TimeInterval = 300 + /// 메모리 캐시 갯수 제한 (기본값 100개) + public var memoryCacheCountLimit = 100 // 최대 100개까지 저장 + /// 메모리 코스트 제한 (최대 50MB까지 저장 기본 값 50MB) + public var memoryCacheTotalCostLimit = 50 * 1024 * 1024 + /// 디스크 캐시 만료 시간 (기본값 일주일) + public var diskCacheExpiration: TimeInterval = 7 * 24 * 60 * 60 // 7일 + /// 디스크 캐시 갯수 제한 + public var diskCacheCountLimit = 1000 // 최대 1000개 파일 + /// 디스크 캐시 용량 제한 (최대 500MB) + public var diskCacheSizeLimit = 500 * 1024 * 1024 // 500MB + /// 기본 이미지 + public var defaultImage: UIImage? = UIImage() +} + +/// URL을 통해 이미지를 비동기적으로 로드하는 클래스 +public final class ImageLoader: @unchecked Sendable { + public static let shared = ImageLoader() + + /// 이미지 로더 설정 객체 + public let configure = ImageLoaderConfigure() + + private init() {} + + public func loadImage(stringURL: String?, defaultImage: UIImage? = nil, completion: @escaping @Sendable (UIImage?) -> Void) { + guard let stringURL, + let url = URL(string: stringURL), + ["http", "https"].contains(url.scheme?.lowercased() ?? "") + else { + DispatchQueue.main.async { + completion(defaultImage) + } + return + } + loadImage(url: url, defaultImage: defaultImage ?? configure.defaultImage, completion: completion) + } + + /// URL을 통해 이미지를 로드하고, 실패 시 기본 이미지를 반환하는 메서드 + /// - Parameters: + /// - stringURL: 이미지 URL 문자열 + /// - defaultImage: 로드 실패 시 반환할 기본 이미지 + /// - completion: 로드 완료 후 호출되는 클로저 + public func loadImage(url: URL?, defaultImage: UIImage? = nil, completion: @escaping @Sendable (UIImage?) -> Void) { + loadImage(url: url) { result in + DispatchQueue.main.async { + switch result { + case .success(let image): + completion(image) + case .failure: + completion(defaultImage ?? self.configure.defaultImage) + } + } + } + } +} + +private extension ImageLoader { + /// 네트워크 및 스토리지를 통해 이미지를 로드하는 내부 메서드 + /// - Parameters: + /// - stringURL: 이미지 URL 문자열 + /// - completion: 로드 완료 후 호출되는 클로저 + func loadImage(url: URL?, completion: @escaping (Result) -> Void) { + guard let url else { + completion(.failure(ImageLoaderError.invalidURL)) + return + } + + // 1. 메모리 캐시 확인 + if let cachedImage = MemoryStorage.shared.fetchImage(stringURL: url.absoluteString) { + completion(.success(cachedImage)) + return + } + + // 2. 디스크 캐시 확인 + DiskStorage.shared.fetchImage(url: url) { image in + if let image { + MemoryStorage.shared.saveImage(image: image, stringURL: url.absoluteString) + completion(.success(image)) + } else { + // 3. 네트워크 요청 + self.fetchDataFrom(url: url) { result in + switch result { + case .success(let data): + guard let data else { + completion(.failure(ImageLoaderError.convertError(description: "No data received"))) + return + } + + self.decodeImageData(data, url: url, completion: completion) + + case .failure(let error): + completion(.failure(error)) + } + } + } + } + } + + private func decodeImageData(_ data: Data, url: URL, completion: @escaping (Result) -> Void) { + if let gifImage = decodeGIF(data: data, url: url) { + completion(.success(gifImage)) + return + } + + // 정적 이미지 처리 + if let image = UIImage(data: data) { + MemoryStorage.shared.saveImage(image: image, stringURL: url.absoluteString) + DiskStorage.shared.saveImage(image: image, url: url) + completion(.success(image)) + } else { + completion(.failure(ImageLoaderError.convertError(description: "Failed to convert data to UIImage"))) + } + } + + private func decodeGIF(data: Data, url: URL) -> UIImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + + let frameCount = CGImageSourceGetCount(source) + guard frameCount > 1 else { return nil } // GIF가 아님 + + var images: [UIImage] = [] + var duration: TimeInterval = 0 + + for index in 0 ..< frameCount { + guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else { continue } + let uiImage = UIImage(cgImage: cgImage) + images.append(uiImage) + + if let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [String: Any], + let gifProps = properties[kCGImagePropertyGIFDictionary as String] as? [String: Any], + let delay = gifProps[kCGImagePropertyGIFUnclampedDelayTime as String] as? Double { + duration += delay + } + } + + let finalDuration = duration > 0 ? duration : Double(frameCount) * 0.1 + let animatedImage = UIImage.animatedImage(with: images, duration: finalDuration) + + if let animatedImage { + MemoryStorage.shared.saveImage(image: animatedImage, stringURL: url.absoluteString) + DiskStorage.shared.saveImage(image: animatedImage, url: url) + } + + return animatedImage + } + + /// URL을 통해 데이터를 요청하는 메서드 + /// - Parameters: + /// - url: 요청할 URL 객체 + /// - completion: 요청 완료 후 호출되는 클로저 + func fetchDataFrom(url: URL, completion: @escaping (Result) -> Void) { + let task = URLSession.shared.dataTask(with: url) { data, _, error in + if let error = error { + completion(.failure(ImageLoaderError.networkError(description: "Network Error: \(error.localizedDescription)"))) + return + } + completion(.success(data)) + } + task.resume() + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/ImageLoader/Storage/DiskStorage.swift b/MLS/MLSCore/Sources/MLSCore/ImageLoader/Storage/DiskStorage.swift new file mode 100644 index 00000000..2582c484 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/ImageLoader/Storage/DiskStorage.swift @@ -0,0 +1,209 @@ +import os +import UIKit + +/// 캐시할 이미지와 추가 데이터를 저장하는 구조체 +internal struct CacheData { + let fileName: String + let creationDate: Date + let expirationDate: Date + let fileSize: Int + + /// 초기화 메소드 + /// - Parameters: + /// - fileName: 재구성한 파일명 + /// - creationDate: 만료일 계산을 위한 생성날짜 + /// - expiration: 계산된 만료기간 + /// - fileSize: 파일 크기 + init(fileName: String, creationDate: Date, expiration: TimeInterval, fileSize: Int) { + self.fileName = fileName + self.creationDate = creationDate + self.expirationDate = creationDate.addingTimeInterval(expiration) + self.fileSize = fileSize + } + + var isExpired: Bool { + return Date() > expirationDate + } +} + +internal final class DiskStorage: @unchecked Sendable { + // 싱글톤 인스턴스 + static let shared = DiskStorage() + + private let fileManager = FileManager.default + private let cacheDirectory: URL + // 우선순위 보장과 저장시 안정성을 위한 큐 + private let queue = DispatchQueue(label: "com.diskStorage", attributes: .concurrent) + // fileName을 키로 캐시데이터 저장 + private var cacheDatas: [String: CacheData] = [:] + private var totalCacheSize: Int = 0 + + private init() { + guard let cacheDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first else { + fatalError("시스템 손상") + } + self.cacheDirectory = cacheDirectory.appendingPathComponent("ImageCache") + try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + // 캐시저장소 로드 + loadCacheData() + // 주기적으로 정리 + startCacheCleanup() + } + + /// 캐싱된 이미지를 불러오는 메소드 + /// - Parameters: + /// - url: 이미지 주소 + /// - completion: url에 해당하는 이미지를 return (없으면 nil) + func fetchImage(url: URL, completion: @escaping (UIImage?) -> Void) { + queue.async { [weak self] in + guard let self else { + DispatchQueue.main.async { completion(nil) } + return + } + + let fileName = self.createFilePath(url: url) + let fileURL = self.cacheDirectory.appendingPathComponent(fileName) + + // 기존 저장소 확인 + guard let cacheData = self.cacheDatas[fileName], !cacheData.isExpired else { + self.cleanCache(key: fileName) + DispatchQueue.main.async { completion(nil) } + return + } + + if self.fileManager.fileExists(atPath: fileURL.path), + let imageData = try? Data(contentsOf: fileURL), + let image = UIImage(data: imageData) { + DispatchQueue.main.async { completion(image) } + } else { + // 파일이 손상된 경우에만 cleanCache 호출 + self.cleanCache(key: fileName) + DispatchQueue.main.async { completion(nil) } + } + } + } + + /// 이미지를 캐시에 저장하는 메소드 + /// - Parameters: + /// - image: 이미지 + /// - url: 이미지 주소 + func saveImage(image: UIImage, url: URL) { + queue.async(flags: .barrier) { [weak self] in + guard let self, let data = image.jpegData(compressionQuality: 0.8) else { return } + + let fileName = self.createFilePath(url: url) + let fileURL = self.cacheDirectory.appendingPathComponent(fileName) + + if !self.fileManager.fileExists(atPath: fileURL.path) { + do { + try data.write(to: fileURL) + let cacheData = CacheData( + fileName: fileName, + creationDate: Date(), + expiration: ImageLoader.shared.configure.diskCacheExpiration, + fileSize: data.count + ) + self.cacheDatas[fileName] = cacheData + self.totalCacheSize += data.count + self.checkCache() + } catch { + os_log("디스크 캐시 저장 실패") + } + } + } + } + + /// 충돌 방지를 위한 파일 이름 재구성 메소드 + /// - Parameter url: 이미지 주소 + /// - Returns: 변경된 파일명을 return + private func createFilePath(url: URL) -> String { + // ToBe: - 파일명 길이 확인 + url.absoluteString + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: ":", with: "_") + .addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? url.lastPathComponent + } + + /// 캐시저장소를 로드하는 메소드 + private func loadCacheData() { + queue.async(flags: .barrier) { [weak self] in + guard let self else { return } + + do { + let files = try self.fileManager.contentsOfDirectory(at: self.cacheDirectory, includingPropertiesForKeys: [.creationDateKey, .fileSizeKey]) + for fileURL in files { + let fileName = fileURL.lastPathComponent + let attributes = try fileURL.resourceValues(forKeys: [.creationDateKey, .fileSizeKey]) + let creationDate = attributes.creationDate ?? Date() + let fileSize = attributes.fileSize ?? 0 + + // 기본 만료 시간 적용 + let cacheData = CacheData( + fileName: fileName, + creationDate: creationDate, + expiration: ImageLoader.shared.configure.diskCacheExpiration, + fileSize: fileSize + ) + self.cacheDatas[fileName] = cacheData + self.totalCacheSize += fileSize + } + } catch { + os_log("캐시 로드 실패") + } + } + } + + /// 캐시 삭제를 위한 제약을 검사하는 메소드 + private func checkCache() { + queue.async(flags: .barrier) { [weak self] in + guard let self else { return } + + let config = ImageLoader.shared.configure + + /// 만료된 캐시 제거 + self.cacheDatas.forEach { key, data in + if data.isExpired { + self.cleanCache(key: key) + } + } + + /// 개수 제한 + while self.cacheDatas.count > config.diskCacheCountLimit { + if let oldest = self.cacheDatas.min(by: { $0.value.creationDate < $1.value.creationDate }) { + self.cleanCache(key: oldest.key) + } + } + + /// 용량 제한 + while self.totalCacheSize > config.diskCacheSizeLimit { + if let oldest = self.cacheDatas.min(by: { $0.value.creationDate < $1.value.creationDate }) { + self.cleanCache(key: oldest.key) + } + } + } + } + + /// 저장된 캐시를 제거하는 메소드 + private func cleanCache(key: String) { + queue.async(flags: .barrier) { [weak self] in + guard let self, let cacheData = self.cacheDatas[key] else { return } + let fileURL = self.cacheDirectory.appendingPathComponent(cacheData.fileName) + try? self.fileManager.removeItem(at: fileURL) + self.totalCacheSize -= cacheData.fileSize + self.cacheDatas.removeValue(forKey: key) + } + } + + /// 주기적으로 캐시를 정리하는 메소드 + private func startCacheCleanup() { + DispatchQueue.global(qos: .background).async { [weak self] in + let timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { _ in + self?.checkCache() + } + // 백그라운드에서 실행되는 타이머를 메인 루프에 추가 + RunLoop.current.add(timer, forMode: .common) + // 백그라운드 스레드에서 타이머를 계속 실행하기 위해 RunLoop를 유지 + RunLoop.current.run() + } + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/ImageLoader/Storage/MemoryStorage.swift b/MLS/MLSCore/Sources/MLSCore/ImageLoader/Storage/MemoryStorage.swift new file mode 100644 index 00000000..db64af75 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/ImageLoader/Storage/MemoryStorage.swift @@ -0,0 +1,101 @@ +import UIKit + +/// 캐시할 이미지와 만료 시간을 저장하는 클래스 +internal class StorageData: NSObject { + let image: UIImage? /// 캐시된 이미지 + let expirationDate: Date /// 캐시 만료 시간 + + /// 초기화 메서드 + /// - Parameters: + /// - image: 저장할 이미지 + /// - expiration: 만료 시간 (초 단위) + init(image: UIImage?, expiration: TimeInterval) { + self.image = image + self.expirationDate = Date().addingTimeInterval(expiration) + } + + /// 캐시가 만료되었는지 확인하는 메서드 + /// - Returns: 만료 여부 (true: 만료됨, false: 유효함) + func isExpired() -> Bool { + return Date() > expirationDate + } +} + +/// 메모리 캐시를 관리하는 클래스 +internal final class MemoryStorage: @unchecked Sendable { + + /// 싱글톤 인스턴스 + static let shared = MemoryStorage() + + /// 이미지 캐시 저장소 + private let cache: NSCache = { + let cache = NSCache() + cache.countLimit = ImageLoader.shared.configure.memoryCacheCountLimit + cache.totalCostLimit = ImageLoader.shared.configure.memoryCacheTotalCostLimit + return cache + }() + + /// 현재 캐시에 저장된 키 목록 + private var cachedKeys: Set = [] + + /// 초기화 (자동 캐시 정리 시작) + private init() { + startCacheCleanup() + } + + /// 이미지를 캐시에 저장하는 메서드 + /// - Parameters: + /// - image: 저장할 이미지 + /// - stringURL: 이미지 URL 문자열 + func saveImage(image: UIImage?, stringURL: String) { + let cachedData = StorageData(image: image, expiration: ImageLoader.shared.configure.memoryCacheExpiration) + let cost = image?.jpegData(compressionQuality: 1.0)?.count ?? 1 + cache.setObject(cachedData, forKey: stringURL as NSString, cost: cost) + cachedKeys.insert(stringURL) + } + + /// 캐시에서 이미지를 가져오는 메서드 + /// - Parameter stringURL: 이미지 URL 문자열 + /// - Returns: 캐시된 UIImage (없으면 nil) + func fetchImage(stringURL: String) -> UIImage? { + if let cachedData = cache.object(forKey: stringURL as NSString), !cachedData.isExpired() { + return cachedData.image + } else { + removeData(stringURL: stringURL) + return nil + } + } + + /// 특정 URL의 캐시 데이터를 제거하는 메서드 + /// - Parameter stringURL: 제거할 이미지의 URL 문자열 + func removeData(stringURL: String) { + cache.removeObject(forKey: stringURL as NSString) + cachedKeys.remove(stringURL) + } + + /// 모든 캐시 데이터를 삭제하는 메서드 + func clearCache() { + cache.removeAllObjects() + cachedKeys.removeAll() + } + + /// 주기적으로 만료된 캐시를 정리하는 메서드 + private func startCacheCleanup() { + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self = self else { return } + let cleanTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { _ in + for key in self.cachedKeys { + let nsKey = key as NSString + if let cachedData = self.cache.object(forKey: nsKey), cachedData.isExpired() { + self.cache.removeObject(forKey: nsKey) + self.cachedKeys.remove(key) + } + } + } + // 백그라운드에서 실행되는 타이머를 메인 루프에 추가 + RunLoop.current.add(cleanTimer, forMode: .common) + // 백그라운드 스레드에서 타이머를 계속 실행하기 위해 RunLoop를 유지 + RunLoop.current.run() + } + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/Logger/Loggable.swift b/MLS/MLSCore/Sources/MLSCore/Logger/Loggable.swift new file mode 100644 index 00000000..03b0f3ad --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Logger/Loggable.swift @@ -0,0 +1,56 @@ +import OSLog + +public protocol Loggable {} + +public extension Loggable { + /// 모듈 이름을 자동으로 추출 + /// - Returns: 타입이 정의된 모듈 이름 (예: "AuthFeature", "BookmarkFeature") + static var subsystem: String { + let fullName = String(reflecting: Self.self) + // "AuthFeature.LoginViewModel" -> "AuthFeature" + return fullName.components(separatedBy: ".").first ?? "Unknown" + } + + /// 타입 이름을 카테고리로 사용 + /// - Returns: 타입 이름 (예: "LoginViewModel", "BookmarkManager") + static var category: String { + String(describing: Self.self) + } + + /// 타입별로 캐싱된 Logger 인스턴스 + private static var logger: Logger { + LoggerStorage.logger(subsystem: subsystem, category: category) + } + + /// 디버그 로그 (Debug 빌드에서만 출력) + /// - Parameter message: 로그 메시지 (.private로 보호되어 민감 정보도 안전하게 로깅) + func logDebug(_ message: String) { + #if DEBUG + Self.logger.debug("\(message, privacy: .private)") + #endif + } + + /// 정보성 로그 (Notice 레벨) + /// - Parameter message: 로그 메시지 + func logNotice(_ message: String) { + Self.logger.notice("\(message)") + } + + /// 경고 로그 (비정상적이지만 처리 가능한 상황) + /// - Parameter message: 로그 메시지 + func logWarning(_ message: String) { + Self.logger.warning("\(message)") + } + + /// 에러 로그 + /// - Parameter message: 로그 메시지 + func logError(_ message: String) { + Self.logger.error("\(message)") + } + + /// 치명적 에러 로그 (앱 크래시 직전 상황) + /// - Parameter message: 로그 메시지 + func logCritical(_ message: String) { + Self.logger.critical("\(message)") + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/Logger/LoggerStorage.swift b/MLS/MLSCore/Sources/MLSCore/Logger/LoggerStorage.swift new file mode 100644 index 00000000..beeb4751 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Logger/LoggerStorage.swift @@ -0,0 +1,47 @@ +import OSLog + +/// Logger를 NSCache에 저장하기 위한 Wrapper 클래스 +/// Logger는 struct이므로 class로 감싸야 NSCache에 저장 가능 +private final class LoggerBox { + let logger: Logger + + init(logger: Logger) { + self.logger = logger + } +} + +/// Logger 인스턴스를 캐싱하여 재사용하는 저장소 +/// NSCache를 사용하여 메모리 압박 시 자동으로 해제되며, Thread-safe하게 동작 +final class LoggerStorage: @unchecked Sendable { + static let shared = LoggerStorage() + + /// 캐싱된 Logger 인스턴스들 + /// NSCache는 메모리 부족 시 자동으로 오래된 항목을 제거 + private let cache = NSCache() + + private init() {} + + /// Logger 인스턴스를 가져오거나 생성 + /// - Parameters: + /// - subsystem: 모듈 이름 (예: "AuthFeature") + /// - category: 카테고리 이름 (예: "LoginViewModel") + /// - Returns: 캐싱되거나 새로 생성된 Logger 인스턴스 + static func logger(subsystem: String, category: String) -> Logger { + shared.getOrCreateLogger(subsystem: subsystem, category: category) + } + + private func getOrCreateLogger(subsystem: String, category: String) -> Logger { + let key = "\(subsystem).\(category)" as NSString + + // 캐시에서 확인 + if let box = cache.object(forKey: key) { + return box.logger + } + + // 없으면 새로 생성 (NSCache는 thread-safe) + let newLogger = Logger(subsystem: subsystem, category: category) + let box = LoggerBox(logger: newLogger) + cache.setObject(box, forKey: key) + return newLogger + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/Network/EndPoint.swift b/MLS/MLSCore/Sources/MLSCore/Network/EndPoint.swift new file mode 100644 index 00000000..c6dbad65 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Network/EndPoint.swift @@ -0,0 +1,27 @@ +import Foundation + +/// 응답값이 없는 엔드포인트 +public struct EndPoint: Requestable { + public var baseURL: String + public var path: String + public var method: HTTPMethod + public var query: Encodable? + public var headers: [String: String]? + public var body: Encodable? + + public init( + baseURL: String, + path: String, + method: HTTPMethod, + query: Encodable? = nil, + headers: [String: String]? = nil, + body: Encodable? = nil + ) { + self.baseURL = baseURL + self.path = path + self.method = method + self.query = query + self.headers = headers + self.body = body + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/Network/HTTPMethod.swift b/MLS/MLSCore/Sources/MLSCore/Network/HTTPMethod.swift new file mode 100644 index 00000000..818b9aff --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Network/HTTPMethod.swift @@ -0,0 +1,5 @@ +import Foundation + +public enum HTTPMethod: String { + case GET, POST, DELETE, PUT +} diff --git a/MLS/MLSCore/Sources/MLSCore/Network/Interceptor.swift b/MLS/MLSCore/Sources/MLSCore/Network/Interceptor.swift new file mode 100644 index 00000000..5b5e87a8 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Network/Interceptor.swift @@ -0,0 +1,16 @@ +import Foundation + +public protocol Interceptor { + /// Request에 요소를 추가하기 위한 함수 + /// - Parameter request: 엔드포인트로 부터 만들어진 Request + /// - Returns: 요소가 추가된 Request + func adapt(_ request: URLRequest) -> URLRequest + + /// 필요에 따라 재요청을 하기 위한 함수 + /// - Parameters: + /// - request: 엔드포인트로 부터 만들어진 Request + /// - response: 돌려받은 응답 + /// - error: 통신간에 발생한 에러 + /// - Returns: 재요청이 필요하면 true, 필요없으면 false + func retry(data: Data?, response: URLResponse?, error: Error?) -> Bool +} diff --git a/MLS/MLSCore/Sources/MLSCore/Network/NetworkError.swift b/MLS/MLSCore/Sources/MLSCore/Network/NetworkError.swift new file mode 100644 index 00000000..203220d5 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Network/NetworkError.swift @@ -0,0 +1,39 @@ +import Foundation + +/// 네트워크 레이어에서 사용하는 에러 +public enum NetworkError: Error, Equatable { + case providerDeallocated + case urlRequest(Error) + case network(Error) + case invalidResponse + case noData + case decodeError(Error) + case httpError + case retryError(Error) + case statusError(Int, String) + case retry + + public static func == (lhs: NetworkError, rhs: NetworkError) -> Bool { + switch (lhs, rhs) { + case (.providerDeallocated, .providerDeallocated), + (.invalidResponse, .invalidResponse), + (.noData, .noData), + (.httpError, .httpError), + (.retry, .retry): + return true + + case let (.statusError(code1, msg1), .statusError(code2, msg2)): + return code1 == code2 && msg1 == msg2 + + // 연관된 Error 타입은 Equatable이 아니므로 항상 false로 비교 (혹은 타입 이름 비교 정도만 가능) + case (.urlRequest, .urlRequest), + (.network, .network), + (.decodeError, .decodeError), + (.retryError, .retryError): + return false + + default: + return false + } + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/Network/NetworkProvider.swift b/MLS/MLSCore/Sources/MLSCore/Network/NetworkProvider.swift new file mode 100644 index 00000000..1adb9ac2 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Network/NetworkProvider.swift @@ -0,0 +1,19 @@ +import Foundation + +import RxSwift + +public protocol NetworkProvider { + /// 엔드포인트를 이용하여 응답이 있는 통신을 위한 함수 + /// - Parameters: + /// - endPoint: 목적지에 대한 정보를 담고있는 엔드포인트 객체 + /// - interceptor: Request에 필요한 인터셉터 + /// - Returns: 응답값을 포함한 Response + func requestData(endPoint: T, interceptor: Interceptor?) -> Observable + + /// 엔드포인트를 이용하여 응답이 없는 통신을 위한 함수 + /// - Parameters: + /// - endPoint: 목적지에 대한 정보를 담고있는 엔드포인트 객체 + /// - interceptor: Request에 필요한 인터셉터 + /// - Returns: 통신이 완료되었는지만 확인 가능 + func requestData(endPoint: Requestable, interceptor: Interceptor?) -> Completable +} diff --git a/MLS/MLSCore/Sources/MLSCore/Network/NetworkProviderImpl.swift b/MLS/MLSCore/Sources/MLSCore/Network/NetworkProviderImpl.swift new file mode 100644 index 00000000..38b7be28 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Network/NetworkProviderImpl.swift @@ -0,0 +1,166 @@ +import Foundation + +import RxSwift + +public final class NetworkProviderImpl: NetworkProvider { + + private let session: URLSession + + private let retryAttempt: Int = 2 + + public init() { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForResource = 60 + self.session = URLSession(configuration: configuration) + } + + public func requestData(endPoint: T, interceptor: Interceptor?) -> Observable { + return Observable.create { [weak self] observer in + print("🚀 requestData: 요청 시작 - \(endPoint)") + + self?.sendRequest(endPoint: endPoint, interceptor: interceptor, completion: { result in + + switch result { + case .success(let data): + print("✅ requestData: 응답 수신") + + if let data = data { + print("📦 requestData: 응답 데이터 있음 - \(String(data: data, encoding: .utf8) ?? "디코딩 실패")") + do { + let decoded = try JSONDecoder().decode(T.Response.self, from: data) + print("🎯 requestData: 디코딩 성공 - \(decoded)") + observer.onNext(decoded) + observer.onCompleted() + } catch { + print("❌ requestData: 디코딩 실패 - \(error)") + observer.onError(NetworkError.decodeError(error)) + } + } else { + print("⚠️ requestData: 응답 데이터 없음") + observer.onError(NetworkError.noData) + } + + case .failure(let error): + print("🔥 requestData: 네트워크 실패 - \(error)") + observer.onError(error) + } + }) + + return Disposables.create() + } + .retry(when: { (errors: Observable) in + errors + .enumerated() + .flatMap { attempt, error -> Observable in + print("🔁 requestData: 재시도 \(attempt + 1)회 - 에러: \(error)") + if attempt < self.retryAttempt, let networkError = error as? NetworkError, networkError == .retry { + return Observable.just(()) + } else { + return Observable.error(error) + } + } + }) + } + + public func requestData(endPoint: Requestable, interceptor: Interceptor?) -> Completable { + return Completable.create { [weak self] completable in + self?.sendRequest(endPoint: endPoint, interceptor: interceptor, completion: { result in + switch result { + case .success(let data): + if data != nil { + completable(.completed) + } else { + completable(.error(NetworkError.noData)) + } + case .failure(let error): + completable(.error(error)) + } + }) + return Disposables.create() + } + .retry(when: { (errors: Observable) in + errors + .enumerated() + .flatMap { attempt, error -> Observable in + if attempt < self.retryAttempt, let networkError = error as? NetworkError, networkError == .retry { + return Observable.just(()) + } else { + return Observable.error(error) + } + } + }) + } +} + +private extension NetworkProviderImpl { + /// 엔드 포인트를 이용하여 요청을 보내기 위한 함수 + /// - Parameters: + /// - endPoint: 요청을 위한 엔드포인트 객체 + /// - completion: 응답 결과 + func sendRequest(endPoint: T, interceptor: Interceptor?, completion: @escaping (Result) -> Void) { + do { + var request = try endPoint.getUrlRequest() + if let interceptor = interceptor { request = interceptor.adapt(request) } + let task = session.dataTask(with: request) { [weak self] data, response, error in + guard let self else { + completion(.failure(.providerDeallocated)) + return + } + let taskResult = checkValidation(data: data, response: response, error: error, interceptor: interceptor) + switch taskResult { + case .success(let data): + completion(.success(data)) + case .failure(let error): + completion(.failure(error)) + print("API 통신에러 \(error)") + } + } + task.resume() + } catch { + completion(.failure(NetworkError.urlRequest(error))) + } + } + + /// 통신간의 유효성 검사를 위한 함수 + /// - Parameters: + /// - data: 통신 결과로 돌려받은 데이터 + /// - response: 상태코드를 포함한 통신 응답 + /// - error: 통신간에 발생한 에러 + /// - Returns: 유효성검사 결과에 따른 데이터와 에러 + func checkValidation( + data: Data?, + response: URLResponse?, + error: Error?, + interceptor: Interceptor? + ) -> Result { + + // 1️⃣ 네트워크 레벨 에러 먼저 체크 + if let error { + if let urlError = error as? URLError, urlError.code == .unsupportedURL { + return .failure(.urlRequest(error)) + } + return .failure(.network(error)) + } + + // 2️⃣ HTTP 응답 객체 확인 + guard let httpResponse = response as? HTTPURLResponse else { + return .failure(.httpError) + } + + // 3️⃣ 상태 코드 기반 검사 + guard (200 ... 299).contains(httpResponse.statusCode) else { + // ❗️여기서만 인터셉터 개입 + if let interceptor = interceptor, + interceptor.retry(data: data, response: response, error: error) { + return .failure(.retry) + } + + let errorMessage = data.flatMap { String(data: $0, encoding: .utf8) } ?? "Unknown" + return .failure(.statusError(httpResponse.statusCode, errorMessage)) + } + + // ✅ 성공 응답 + return .success(data) + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/Network/Requestable.swift b/MLS/MLSCore/Sources/MLSCore/Network/Requestable.swift new file mode 100644 index 00000000..53da0f3d --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Network/Requestable.swift @@ -0,0 +1,51 @@ +import Foundation + +public protocol Requestable { + var baseURL: String { get set } + var path: String { get set } + var method: HTTPMethod { get set } + var query: Encodable? { get set } + var headers: [String: String]? { get set } + var body: Encodable? { get set } +} + +extension Requestable { + /// 엔드포인트 객체의 속성들을 이용하여 Request를 만드는 함수 + /// - Returns: API 통신에 필요한 요청 -> Request + public func getUrlRequest() throws -> URLRequest { + guard var base = URL(string: baseURL) else { + throw URLError(.badURL) + } + + base.appendPathComponent(path) + + guard var components = URLComponents(url: base, resolvingAgainstBaseURL: false) else { + throw URLError(.badURL) + } + + if let query = query { + let queryData = try JSONEncoder().encode(query) + let dictionary = try JSONSerialization.jsonObject(with: queryData, options: []) as? [String: Any] + components.queryItems = dictionary?.map { + URLQueryItem(name: $0.key, value: "\($0.value)") + } + } + + guard let url = components.url else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + + headers?.forEach { key, value in + request.setValue(value, forHTTPHeaderField: key) + } + + if let body = body { + request.httpBody = try JSONEncoder().encode(body) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + return request + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/Network/Responsable.swift b/MLS/MLSCore/Sources/MLSCore/Network/Responsable.swift new file mode 100644 index 00000000..d185fd71 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Network/Responsable.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol Responsable { + associatedtype Response: Decodable +} diff --git a/MLS/MLSCore/Sources/MLSCore/Network/ResponsableEndPoint.swift b/MLS/MLSCore/Sources/MLSCore/Network/ResponsableEndPoint.swift new file mode 100644 index 00000000..124cb018 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Network/ResponsableEndPoint.swift @@ -0,0 +1,29 @@ +import Foundation + +/// 응답값이 있는 엔드포인트 +public struct ResponsableEndPoint: Requestable, Responsable { + public typealias Response = T + + public var baseURL: String + public var path: String + public var method: HTTPMethod + public var query: (any Encodable)? + public var headers: [String: String]? + public var body: (any Encodable)? + + public init( + baseURL: String, + path: String, + method: HTTPMethod, + query: (any Encodable)? = nil, + headers: [String: String]? = nil, + body: (any Encodable)? = nil + ) { + self.baseURL = baseURL + self.path = path + self.method = method + self.query = query + self.headers = headers + self.body = body + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/Utils/NotificationPermissionManager.swift b/MLS/MLSCore/Sources/MLSCore/Utils/NotificationPermissionManager.swift new file mode 100644 index 00000000..87b184c6 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Utils/NotificationPermissionManager.swift @@ -0,0 +1,50 @@ +import UIKit +import UserNotifications + +public final class NotificationPermissionManager: @unchecked Sendable { + + public static let shared = NotificationPermissionManager() + private init() {} + + public func getStatus(completion: @escaping (UNAuthorizationStatus) -> Void) { + UNUserNotificationCenter.current().getNotificationSettings { settings in + completion(settings.authorizationStatus) + } + } + + public func requestIfNeeded(completion: ((Bool) -> Void)? = nil) { + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + switch settings.authorizationStatus { + case .notDetermined: + center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if let error = error { + print("error: \(error.localizedDescription)") + completion?(false) + return + } + if granted { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + completion?(true) + } else { + completion?(false) + } + } + + case .authorized, .provisional: + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + completion?(true) + + case .denied: + completion?(false) + + default: + completion?(false) + } + } + } +} diff --git a/MLS/MLSCore/Tests/MLSCoreTests/DIContainerTests.swift b/MLS/MLSCore/Tests/MLSCoreTests/DIContainerTests.swift new file mode 100644 index 00000000..d520d448 --- /dev/null +++ b/MLS/MLSCore/Tests/MLSCoreTests/DIContainerTests.swift @@ -0,0 +1,140 @@ +import Testing + +@testable import MLSCore + +/// 테스트를 위한 프로토콜 및 클래스 +protocol Service { + func perform() -> String +} + +class ServiceA: Service { + func perform() -> String { + return "ServiceA" + } +} + +class ServiceB: Service { + func perform() -> String { + return "ServiceB" + } +} + +class ServiceWithDependency { + let dependency: ServiceB + + init(dependency: ServiceB) { + self.dependency = dependency + } + + func perform() -> String { + return "ServiceWithDependency using \(dependency.perform())" + } +} + +struct DIContainerTests { + + @Test("객체 등록 및 가져오기") + func testRegisterAndResolve() async throws { + DIContainer.register(type: Service.self, name: "TestA") { ServiceA() } + DIContainer.register(type: Service.self, name: "TestB") { ServiceB() } + + let serviceA: Service = DIContainer.resolve(type: Service.self, name: "TestA") + let serviceB: Service = DIContainer.resolve(type: Service.self, name: "TestB") + + #expect(serviceA.perform() == "ServiceA") + #expect(serviceB.perform() == "ServiceB") + } + + @Test("기본 이름 동작") + func testDefaultName() async throws { + DIContainer.register(type: Service.self) { ServiceA() as Service } + + let service: Service = DIContainer.resolve(type: Service.self) + + #expect(service.perform() == "ServiceA") + } + + @Test("중복 등록 시 덮어쓰기") + func testDuplicateRegistration() async throws { + DIContainer.register(type: Service.self, name: "Duplicate") { ServiceA() as Service } + DIContainer.register(type: Service.self, name: "Duplicate") { ServiceB() as Service } + + let service: Service = DIContainer.resolve(type: Service.self, name: "Duplicate") + #expect(service.perform() == "ServiceB", "나중에 등록된 ServiceB가 반환되어야 함") + } + + @Test("싱글톤 동작 검증 - 같은 인스턴스 반환") + func testSingletonBehavior() async throws { + DIContainer.register(type: Service.self, name: "Singleton") { ServiceA() } + + let instance1: Service = DIContainer.resolve(type: Service.self, name: "Singleton") + let instance2: Service = DIContainer.resolve(type: Service.self, name: "Singleton") + + #expect(instance1 as AnyObject === instance2 as AnyObject, "같은 인스턴스를 반환해야 함") + } + + @Test("타입 안전성 검증") + func testTypeSafety() async throws { + DIContainer.register(type: Service.self, name: "TypeTest") { ServiceA() as Service } + + let service: Service = DIContainer.resolve(type: Service.self, name: "TypeTest") + #expect(service.perform() == "ServiceA") + } + + @Test("동시성 안전성 - 여러 스레드에서 register/resolve 동시 호출") + func testConcurrency() async throws { + await withTaskGroup(of: Void.self) { group in + for index in 0..<100 { + group.addTask { + let name = "Concurrent\(index)" + DIContainer.register(type: Service.self, name: name) { + index % 2 == 0 ? ServiceA() as Service : ServiceB() as Service + } + let service: Service = DIContainer.resolve(type: Service.self, name: name) + let expected = index % 2 == 0 ? "ServiceA" : "ServiceB" + #expect(service.perform() == expected) + } + } + } + } + + @Test("재진입 안전성 - register 내부에서 resolve 호출 시 데드락 방지") + func testReentrancy() async throws { + DIContainer.register(type: ServiceB.self, name: "ReentryDependency") { ServiceB() } + DIContainer.register(type: ServiceWithDependency.self, name: "ReentryMain") { + let dependency: ServiceB = DIContainer.resolve(type: ServiceB.self, name: "ReentryDependency") + return ServiceWithDependency(dependency: dependency) + } + + let service: ServiceWithDependency = DIContainer.resolve(type: ServiceWithDependency.self, name: "ReentryMain") + #expect(service.perform() == "ServiceWithDependency using ServiceB") + } + + @Test("복잡한 의존성 체인 - A -> B -> C") + func testDependencyChain() async throws { + class ServiceC { + func perform() -> String { "C" } + } + class ServiceBWithC { + let c: ServiceC + init(c: ServiceC) { self.c = c } + func perform() -> String { "B->\(c.perform())" } + } + class ServiceAWithB { + let b: ServiceBWithC + init(b: ServiceBWithC) { self.b = b } + func perform() -> String { "A->\(b.perform())" } + } + + DIContainer.register(type: ServiceC.self, name: "ChainC") { ServiceC() } + DIContainer.register(type: ServiceBWithC.self, name: "ChainB") { + ServiceBWithC(c: DIContainer.resolve(type: ServiceC.self, name: "ChainC")) + } + DIContainer.register(type: ServiceAWithB.self, name: "ChainA") { + ServiceAWithB(b: DIContainer.resolve(type: ServiceBWithC.self, name: "ChainB")) + } + + let serviceA: ServiceAWithB = DIContainer.resolve(type: ServiceAWithB.self, name: "ChainA") + #expect(serviceA.perform() == "A->B->C") + } +} diff --git a/MLS/MLSCore/Tests/MLSCoreTests/LoggerTests.swift b/MLS/MLSCore/Tests/MLSCoreTests/LoggerTests.swift new file mode 100644 index 00000000..c3a7f322 --- /dev/null +++ b/MLS/MLSCore/Tests/MLSCoreTests/LoggerTests.swift @@ -0,0 +1,101 @@ +import Testing + +@testable import MLSCore + +struct TestViewModel: Loggable { + func testLogs() { + logDebug("Debug 로그 테스트") + logNotice("Notice 로그 테스트") + logWarning("Warning 로그 테스트") + logError("Error 로그 테스트") + logCritical("Critical 로그 테스트") + } +} + +class TestManager: Loggable { + func testLogs() { + logDebug("Debug 로그 (class)") + logNotice("Notice 로그 (class)") + logWarning("Warning 로그 (class)") + logError("Error 로그 (class)") + logCritical("Critical 로그 (class)") + } +} + +struct LoggerTests { + + @Test("Subsystem 자동 추출 검증 - Struct") + func testSubsystemExtractionStruct() async throws { + // subsystem은 타입이 정의된 모듈 이름이어야 함 + let subsystem = TestViewModel.subsystem + let category = TestViewModel.category + + // DIContainerTests 모듈에 정의되어 있으므로 + #expect(subsystem == "MLSCoreTests") + #expect(category == "TestViewModel") + } + + @Test("Subsystem 자동 추출 검증 - Class") + func testSubsystemExtractionClass() async throws { + let subsystem = TestManager.subsystem + let category = TestManager.category + + #expect(subsystem == "MLSCoreTests") + #expect(category == "TestManager") + } + + @Test("Struct에서 Loggable 사용 테스트") + func testLoggableWithStruct() async throws { + let viewModel = TestViewModel() + + // 로그 출력 (Console.app에서 확인 가능) + // subsystem: "DIContainerTests", category: "TestViewModel" + viewModel.testLogs() + + // 에러 없이 실행되면 성공 + #expect(true) + } + + @Test("Class에서 Loggable 사용 테스트") + func testLoggableWithClass() async throws { + let manager = TestManager() + + // 로그 출력 (Console.app에서 확인 가능) + // subsystem: "DIContainerTests", category: "TestManager" + manager.testLogs() + + // 에러 없이 실행되면 성공 + #expect(true) + } + + @Test("동시성 안전성 테스트 - 같은 Logger 동시 접근") + func testConcurrentLoggerAccess() async throws { + // 100개 스레드가 동시에 같은 Logger 사용 + await withTaskGroup(of: Void.self) { group in + for i in 0..<100 { + group.addTask { + let vm = TestViewModel() + vm.logNotice("동시 로그 \(i)") + } + } + } + + // 크래시 없이 완료되면 성공 + #expect(true) + } + + @Test("NSCache 캐싱 검증 - 반복 접근 시 정상 동작") + func testLoggerCaching() async throws { + let vm1 = TestViewModel() + let vm2 = TestViewModel() + + // 같은 타입에서 여러 번 로깅 (NSCache에서 캐싱된 Logger 재사용) + vm1.logNotice("첫 번째 인스턴스") + vm2.logNotice("두 번째 인스턴스") + vm1.logNotice("다시 첫 번째") + vm2.logNotice("다시 두 번째") + + // 크래시 없이 정상 동작하면 성공 + #expect(true) + } +} diff --git a/MLS/MLSDesignSystem/.gitignore b/MLS/MLSDesignSystem/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/MLS/MLSDesignSystem/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/MLS/MLSDesignSystem/Package.swift b/MLS/MLSDesignSystem/Package.swift new file mode 100644 index 00000000..259c918d --- /dev/null +++ b/MLS/MLSDesignSystem/Package.swift @@ -0,0 +1,40 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MLSDesignSystem", + platforms: [ + .iOS(.v15) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "MLSDesignSystem", + targets: ["MLSDesignSystem"] + ) + ], + dependencies: [ + .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"), + .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.9.1"), + .package(url: "https://github.com/RxSwiftCommunity/RxGesture.git", from: "4.0.4") + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "MLSDesignSystem", + dependencies: [ + .product(name: "SnapKit", package: "SnapKit"), + .product(name: "RxSwift", package: "RxSwift"), + .product(name: "RxCocoa", package: "RxSwift"), + .product(name: "RxRelay", package: "RxSwift"), + .product(name: "RxGesture", package: "RxGesture") // 추가 + ], + resources: [ + .process("Resources") + ] + ) + ] +) diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/AuthGuideAlert.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/AuthGuideAlert.swift new file mode 100644 index 00000000..574b231d --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/AuthGuideAlert.swift @@ -0,0 +1,130 @@ +import UIKit + +import SnapKit + +public class AuthGuideAlert: GuideAlert { + // MARK: - Type + private enum Constant { + static let iconSize: CGFloat = 24 + static let topSpacing: CGFloat = 14 + static let stackViewTopSpacing: CGFloat = stackViewBottomSpacing * 2 + static let stackViewBottomSpacing: CGFloat = 8 + static let iconSpacing: CGFloat = 5 + } + + public enum AuthGuideAlertType { + case logout + case withdraw + + var mainText: String { + switch self { + case .logout: + "정말 로그아웃 하시겠어요?" + case .withdraw: + "정말 탈퇴 하시겠어요?" + } + } + + var subText: String { + switch self { + case .logout: + "로그아웃하면 저장한 캐릭터,\n북마크한 아이템 정보를 볼 수 없어요." + case .withdraw: + "탈퇴 시, 아래 정보가 삭제 되어 복구가 불가능해요." + } + } + + var ctaText: String { + switch self { + case .logout: + "로그아웃 하기" + case .withdraw: + "탈퇴하기" + } + } + } + + // MARK: - Components + private let contentStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + return view + }() + + private let subTextLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 2 + return label + }() + + private lazy var bookmarkStackView = makeStack(icon: DesignSystemAsset.image(named: "checkMarkFill"), text: "북마크 컬렉션") + private lazy var characterStackView = makeStack(icon: DesignSystemAsset.image(named: "checkMarkFill"), text: "캐릭터 정보") + + // MARK: - init + public init(type: AuthGuideAlertType) { + super.init(mainText: type.mainText, ctaText: type.ctaText, cancelText: "취소") + addViews(type: type) + setupConstraints(type: type) + configureUI(type: type) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension AuthGuideAlert { + func addViews(type: AuthGuideAlertType) { + addSubview(contentStackView) + + contentStackView.addArrangedSubview(subTextLabel) + if type == .withdraw { + contentStackView.addArrangedSubview(bookmarkStackView) + contentStackView.addArrangedSubview(characterStackView) + } + } + + func setupConstraints(type: AuthGuideAlertType) { + contentStackView.snp.makeConstraints { make in + make.top.equalTo(mainTextLabel.snp.bottom).offset(Constant.topSpacing) + make.horizontalEdges.equalToSuperview().inset(GuideAlert.Constant.horizontalInset) + } + + if type == .withdraw { + contentStackView.setCustomSpacing(Constant.stackViewTopSpacing, after: subTextLabel) + contentStackView.setCustomSpacing(Constant.stackViewBottomSpacing, after: bookmarkStackView) + } + + buttonStackView.snp.remakeConstraints { make in + make.top.equalTo(contentStackView.snp.bottom).offset(GuideAlert.Constant.verticalSpacing) + make.horizontalEdges.equalToSuperview().inset(GuideAlert.Constant.horizontalInset) + make.bottom.equalToSuperview().inset(GuideAlert.Constant.verticalInset) + make.height.equalTo(GuideAlert.Constant.buttonHeight) + } + } + + func configureUI(type: AuthGuideAlertType) { + subTextLabel.attributedText = .makeStyledString(font: .b_s_r, text: type.subText, color: .neutral700) + } + + func makeStack(icon: UIImage, text: String) -> UIStackView { + let iconView = UIImageView(image: icon) + + let label: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .cp_s_r, text: text, color: .neutral700, alignment: .left) + return label + }() + + iconView.snp.makeConstraints { make in + make.size.equalTo(Constant.iconSize) + } + + let stackView = UIStackView(arrangedSubviews: [iconView, label]) + stackView.axis = .horizontal + stackView.spacing = Constant.stackViewBottomSpacing + return stackView + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Badge.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Badge.swift new file mode 100644 index 00000000..6d7ff623 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Badge.swift @@ -0,0 +1,102 @@ +import UIKit + +import SnapKit + +public final class Badge: UIView { + // MARK: - Type + public enum BadgeStyle { + case element(String) + case preQuest + case currentQuest + case nextQuest + + var font: UIFont? { + switch self { + case .element: + return .cp_s_sb + case .currentQuest: + return .cp_xs_sb + default: + return .cp_xs_r + } + } + + var fontColor: UIColor { + switch self { + case .element, .currentQuest: + return .primary700 + default: + return .neutral600 + } + } + + var backgroundColor: UIColor { + switch self { + case .element, .currentQuest: + return .primary25 + default: + return .neutral100 + } + } + + var text: String { + switch self { + case .element(let text): + return text + case .preQuest: + return "이전 퀘스트" + case .currentQuest: + return "현재 퀘스트" + case .nextQuest: + return "다음 퀘스트" + } + } + } + + private enum Constant { + static let radius: CGFloat = 4 + static let contentInsets: CGFloat = 6 + } + + // MARK: - Components + private let textLabel = UILabel() + + // MARK: - init + public init(style: BadgeStyle) { + super.init(frame: .zero) + + addViews() + setupConstraints() + configureUI(style: style) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension Badge { + func addViews() { + addSubview(textLabel) + } + + func setupConstraints() { + textLabel.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(Constant.contentInsets) + } + } + + func configureUI(style: BadgeStyle) { + layer.cornerRadius = Constant.radius + backgroundColor = style.backgroundColor + textLabel.attributedText = .makeStyledString(font: style.font, text: style.text, color: style.fontColor, lineHeight: 1) + } +} + +public extension Badge { + func update(style: BadgeStyle) { + configureUI(style: style) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift new file mode 100644 index 00000000..f461d117 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift @@ -0,0 +1,283 @@ +import UIKit + +import SnapKit + +public final class CardList: UIView { + // MARK: - Type + public enum CardListType { + case bookmark + case checkbox + case detailStack + case detailStackText // 몬스터 카드일 경우 드롭률 + case detailStackBadge(Badge.BadgeStyle) + case recommended(rank: Int) + + var icon: UIImage? { + switch self { + case .bookmark, .recommended: + return DesignSystemAsset.image(named: "bookmarkBorderList") + case .checkbox: + return DesignSystemAsset.image(named: "checkSquare") + case .detailStack, .detailStackText, .detailStackBadge: + return nil + } + } + + var selectedIcon: UIImage? { + switch self { + case .bookmark, .recommended: + return DesignSystemAsset.image(named: "bookmarkList") + case .checkbox: + return DesignSystemAsset.image(named: "checkSquareFill") + case .detailStack, .detailStackText, .detailStackBadge: + return nil + } + } + } + + enum Constant { + static let cardRadius: CGFloat = 16 + static let cardLeadingInset: CGFloat = 12 + static let cardTrailingInset: CGFloat = 16 + static let infoLabelInset: CGFloat = 16 + static let imageRadius: CGFloat = 8 + static let imageInset: CGFloat = 10 + static let imageContentViewSize: CGFloat = 80 + static let stackViewSpacing: CGFloat = 4 + static let iconSize: CGFloat = 24 + static let mapImageSize: CGFloat = 40 + static let tagHeight: CGFloat = 24 + } + + // MARK: - Properties + private var type = CardListType.bookmark + private var icon = UIImage() + private var selectedIcon = UIImage() + + public var isIconSelected: Bool = false { + didSet { + updateIcon() + } + } + + public var mainText: String? { + didSet { + updateMainText() + } + } + + public var subText: String? { + didSet { + updateSubText() + } + } + + public var onIconTapped: (() -> Void)? + + // MARK: - Components + public let imageView = ItemImageView(image: nil, cornerRadius: Constant.imageRadius, inset: Constant.imageInset, backgroundColor: .listMap) + + private lazy var textLabelStackView: UIStackView = { + let view = UIStackView(arrangedSubviews: [rankTag, mainTextLabel, subTextLabel]) + view.axis = .vertical + view.spacing = Constant.stackViewSpacing + view.alignment = .leading + return view + }() + + private let mainTextLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 2 + label.lineBreakMode = .byTruncatingTail + label.lineBreakStrategy = .pushOut + return label + }() + + private let subTextLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 1 + return label + }() + + private let iconButton: UIButton = { + let button = UIButton() + button.setImage(DesignSystemAsset.image(named: "bookmarkBorder"), for: .normal) + return button + }() + + // 드롭률 표시용 라벨 2개 + private let dropTitleLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 1 + return label + }() + + private let dropValueLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 1 + return label + }() + + // 2개 라벨을 세로로 묶는 스택뷰 + private lazy var dropInfoStack: UIStackView = { + let stack = UIStackView(arrangedSubviews: [dropTitleLabel, dropValueLabel]) + stack.axis = .vertical + stack.alignment = .trailing + stack.isHidden = true // 기본은 숨김 + return stack + }() + + private let badge = Badge(style: .currentQuest) + + private let rankTag = TagChip(style: .text, text: "순위") + + public init() { + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI() + bindButton() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension CardList { + func addViews() { + addSubview(imageView) + addSubview(textLabelStackView) + addSubview(iconButton) + addSubview(dropInfoStack) + addSubview(badge) + } + + func setupConstraints() { + imageView.snp.makeConstraints { make in + make.top.leading.bottom.equalToSuperview().inset(Constant.cardLeadingInset) + make.size.equalTo(Constant.imageContentViewSize) + } + + textLabelStackView.snp.makeConstraints { make in + make.leading.equalTo(imageView.snp.trailing).offset(Constant.cardLeadingInset) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview().inset(Constant.cardTrailingInset) + } + + dropInfoStack.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(Constant.cardTrailingInset) + } + + badge.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(Constant.cardTrailingInset) + } + + rankTag.snp.makeConstraints { make in + make.height.equalTo(Constant.tagHeight) + } + + iconButton.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(Constant.cardTrailingInset) + make.size.equalTo(Constant.iconSize) + } + + textLabelStackView.snp.makeConstraints { make in + make.trailing.lessThanOrEqualTo(dropInfoStack.snp.leading).offset(-Constant.cardLeadingInset) + make.trailing.lessThanOrEqualTo(badge.snp.leading).offset(-Constant.cardLeadingInset) + make.trailing.lessThanOrEqualTo(iconButton.snp.leading).offset(-Constant.cardLeadingInset) + } + } + + func configureUI() { + backgroundColor = .whiteMLS + layer.cornerRadius = Constant.cardRadius + } + + func bindButton() { + iconButton.addAction(UIAction(handler: { [weak self] _ in + guard let self = self else { return } + self.onIconTapped?() + }), for: .touchUpInside) + } + + func updateMainText() { + mainTextLabel.attributedText = .makeStyledString(font: .sub_m_sb, text: mainText, alignment: .left) + } + + func updateSubText() { + subTextLabel.attributedText = .makeStyledString(font: .cp_s_r, text: subText, color: .neutral500, alignment: .left) + } + + func updateIcon() { + iconButton.setImage(isIconSelected ? selectedIcon : icon, for: .normal) + } +} + +public extension CardList { + func setMainText(text: String) { + mainText = text + } + + func setSubText(text: String?) { + subText = text + } + + func setImage(image: UIImage, backgroundColor: UIColor) { + imageView.setImage(image: image, backgroundColor: backgroundColor) + } + + func setMapImage(image: UIImage, backgroundColor: UIColor) { + imageView.setMapImage(image: image, backgroundColor: backgroundColor) + } + + func setSelected(isSelected: Bool) { + isIconSelected = isSelected + } + + func setType(type: CardListType) { + self.type = type + icon = type.icon ?? UIImage() + selectedIcon = type.selectedIcon ?? UIImage() + + switch type { + case .detailStack: + iconButton.isHidden = true + dropInfoStack.isHidden = true + badge.isHidden = true + rankTag.isHidden = true + case .detailStackText: + iconButton.isHidden = true + dropInfoStack.isHidden = false + badge.isHidden = true + rankTag.isHidden = true + case .detailStackBadge(let type): + iconButton.isHidden = true + dropInfoStack.isHidden = false + badge.isHidden = false + rankTag.isHidden = true + badge.update(style: type) + case .recommended(let rank): + iconButton.isHidden = true + dropInfoStack.isHidden = true + subTextLabel.isHidden = true + badge.isHidden = true + rankTag.isHidden = false + rankTag.text = "\(rank)위" + default: + iconButton.isHidden = false + dropInfoStack.isHidden = true + badge.isHidden = true + } + } + + func setDropInfoText(title: String, value: String?) { + dropTitleLabel.attributedText = .makeStyledString(font: .cp_s_r, text: title, color: .neutral700) + dropValueLabel.attributedText = .makeStyledString(font: .sub_m_b, text: value, color: .primary700) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CheckBoxButton.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CheckBoxButton.swift new file mode 100644 index 00000000..2b93edf5 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CheckBoxButton.swift @@ -0,0 +1,253 @@ +import UIKit + +import SnapKit + +public final class CheckBoxButton: UIButton { + // MARK: - Types + public enum CheckBoxButtonStyle { + case normal + case listSmall + case listMedium + case listLarge + + public var font: UIFont? { + switch self { + case .normal: + return .sub_m_b + case .listSmall, .listMedium: + return .b_s_r + case .listLarge: + return .b_m_r + } + } + + public var verticalInset: CGFloat { + switch self { + case .normal, .listLarge: + return 16 + case .listSmall: + return 4 + case .listMedium: + return 10 + } + } + + public var subtitleIsHidden: Bool { + switch self { + case .normal: + return false + default: + return true + } + } + + public var rightButtonIsHidden: Bool { + switch self { + case .listMedium: + return false + default: + return true + } + } + + public var backgroundColor: UIColor { + switch self { + case .normal: + return .neutral100 + default: + return .clearMLS + } + } + + public var textColor: UIColor { + switch self { + case .listSmall: + return .neutral700 + default: + return .textColor + } + } + + public var selecteTextColor: UIColor { + switch self { + case .listSmall: + return .primary700 + default: + return .textColor + } + } + + public var height: CGFloat? { + switch self { + case .normal: + return 56 + case .listSmall: + return 32 + case .listMedium: + return 44 + case .listLarge: + return nil + } + } + } + + private enum Constant { + static let imageSize: CGFloat = 24 + static let horizontalInset: CGFloat = 20 + static let labelSpacing: CGFloat = 4 + static let spacing: CGFloat = 8 + static let radius: CGFloat = 8 + } + + // MARK: - Properties + private let style: CheckBoxButtonStyle + + public var mainTitle: String? { + didSet { + updateText() + } + } + + public var subTitle: String? { + didSet { + updateText() + } + } + + override public var isSelected: Bool { + didSet { + updateTintColor() + } + } + + private lazy var contentStackView: UIStackView = { [weak self] in + let view = UIStackView() + view.isUserInteractionEnabled = false + view.spacing = Constant.spacing + return view + }() + + private let labelTrailingView: UIView = .init() + + private let checkIconImageView: UIImageView = { + let image = DesignSystemAsset.image(named: "checkCircleFill").withRenderingMode(.alwaysTemplate) + let view = UIImageView(image: image) + return view + }() + + private let buttonTitleLabel: UILabel = .init() + + private let buttonSubTitleLabel: UILabel = .init() + + public let rightButton: UIButton = { + let button = UIButton() + let image = DesignSystemAsset.image(named: "arrowForwardSmall").withRenderingMode(.alwaysTemplate) + button.setImage(image, for: .normal) + button.tintColor = .textColor + return button + }() + + // MARK: - init + public init(style: CheckBoxButtonStyle, mainTitle: String?, subTitle: String?) { + self.style = style + self.mainTitle = mainTitle + self.subTitle = subTitle + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard !isHidden, isUserInteractionEnabled, alpha > 0.01 else { return nil } + let view = super.hitTest(point, with: event) + if view != self { return view } + let rightPoint = convert(point, to: rightButton) + if rightButton.bounds.contains(rightPoint) && !rightButton.isHidden && rightButton.isUserInteractionEnabled { + return rightButton + } + return view + } +} + +// MARK: - SetUp +private extension CheckBoxButton { + func addViews() { + addSubview(contentStackView) + labelTrailingView.addSubview(buttonTitleLabel) + labelTrailingView.addSubview(buttonSubTitleLabel) + + if style == .listLarge { + contentStackView.addArrangedSubview(labelTrailingView) + contentStackView.addArrangedSubview(UIView()) + contentStackView.addArrangedSubview(checkIconImageView) + } else { + contentStackView.addArrangedSubview(checkIconImageView) + contentStackView.addArrangedSubview(labelTrailingView) + contentStackView.addArrangedSubview(UIView()) + contentStackView.addArrangedSubview(rightButton) + } + } + + func setupConstraints() { + snp.makeConstraints { make in + if let height = style.height { + make.height.equalTo(height) + } + } + + contentStackView.snp.makeConstraints { make in + if style == .listSmall { + make.horizontalEdges.equalToSuperview() + } else { + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + } + make.verticalEdges.equalToSuperview().inset(style.verticalInset) + } + + checkIconImageView.snp.makeConstraints { make in + make.size.equalTo(Constant.imageSize) + } + + buttonTitleLabel.snp.makeConstraints { make in + make.leading.verticalEdges.equalToSuperview() + make.centerY.equalToSuperview() + } + + buttonSubTitleLabel.snp.makeConstraints { make in + make.leading.equalTo(buttonTitleLabel.snp.trailing).offset(Constant.labelSpacing) + make.trailing.equalToSuperview() + make.centerY.equalToSuperview() + } + + rightButton.snp.makeConstraints { make in + make.size.equalTo(Constant.imageSize) + } + } + + func configureUI() { + updateTintColor() + updateText() + + buttonTitleLabel.font = style.font + buttonSubTitleLabel.isHidden = style.subtitleIsHidden + backgroundColor = style.backgroundColor + layer.cornerRadius = Constant.radius + rightButton.isHidden = style.rightButtonIsHidden + } + + func updateTintColor() { + checkIconImageView.tintColor = isSelected ? .primary700 : .neutral300 + buttonTitleLabel.attributedText = .makeStyledString(font: style.font, text: mainTitle, color: isSelected ? style.selecteTextColor : style.textColor) + } + + func updateText() { + buttonTitleLabel.attributedText = .makeStyledString(font: style.font, text: mainTitle, color: style.textColor) + buttonSubTitleLabel.attributedText = .makeStyledString(font: .b_m_r, text: subTitle) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CollectionList.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CollectionList.swift new file mode 100644 index 00000000..2c6d0240 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CollectionList.swift @@ -0,0 +1,143 @@ +import UIKit + +import SnapKit + +public final class CollectionList: UIView { + private enum Constant { + static let imageSize: CGFloat = 36 + static let imageInset: CGFloat = 4 + static let imageSpacing: CGFloat = 4 + static let imageRadius: CGFloat = 6 + static let contentInset: CGFloat = 10 + static let labelLeadingMargin: CGFloat = 20 + static let labelTrailingMargin: CGFloat = 64 + static let iconSize: CGFloat = 24 + static let radius: CGFloat = 12 + } + + // MARK: - Components + private lazy var imageViews: [ItemImageView] = (0 ..< 4).map { _ in + let view = ItemImageView( + image: nil, + cornerRadius: Constant.imageRadius, + inset: Constant.imageInset, + backgroundColor: .neutral200 + ) + return view + } + + private lazy var imageGridView: UIStackView = { + let topRow = UIStackView(arrangedSubviews: Array(imageViews[0...1])) + topRow.axis = .horizontal + topRow.spacing = Constant.imageSpacing + topRow.distribution = .fillEqually + + let bottomRow = UIStackView(arrangedSubviews: Array(imageViews[2...3])) + bottomRow.axis = .horizontal + bottomRow.spacing = Constant.imageSpacing + bottomRow.distribution = .fillEqually + + let stack = UIStackView(arrangedSubviews: [topRow, bottomRow]) + stack.axis = .vertical + stack.spacing = Constant.imageSpacing + return stack + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + return label + }() + + private let subtitleLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 1 + return label + }() + + private lazy var textStackView: UIStackView = { + let stack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + stack.axis = .vertical + stack.spacing = 4 + return stack + }() + + private let clickIcon: UIImageView = { + let view = UIImageView(image: DesignSystemAsset.image(named: "arrowForwardSmall")) + view.tintColor = .black + return view + }() + + // MARK: - Init + override public init(frame: CGRect) { + super.init(frame: frame) + addViews() + setupConstraints() + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension CollectionList { + func addViews() { + addSubview(imageGridView) + addSubview(textStackView) + addSubview(clickIcon) + } + + func setupConstraints() { + imageGridView.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(Constant.contentInset) + make.verticalEdges.equalToSuperview().inset(Constant.contentInset) + make.width.height.equalTo((Constant.imageSize * 2) + Constant.imageSpacing) + } + + textStackView.snp.makeConstraints { make in + make.leading.equalTo(imageGridView.snp.trailing).offset(Constant.labelLeadingMargin) + make.centerY.equalToSuperview() + } + + clickIcon.snp.makeConstraints { make in + make.leading.equalTo(textStackView.snp.trailing).offset(Constant.labelTrailingMargin) + make.trailing.equalToSuperview().inset(Constant.contentInset) + make.centerY.equalToSuperview() + make.width.height.equalTo(Constant.iconSize) + } + + imageViews.forEach { + $0.snp.makeConstraints { make in + make.width.height.equalTo(Constant.imageSize) + } + } + } + + func configureUI() { + backgroundColor = .whiteMLS + layer.cornerRadius = Constant.radius + clipsToBounds = true + } +} + +public extension CollectionList { + func setTitle(text: String) { + titleLabel.attributedText = .makeStyledString(font: .b_s_m, text: text, alignment: .left) + } + + func setSubtitle(text: String) { + subtitleLabel.attributedText = .makeStyledString(font: .cp_xs_r, text: text, color: .neutral500, alignment: .left) + } + + func setImages(images: [UIImage?]) { + for (index, view) in imageViews.enumerated() { + let imageView = view.subviews.compactMap { $0 as? UIImageView }.first + print("이미지 뷰 설정") + imageView?.image = index < images.count ? images[index] : nil + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CommonButton.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CommonButton.swift new file mode 100644 index 00000000..738acf56 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CommonButton.swift @@ -0,0 +1,193 @@ +import UIKit + +import SnapKit + +public final class CommonButton: UIButton { + // MARK: - Type + public enum CommonButtonStyle { + case normal + case text + case border + + public var height: CGFloat { + switch self { + case .normal, .border: + return 54 + case .text: + return 44 + } + } + + public var backgroundColor: UIColor { + switch self { + case .normal: + .primary700 + case .text, .border: + .clearMLS + } + } + + public var borderColor: UIColor { + switch self { + case .normal, .text: + UIColor.clearMLS + case .border: + UIColor.neutral300 + } + } + + public var textColor: UIColor { + switch self { + case .normal: + .whiteMLS + case .text, .border: + .textColor + } + } + + public var font: UIFont? { + switch self { + case .normal: + .btn_m_b + case .text: + .btn_s_r + case .border: + .btn_m_r + } + } + } + + private enum Constant { + static let height: CGFloat = 54 + static let normalStyleCornerRadius: CGFloat = 8 + static let textLineHeight: CGFloat = 1.2 + static let buttonInsets = NSDirectionalEdgeInsets(top: 16, leading: 20, bottom: 16, trailing: 20) + } + + // MARK: - Properties + private let style: CommonButtonStyle + private var title: String? + private var disabledTitle: String? + + override public var isEnabled: Bool { + didSet { + configureUI() + } + } + + // MARK: - init + public init(style: CommonButtonStyle, title: String?, disabledTitle: String?) { + self.style = style + self.title = title + self.disabledTitle = disabledTitle + super.init(frame: .zero) + configureUI() + } + + public init() { + self.style = .normal + self.title = nil + self.disabledTitle = nil + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension CommonButton { + func configureUI() { + var config = UIButton.Configuration.plain() + config.contentInsets = Constant.buttonInsets + + switch style { + case .normal, .border: + config.background.backgroundColor = isEnabled ? style.backgroundColor : .neutral300 + config.background.cornerRadius = Constant.normalStyleCornerRadius + let currentTitle = isEnabled ? title : disabledTitle + config.attributedTitle = AttributedString(.makeStyledString(font: style.font, text: currentTitle, color: isEnabled ? style.textColor : .whiteMLS) ?? .init()) + config.baseForegroundColor = isEnabled ? style.textColor : .whiteMLS + switch style { + case .border: + config.background.strokeColor = isEnabled ? style.borderColor : .neutral300 + config.background.strokeWidth = 1 + default: + break + } + configuration = config + snp.makeConstraints { make in + make.height.equalTo(style.height) + } + case .text: + config.background.backgroundColor = .clear + let currentTitle = isEnabled ? title : disabledTitle + if let textButtonTitle = currentTitle, + let lineHeight = style.font?.lineHeight { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.minimumLineHeight = lineHeight * Constant.textLineHeight + paragraphStyle.maximumLineHeight = lineHeight * Constant.textLineHeight + paragraphStyle.alignment = .center + + let enabledAttributedString = NSAttributedString( + string: textButtonTitle, + attributes: [ + .foregroundColor: isEnabled ? style.textColor : .neutral700, + .underlineStyle: NSUnderlineStyle.single.rawValue, + .underlineColor: isEnabled ? style.textColor : .neutral700, + .paragraphStyle: paragraphStyle, + .font: style.font ?? UIFont() + ] + ) + config.attributedTitle = AttributedString(enabledAttributedString) + } + configuration = config + } + + configurationUpdateHandler = { [weak self] button in + guard let self = self else { return } + var updatedConfig = button.configuration + switch button.state { + case .highlighted: + switch style { + case .normal: + updatedConfig?.background.backgroundColor = self.style.backgroundColor.withAlphaComponent(0.9) + case .border: + updatedConfig?.background.backgroundColor = UIColor.neutral100 + default: + break + } + default: + updatedConfig?.background.backgroundColor = self.isEnabled ? self.style.backgroundColor : .neutral300 + } + button.configuration = updatedConfig + } + } +} + +public extension CommonButton { + func updateTitle(title: String, disabledTitle: String? = nil) { + self.title = title + if let disabledTitle = disabledTitle { + self.disabledTitle = disabledTitle + } + configureUI() + } + + func updateTitleColor(color: UIColor, for state: UIControl.State = .normal) { + var config = configuration ?? UIButton.Configuration.plain() + + if var attributedTitle = config.attributedTitle { + var container = AttributeContainer() + container.foregroundColor = color + attributedTitle.mergeAttributes(container, mergePolicy: .keepNew) + config.attributedTitle = attributedTitle + } else if let title = title(for: state) { + config.attributedTitle = AttributedString(title, attributes: AttributeContainer([.foregroundColor: color])) + } + + configuration = config + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/DictionaryDetailListView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/DictionaryDetailListView.swift new file mode 100644 index 00000000..a3b9c42e --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/DictionaryDetailListView.swift @@ -0,0 +1,132 @@ +import SnapKit +import UIKit + +public final class DictionaryDetailListView: UIStackView { + // MARK: - Type + private enum Constant { + static let height: CGFloat = 50 + static let horizontalInset: CGFloat = 7 + static let iconSize: CGFloat = 24 + static let spacing: CGFloat = 9 + } + + // MARK: - Components + private let mainLabel = UILabel() + private let mainButtonLabel = UILabel() + private lazy var mainButton = makeButton(label: mainButtonLabel) + + private let leftSpacer = UIView() + private let rightSpacer = UIView() + + private let mainAdditionalLabel = UILabel() + private let spacer = UIView() + + private let subLabel = UILabel() + private let subButtonLabel = UILabel() + private lazy var subButton = makeButton(label: subButtonLabel) + + private let underLine: UIView = { + let view = UIView() + view.backgroundColor = .neutral200 + return view + }() + + // MARK: - init + public init() { + super.init(frame: .zero) + axis = .horizontal + spacing = Constant.spacing + alignment = .center + + addBaseViews() + setupConstraints() + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension DictionaryDetailListView { + func addBaseViews() { + addArrangedSubview(leftSpacer) + addArrangedSubview(spacer) + addArrangedSubview(rightSpacer) + addSubview(underLine) + } + + func setupConstraints() { + snp.makeConstraints { make in + make.height.equalTo(Constant.height) + } + + leftSpacer.snp.makeConstraints { make in + make.width.equalTo(Constant.horizontalInset) + } + + rightSpacer.snp.makeConstraints { make in + make.width.equalTo(Constant.horizontalInset) + } + + underLine.snp.makeConstraints { make in + make.horizontalEdges.bottom.equalToSuperview() + make.height.equalTo(1) + } + } + + func makeButton(label: UILabel) -> UIButton { + let button = UIButton() + let icon = UIImageView(image: UIImage(named: "rightArrow")) + + button.addSubview(label) + button.addSubview(icon) + + label.snp.makeConstraints { make in + make.leading.verticalEdges.equalToSuperview() + } + + icon.snp.makeConstraints { make in + make.leading.equalTo(label.snp.trailing) + make.trailing.centerY.equalToSuperview() + make.size.equalTo(Constant.iconSize) + } + + return button + } +} + +extension DictionaryDetailListView { + public func update(mainText: String? = nil, clickableMainText: String? = nil, additionalText: String? = nil, subText: String? = nil, clickableSubText: String? = nil) { + let fixedViews: Set = [leftSpacer, spacer, rightSpacer] + arrangedSubviews + .filter { !fixedViews.contains($0) } + .forEach { removeArrangedSubview($0); $0.removeFromSuperview() } + + insertArrangedSubview(leftSpacer, at: 0) + + if let mainText = mainText { + mainLabel.attributedText = .makeStyledString(font: .sub_m_sb, text: mainText) + insertArrangedSubview(mainLabel, at: 1) + } + + if let clickableMainText = clickableMainText { + mainButtonLabel.attributedText = .makeStyledUnderlinedString(font: .sub_m_sb, text: clickableMainText) + insertArrangedSubview(mainButton, at: arrangedSubviews.firstIndex(of: spacer) ?? 0) + } + + if let additionalText = additionalText { + mainAdditionalLabel.attributedText = .makeStyledString(font: .sub_m_sb, text: additionalText) + insertArrangedSubview(mainAdditionalLabel, at: arrangedSubviews.firstIndex(of: spacer) ?? 0) + } + + if let subText = subText { + subLabel.attributedText = .makeStyledString(font: .btn_s_r, text: subText) + insertArrangedSubview(subLabel, at: arrangedSubviews.firstIndex(of: rightSpacer) ?? 0) + } + + if let clickableSubText = clickableSubText { + subButtonLabel.attributedText = .makeStyledUnderlinedString(font: .btn_s_r, text: clickableSubText) + insertArrangedSubview(subButton, at: arrangedSubviews.firstIndex(of: rightSpacer) ?? 0) + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/DividerView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/DividerView.swift new file mode 100644 index 00000000..8f636bae --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/DividerView.swift @@ -0,0 +1,19 @@ +import UIKit + +import SnapKit + +public final class DividerView: UIView { + // MARK: - init + public init() { + super.init(frame: .zero) + self.backgroundColor = .neutral200 + self.snp.makeConstraints { make in + make.height.equalTo(1) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/DropDownBox/DropDownBox.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/DropDownBox/DropDownBox.swift new file mode 100644 index 00000000..7be4f3f8 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/DropDownBox/DropDownBox.swift @@ -0,0 +1,184 @@ +import UIKit + +import SnapKit + +public final class DropDownBox: UIStackView { + // MARK: - Properties + private var isExpanded = false + private var tableViewHeightConstraint: Constraint? + private var selectedIndex: Int? { + didSet { + tableView.reloadData() + } + } + + public var selectedItem: Item? { + guard let index = selectedIndex, items.indices.contains(index) else { + return nil + } + return items[index] + } + + // 선택 이벤트 콜백 + public var onItemSelected: ((Item) -> Void)? + + public var items = [Item]() { + didSet { + tableView.reloadData() + } + } + + // MARK: - Components + public let inputBox = InputBox() + + private let iconButton: UIButton = { + let view = UIButton() + view.setImage(DesignSystemAsset.image(named: "arrowDropdown"), for: .normal) + return view + }() + + public let tableView: UITableView = { + let tableView = UITableView() + tableView.isHidden = true + tableView.layer.borderWidth = 1 + tableView.layer.cornerRadius = 8 + tableView.layer.borderColor = UIColor.neutral300.cgColor + tableView.separatorStyle = .none + tableView.isScrollEnabled = false + tableView.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0) + tableView.register(DropDownBoxCell.self, forCellReuseIdentifier: "DropDownCell") + return tableView + }() + + // MARK: - Init + public init(label: String? = nil, placeHodler: String? = nil, items: [Item]) { + self.items = items + super.init(frame: .zero) + + inputBox.label.attributedText = .makeStyledString(font: .b_s_r, text: label, color: .neutral700, alignment: .left) + inputBox.textField.attributedPlaceholder = .makeStyledString(font: .b_m_r, text: placeHodler, color: .neutral500, alignment: .left) + + setupStackView() + setupInputBox() + setupTableView() + configureTap() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Setup +private extension DropDownBox { + func setupStackView() { + axis = .vertical + spacing = 4 + alignment = .fill + addArrangedSubview(inputBox) + addArrangedSubview(tableView) + } + + func setupInputBox() { + inputBox.borderView.addSubview(iconButton) + + inputBox.textField.snp.remakeConstraints { make in + make.verticalEdges.equalToSuperview().inset(16) + make.leading.equalToSuperview().inset(20) + } + + iconButton.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalTo(inputBox.textField.snp.trailing).offset(8) + make.trailing.equalToSuperview().inset(20) + make.size.equalTo(24) + } + + inputBox.borderView.layer.borderColor = UIColor.neutral300.cgColor + inputBox.textField.isUserInteractionEnabled = false + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + inputBox.borderView.addGestureRecognizer(tapGesture) + inputBox.borderView.isUserInteractionEnabled = true + } + + func setupTableView() { + tableView.delegate = self + tableView.dataSource = self + + tableView.snp.makeConstraints { make in + make.horizontalEdges.equalTo(inputBox) + tableViewHeightConstraint = make.height.equalTo(0).constraint + } + } + + func configureTap() { + let action = UIAction { [weak self] _ in + self?.toggleDropdown() + } + iconButton.addAction(action, for: .touchUpInside) + } + + func toggleDropdown() { + isExpanded.toggle() + tableView.isHidden = !isExpanded + iconButton.setImage(isExpanded ? DesignSystemAsset.image(named: "arrowDropUp") : DesignSystemAsset.image(named: "arrowDropdown"), for: .normal) + let height = CGFloat(items.count) * 44 + tableView.contentInset.top + tableView.contentInset.bottom + tableViewHeightConstraint?.update(offset: isExpanded ? height : 0) + } + + func removeKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + + @objc private func handleTap() { + toggleDropdown() + removeKeyboard() + } +} + +// MARK: - UITableView +extension DropDownBox: UITableViewDataSource, UITableViewDelegate { + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return items.count + } + + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: "DropDownCell", for: indexPath) as? DropDownBoxCell else { + return UITableViewCell() + } + let isSelected = selectedIndex == indexPath.row + cell.injection(with: items[indexPath.row].name, isSelected: isSelected) + cell.selectionStyle = .none + return cell + } + + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + selectedIndex = indexPath.row + let selectedItem = items[indexPath.row] + inputBox.textField.attributedText = .makeStyledString(font: .b_m_r, text: selectedItem.name, alignment: .left, lineHeight: 1.0) + inputBox.textField.sendActions(for: .editingChanged) + toggleDropdown() + + onItemSelected?(selectedItem) + } + + public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 44 + } +} + +// MARK: Model +extension DropDownBox { + public struct Item { + public let name: String + public let id: Int + + public init(name: String, id: Int) { + self.name = name + self.id = id + } + } + +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/DropDownBox/DropDownBoxCell.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/DropDownBox/DropDownBoxCell.swift new file mode 100644 index 00000000..183b96ef --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/DropDownBox/DropDownBoxCell.swift @@ -0,0 +1,61 @@ +import UIKit + +import SnapKit + +final class DropDownBoxCell: UITableViewCell { + // MARK: - Type + private enum Constant { + static let horizontalInset: CGFloat = 20 + static let verticalInset: CGFloat = 10 + static let cellInset: CGFloat = 4 + static let radius: CGFloat = 8 + } + + // MARK: - Components + private let titleLabel = UILabel() + + private let backgroundColorView: UIView = { + let view = UIView() + view.layer.cornerRadius = Constant.radius + return view + }() + + // MARK: - init + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + addViews() + setupContstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension DropDownBoxCell { + func addViews() { + contentView.addSubview(backgroundColorView) + backgroundColorView.addSubview(titleLabel) + } + + func setupContstraints() { + titleLabel.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.verticalEdges.equalToSuperview().inset(Constant.verticalInset) + } + + backgroundColorView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.cellInset) + make.verticalEdges.equalToSuperview() + } + } +} + +extension DropDownBoxCell { + func injection(with input: String, isSelected: Bool) { + titleLabel.attributedText = .makeStyledString(font: .b_m_r, text: input, color: isSelected ? .textColor : .neutral500, alignment: .left) + backgroundColorView.backgroundColor = isSelected ? .neutral100 : .clearMLS + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/ErrorMessage.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/ErrorMessage.swift new file mode 100644 index 00000000..973bb586 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/ErrorMessage.swift @@ -0,0 +1,69 @@ +import UIKit + +import SnapKit + +public final class ErrorMessage: UIView { + private enum Constant { + static let verticalEdgesInset: CGFloat = 8 + static let horizontalEdges: CGFloat = 20 + static let cornerRadius: CGFloat = 18 + static let spacing: CGFloat = 4 + static let height: CGFloat = 36 + static let iconSize: CGFloat = 16 + } + + // MARK: - Properties + private let iconView: UIImageView = { + let view = UIImageView() + view.image = UIImage(named: "error") + return view + }() + + public let label = UILabel() + + // MARK: - init + public init(message: String?) { + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI(message: message) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension ErrorMessage { + func addViews() { + addSubview(iconView) + addSubview(label) + } + + func setupConstraints() { + snp.makeConstraints { make in + make.height.equalTo(Constant.height) + } + + iconView.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalToSuperview().inset(Constant.horizontalEdges) + make.size.equalTo(Constant.iconSize) + } + + label.snp.makeConstraints { make in + make.verticalEdges.equalToSuperview().inset(Constant.verticalEdgesInset) + make.leading.equalTo(self.iconView.snp.trailing).offset(Constant.spacing) + make.trailing.equalToSuperview().inset(Constant.horizontalEdges) + } + } + + func configureUI(message: String?) { + self.backgroundColor = .error100 + self.layer.cornerRadius = Constant.cornerRadius + self.clipsToBounds = true + self.label.attributedText = .makeStyledString(font: .b_s_r, text: message, color: .error900) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/FloatingActionButton.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/FloatingActionButton.swift new file mode 100644 index 00000000..841459a8 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/FloatingActionButton.swift @@ -0,0 +1,34 @@ +import UIKit + +import SnapKit + +public final class FloatingActionButton: UIButton { + // MARK: - Properties + private var action: (() -> Void)? + + // MARK: - LifeCycle + public init(action: @escaping () -> Void) { + self.action = action + super.init(frame: .zero) + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension FloatingActionButton { + func configureUI() { + setImage(UIImage(named: "fab"), for: .normal) + layer.cornerRadius = 24 + clipsToBounds = true + addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + @objc private func buttonTapped() { + action?() + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/GuideAlert.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/GuideAlert.swift new file mode 100644 index 00000000..13e1f05e --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/GuideAlert.swift @@ -0,0 +1,110 @@ +import UIKit + +import SnapKit + +public class GuideAlert: UIView { + // MARK: - Type + enum Constant { + static let verticalInset: CGFloat = 20 + static let horizontalInset: CGFloat = 20 + static let iconSize: CGFloat = 48 + static let verticalSpacing: CGFloat = 24 + static let buttonSpacing: CGFloat = 4 + static let buttonHeight: CGFloat = 48 + static let alertWidth: CGFloat = 327 + static let radius: CGFloat = 24 + } + + // MARK: - Components + private let warningIconView: UIImageView = { + let view = UIImageView() + view.image = UIImage(named: "warning") + return view + }() + + public let mainTextLabel = UILabel() + public let buttonStackView: UIStackView = { + let view = UIStackView() + view.spacing = Constant.buttonSpacing + return view + }() + + public var ctaButton: CommonButton + public var cancelButton: CommonButton? + + // MARK: - init + public init(mainText: String, ctaText: String, cancelText: String?, ctaRatio: Double = 0.7) { + mainTextLabel.attributedText = .makeStyledString(font: .sub_l_b, text: mainText) + self.ctaButton = CommonButton(style: .normal, title: ctaText, disabledTitle: nil) + self.cancelButton = cancelText.map { CommonButton(style: .border, title: $0, disabledTitle: nil) } + mainTextLabel.numberOfLines = 0 + super.init(frame: .zero) + + addViews(cancelText: cancelText) + setupConstraints(ctaRatio: ctaRatio) + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension GuideAlert { + func addViews(cancelText: String?) { + addSubview(warningIconView) + addSubview(mainTextLabel) + addSubview(buttonStackView) + + if let cancelButton = cancelButton { + buttonStackView.addArrangedSubview(cancelButton) + } + buttonStackView.addArrangedSubview(ctaButton) + } + + func setupConstraints(ctaRatio: Double) { + snp.makeConstraints { make in + make.width.equalTo(Constant.alertWidth) + } + + warningIconView.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.verticalInset) + make.centerX.equalToSuperview() + make.size.equalTo(Constant.iconSize) + } + + mainTextLabel.snp.makeConstraints { make in + make.top.equalTo(warningIconView.snp.bottom).offset(Constant.verticalInset) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + } + + buttonStackView.snp.makeConstraints { make in + make.top.equalTo(mainTextLabel.snp.bottom).offset(Constant.verticalSpacing) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.bottom.equalToSuperview().inset(Constant.verticalInset) + make.height.equalTo(Constant.buttonHeight) + } + + if let cancelButton = cancelButton { + let cancelRatio = 1 - ctaRatio + + cancelButton.snp.makeConstraints { make in + make.width.equalTo(buttonStackView.snp.width).multipliedBy(cancelRatio).offset(-Constant.buttonSpacing) + } + ctaButton.snp.makeConstraints { make in + make.width.equalTo(buttonStackView.snp.width).multipliedBy(ctaRatio).offset(-Constant.buttonSpacing) + } + } else { + ctaButton.snp.makeConstraints { make in + make.width.equalTo(buttonStackView.snp.width) + } + } + } + + func configureUI() { + backgroundColor = .whiteMLS + layer.cornerRadius = Constant.radius + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Header.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Header.swift new file mode 100644 index 00000000..1da7a8b4 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Header.swift @@ -0,0 +1,102 @@ +import UIKit + +import SnapKit + +public final class Header: UIStackView { + // MARK: - Type + public enum HeaderStyle { + case main + case filter + + public var titleFont: UIFont? { + switch self { + case .main: + return .h_xxxl_sb + case .filter: + return .h_xl_sb + } + } + + var icons: [UIImage] { + switch self { + case .main: + return [DesignSystemAsset.image(named: "search"), DesignSystemAsset.image(named: "bell")].compactMap { $0 } + case .filter: + return [DesignSystemAsset.image(named: "largeX")].compactMap { $0 } + } + } + } + + private enum Constant { + static let iconSize: CGFloat = 24 + static let spacing: CGFloat = 16 + static let mainTypeHeight: CGFloat = 44 + } + + // MARK: - Properties + public let style: HeaderStyle + + // MARK: - Components + public let titleLabel = UILabel() + private let spacer = UIView() + public let firstIconButton = UIButton() + public let secondIconButton = UIButton() + + // MARK: - init + public init(style: HeaderStyle, title: String) { + titleLabel.attributedText = .makeStyledString(font: .h_xxxl_sb, text: title) + self.style = style + super.init(frame: .zero) + + addViews() + setupConstraints() + configureUI() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension Header { + func addViews() { + addArrangedSubview(titleLabel) + addArrangedSubview(spacer) + addArrangedSubview(firstIconButton) + if style == .main { + addArrangedSubview(secondIconButton) + } + } + + func setupConstraints() { + if style == .main { + snp.makeConstraints { make in + make.height.equalTo(Constant.mainTypeHeight) + } + } + + firstIconButton.snp.makeConstraints { make in + make.size.equalTo(Constant.iconSize) + } + + secondIconButton.snp.makeConstraints { make in + make.size.equalTo(Constant.iconSize) + } + } + + func configureUI() { + alignment = .center + isLayoutMarginsRelativeArrangement = true + layoutMargins = UIEdgeInsets(top: 0, left: Constant.spacing, bottom: 0, right: Constant.spacing) + axis = .horizontal + spacing = Constant.spacing + titleLabel.font = style.titleFont + titleLabel.textColor = .textColor + firstIconButton.setImage(style.icons[0], for: .normal) + if style == .main { + secondIconButton.setImage(style.icons[1], for: .normal) + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/InputBox.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/InputBox.swift new file mode 100644 index 00000000..9ee776a5 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/InputBox.swift @@ -0,0 +1,105 @@ +import UIKit + +import SnapKit + +public final class InputBox: UIStackView { + // MARK: - Properties + private var type: InputBoxType = .edit { + didSet { + setBorderColor() + } + } + + // MARK: - Components + public let label = UILabel() + public let textField: UITextField = { + let textField = UITextField() + textField.clearButtonMode = .whileEditing + return textField + }() + + public lazy var borderView: UIView = { + let view = UIView() + view.layer.cornerRadius = 8 + view.layer.borderWidth = 1 + view.layer.borderColor = UIColor.neutral300.cgColor + + view.addSubview(textField) + + textField.snp.makeConstraints { make in + make.verticalEdges.equalToSuperview().inset(16) + make.leading.equalToSuperview().inset(20) + make.trailing.equalToSuperview().inset(10) + } + return view + }() + + // MARK: - Init + public init(label: String? = nil, placeHodler: String? = nil) { + super.init(frame: .zero) + configureUI(label: label, placeHodler: placeHodler) + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension InputBox { + func setupStackView() { + addArrangedSubview(label) + addArrangedSubview(borderView) + + spacing = 4 + axis = .vertical + alignment = .leading + } + + func setupLabel(label: String?) { + self.label.attributedText = .makeStyledString(font: .b_s_r, text: label, color: .neutral700, alignment: .left) + } + + func setupTextField(placeHolder: String?) { + textField.attributedPlaceholder = .makeStyledString(font: .b_m_r, text: placeHolder, color: .neutral500, alignment: .left) + } + + func setupConstaraints() { + borderView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview() + } + } + + func configureUI(label: String?, placeHodler: String?) { + setupStackView() + setupLabel(label: label) + setupTextField(placeHolder: placeHodler) + setupConstaraints() + } + + func setBorderColor() { + borderView.layer.borderColor = type.borderColor.cgColor + } +} + +// MARK: - Mothods +public extension InputBox { + func setType(type: InputBoxType) { + self.type = type + } +} + +public enum InputBoxType { + case edit + case error + + var borderColor: UIColor { + switch self { + case .edit: + return .neutral300 + case .error: + return .error900 + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/ItemImageView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/ItemImageView.swift new file mode 100644 index 00000000..57a15624 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/ItemImageView.swift @@ -0,0 +1,54 @@ +import UIKit + +import SnapKit + +public final class ItemImageView: UIView { + private let imageView = UIImageView() + + init(image: UIImage?, cornerRadius: CGFloat, inset: CGFloat, backgroundColor: UIColor) { + super.init(frame: .zero) + addViews() + setUpConstraints(inset: inset) + configureUI(radius: cornerRadius) + setImage(image: image, backgroundColor: backgroundColor) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension ItemImageView { + func addViews() { + addSubview(imageView) + } + + func setUpConstraints(inset: CGFloat) { + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(inset) + } + } + + func configureUI(radius: CGFloat) { + layer.cornerRadius = radius + imageView.contentMode = .scaleAspectFit + imageView.backgroundColor = .clearMLS + } +} + +public extension ItemImageView { + func setImage(image: UIImage?, backgroundColor: UIColor) { + imageView.image = image + self.backgroundColor = backgroundColor + } + + func setMapImage(image: UIImage?, backgroundColor: UIColor) { + setImage(image: image, backgroundColor: backgroundColor) + imageView.snp.remakeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(40) + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/NavigationBar.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/NavigationBar.swift new file mode 100644 index 00000000..46a5cd02 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/NavigationBar.swift @@ -0,0 +1,182 @@ +import UIKit + +import SnapKit + +public final class NavigationBar: UIView { + // MARK: - Types + public enum NavigationType { + case withUnderLine(String) + case arrowRightLeft + case arrowLeft + case withString(String) + case collection(String) + } + + private enum Constant { + static let spacing: CGFloat = 8 + static let imageSize: CGFloat = 44 + static let rightInset: CGFloat = 16 + static let lineHeight: CGFloat = 1.17 + } + + // MARK: - Properties + private let contentStackView: UIStackView = { + let view = UIStackView() + view.alignment = .center + view.spacing = Constant.spacing + return view + }() + + public let leftButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(DesignSystemAsset.image(named: "arrowBack").withRenderingMode(.alwaysTemplate), for: .normal) + button.tintColor = .textColor + return button + }() + + public let rightButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(DesignSystemAsset.image(named: "arrowForward").withRenderingMode(.alwaysTemplate), for: .normal) + button.tintColor = .textColor + return button + }() + + public let underlineTextButton: UIButton = { + let button = UIButton() + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) + return button + }() + + public let boldTextButton: UIButton = { + let button = UIButton() + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) + return button + }() + + private let collectionTitleLabel = UILabel() + public let editButton: UIButton = { + let button = UIButton() + button.setImage(DesignSystemAsset.image(named: "edit"), for: .normal) + return button + }() + + public let addButton: UIButton = { + let button = UIButton() + button.setImage(DesignSystemAsset.image(named: "addIcon"), for: .normal) + return button + }() + + // MARK: - init + public init(type: NavigationType) { + super.init(frame: .zero) + addViews(type: type) + setupConstraints(type: type) + configureUI(type: type) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension NavigationBar { + func addViews(type: NavigationType) { + addSubview(contentStackView) + + switch type { + case .withUnderLine: + contentStackView.addArrangedSubview(leftButton) + contentStackView.addArrangedSubview(UIView()) + contentStackView.addArrangedSubview(underlineTextButton) + case .arrowRightLeft: + contentStackView.addArrangedSubview(leftButton) + contentStackView.addArrangedSubview(UIView()) + contentStackView.addArrangedSubview(rightButton) + case .arrowLeft: + contentStackView.addArrangedSubview(leftButton) + contentStackView.addArrangedSubview(UIView()) + case .withString: + contentStackView.addArrangedSubview(leftButton) + contentStackView.addArrangedSubview(UIView()) + contentStackView.addArrangedSubview(boldTextButton) + case .collection: + contentStackView.addArrangedSubview(leftButton) + contentStackView.addArrangedSubview(collectionTitleLabel) + contentStackView.addArrangedSubview(UIView()) + contentStackView.addArrangedSubview(editButton) + contentStackView.addArrangedSubview(addButton) + } + } + + func setupConstraints(type: NavigationType) { + contentStackView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + leftButton.snp.makeConstraints { make in + make.size.equalTo(Constant.imageSize) + } + switch type { + case .withUnderLine, .arrowRightLeft: + rightButton.snp.makeConstraints { make in + make.size.equalTo(Constant.imageSize) + } + case .withString: + boldTextButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(10) + } + case .collection: + editButton.snp.makeConstraints { make in + make.size.equalTo(Constant.imageSize) + } + addButton.snp.makeConstraints { make in + make.size.equalTo(Constant.imageSize) + } + default: + break + } + } + + func configureUI(type: NavigationType) { + switch type { + case .withUnderLine(let title): + guard let lineHeight = UIFont.b_s_r?.lineHeight, + let font = UIFont.b_m_r else { return } + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.minimumLineHeight = lineHeight * Constant.lineHeight + paragraphStyle.maximumLineHeight = lineHeight * Constant.lineHeight + paragraphStyle.alignment = .center + + let attributedString = NSAttributedString( + string: title, + attributes: [ + .font: font, + .foregroundColor: UIColor.neutral700, + .underlineStyle: NSUnderlineStyle.single.rawValue, + .underlineColor: UIColor.neutral700, + .paragraphStyle: paragraphStyle + ] + ) + underlineTextButton.setAttributedTitle(attributedString, for: .normal) + case .withString(let title): + boldTextButton.setAttributedTitle( + .makeStyledString(font: .btn_m_b, text: title), + for: .normal + ) + case .collection(let title): + collectionTitleLabel.text = title + collectionTitleLabel.font = .b_m_r + collectionTitleLabel.textColor = .textColor + case .arrowRightLeft, .arrowLeft: + break + } + } +} + +public extension NavigationBar { + func setTitle(title: String) { + collectionTitleLabel.text = title + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/SearchBar.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/SearchBar.swift new file mode 100644 index 00000000..35cb221a --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/SearchBar.swift @@ -0,0 +1,163 @@ +import UIKit + +import SnapKit + +public protocol SearchBarDelegate: AnyObject { + func searchBarDidReturn(_ searchBar: SearchBar, text: String) +} + +public final class SearchBar: UIView { + // MARK: - Properties + public weak var searchDelegate: SearchBarDelegate? + + // MARK: - Components + public let backButton: UIButton = { + let button = UIButton(type: .system) + let image = DesignSystemAsset.image(named: "arrowBack").withRenderingMode(.alwaysTemplate) + button.setImage(image, for: .normal) + button.tintColor = .textColor + return button + }() + + public let textField: UITextField = { + let textField = UITextField() + textField.font = .b_l_r + textField.textColor = .textColor + textField.attributedPlaceholder = .makeStyledString(font: .b_l_r, text: "찾는 정보를 검색해 보세요", color: .neutral300) + textField.textAlignment = .left + textField.tintColor = .primary300 + textField.returnKeyType = .search + return textField + }() + + private let contentStackView: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.alignment = .center + view.spacing = 8 + return view + }() + + public let searchButton: UIButton = { + let button = UIButton(type: .system) + let image = DesignSystemAsset.image(named: "search").withRenderingMode(.alwaysTemplate) + button.setImage(image, for: .normal) + button.tintColor = .textColor + return button + }() + + public let clearButton: UIButton = { + let button = UIButton(type: .custom) + let image = DesignSystemAsset.image(named: "textFieldClear") + button.setImage(image, for: .normal) + button.isHidden = true + return button + }() + + private let lineView: UIView = { + let view = UIView() + view.backgroundColor = .neutral300 + return view + }() + + private let fillLineView: UIView = { + let view = UIView() + view.backgroundColor = .primary700 + view.transform = CGAffineTransform(scaleX: 0, y: 1) + return view + }() + + // MARK: - init + public init() { + super.init(frame: .zero) + + addViews() + setupConstraints() + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension SearchBar { + func addViews() { + addSubview(contentStackView) + addSubview(lineView) + lineView.addSubview(fillLineView) + + contentStackView.addArrangedSubview(backButton) + contentStackView.addArrangedSubview(textField) + contentStackView.addArrangedSubview(clearButton) + contentStackView.addArrangedSubview(searchButton) + } + + func setupConstraints() { + contentStackView.snp.makeConstraints { make in + make.verticalEdges.leading.equalToSuperview() + make.trailing.equalToSuperview().inset(10) + } + lineView.snp.makeConstraints { make in + make.horizontalEdges.bottom.equalToSuperview() + make.height.equalTo(2) + } + backButton.snp.makeConstraints { make in + make.size.equalTo(44) + } + searchButton.snp.makeConstraints { make in + make.size.equalTo(44) + } + textField.snp.makeConstraints { make in + make.height.equalTo(25) + } + clearButton.snp.makeConstraints { make in + make.size.equalTo(19) + } + fillLineView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + func configureUI() { + textField.delegate = self + clearButton.addAction(.init(handler: { [weak self] _ in self?.textField.text = "" }), for: .touchUpInside) + searchButton.addAction(.init(handler: { [weak self] _ in self?.endEditing(true) }), for: .touchUpInside) + } +} + +extension SearchBar: UITextFieldDelegate { + public func textFieldDidBeginEditing(_ textField: UITextField) { + clearButton.isHidden = (textField.text ?? "").isEmpty + UIView.animate(withDuration: 0.35, delay: 0, options: [.curveEaseOut]) { + self.fillLineView.transform = CGAffineTransform.identity + } + } + + public func textFieldDidEndEditing(_ textField: UITextField) { + clearButton.isHidden = true + UIView.animate(withDuration: 0.35, delay: 0, options: [.curveEaseIn]) { + self.fillLineView.transform = CGAffineTransform(scaleX: 0.001, y: 1) + } completion: { _ in + self.fillLineView.transform = CGAffineTransform(scaleX: 0, y: 1) + } + } + + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + searchDelegate?.searchBarDidReturn(self, text: textField.text ?? "") + + endEditing(true) + clearButton.isHidden = true + return true + } + + public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + let currentText = textField.text ?? "" + guard let textRange = Range(range, in: currentText) else { return true } + let updatedText = currentText.replacingCharacters(in: textRange, with: string) + clearButton.isHidden = updatedText.isEmpty || !textField.isFirstResponder + return true + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/SnackBar.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/SnackBar.swift new file mode 100644 index 00000000..d093cf82 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/SnackBar.swift @@ -0,0 +1,115 @@ +import UIKit + +import SnapKit + +public final class SnackBar: UIView { + private enum Constant { + static let imageSize: CGFloat = 32 + static let imageInset: CGFloat = 5 + static let horizontalInset: CGFloat = 16 + static let width: CGFloat = 343 + static let height: CGFloat = 48 + static let spacing: CGFloat = 8 + static let radius: CGFloat = 8 + } + + // MARK: - ProPerties + private let type: SnackBarType + + // MARK: - Components + public let imageView: ItemImageView + private let label = UILabel() + private let button = UIButton() + + // MARK: - init + public init(type: SnackBarType, image: UIImage?, imageBackgroundColor: UIColor, text: String, buttonText: String, buttonAction: (() -> Void)?) { + self.type = type + imageView = ItemImageView(image: image, cornerRadius: 3.432, inset: 5, backgroundColor: imageBackgroundColor) + super.init(frame: .zero) + + addViews() + setupConstraints() + configureUI(text: text, buttonText: buttonText, buttonAction: buttonAction) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension SnackBar { + func addViews() { + addSubview(imageView) + addSubview(label) + addSubview(button) + } + + func setupConstraints() { + snp.makeConstraints { make in + make.width.equalTo(Constant.width) + make.height.equalTo(Constant.height) + } + + imageView.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalToSuperview().inset(Constant.horizontalInset) + make.size.equalTo(Constant.imageSize) + } + + label.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalTo(imageView.snp.trailing).offset(Constant.spacing) + } + + button.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalTo(label.snp.trailing) + make.trailing.equalToSuperview().inset(Constant.horizontalInset) + } + + label.setContentHuggingPriority(.defaultLow, for: .horizontal) + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + } + + func configureUI(text: String, buttonText: String, buttonAction: (() -> Void)?) { + layer.cornerRadius = Constant.radius + clipsToBounds = true + + label.adjustsFontSizeToFitWidth = true + label.attributedText = .makeStyledString(font: .b_s_r, text: text, color: .whiteMLS, alignment: .left) + + let attributedTitle = NSAttributedString( + string: buttonText, + attributes: [ + .font: UIFont.btn_xs_r ?? UIFont.systemFont(ofSize: 12), + .foregroundColor: UIColor.whiteMLS, + .underlineStyle: NSUnderlineStyle.single.rawValue, + .underlineColor: UIColor.whiteMLS + ] + ) + button.setAttributedTitle(attributedTitle, for: .normal) + button.backgroundColor = .clear + button.addAction(UIAction { _ in + buttonAction?() + }, for: .touchUpInside) + + switch type { + case .normal: + backgroundColor = .neutral900 + case .delete: + backgroundColor = .error900 + } + } +} + +public extension SnackBar { + enum SnackBarType { + case normal + case delete + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/StepIndicator.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/StepIndicator.swift new file mode 100644 index 00000000..3b219fbc --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/StepIndicator.swift @@ -0,0 +1,57 @@ +import UIKit + +import SnapKit + +public final class StepIndicator: UIStackView { + // MARK: - Type + private enum Constant { + static let circleSize: CGFloat = 8 + static let spacing: CGFloat = 8 + } + + // MARK: - Components + + // MARK: - init + public init(circleCount: Int) { + super.init(frame: .zero) + configureUI(circleCount: circleCount) + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension StepIndicator { + func configureUI(circleCount: Int) { + axis = .horizontal + distribution = .fillEqually + spacing = Constant.spacing + + for _ in 0 ..< circleCount { + let view = UIImageView(image: DesignSystemAsset.image(named: "circle").withRenderingMode(.alwaysTemplate)) + view.contentMode = .scaleAspectFit + view.tintColor = .neutral300 + addArrangedSubview(view) + } + + arrangedSubviews.forEach { view in + view.snp.makeConstraints { make in + make.size.equalTo(Constant.circleSize) + } + } + } +} + +public extension StepIndicator { + func selectIndicator(index: Int) { + guard index >= 0, index < arrangedSubviews.count else { return } + + arrangedSubviews.enumerated().forEach { circleIndex, view in + guard let circle = view as? UIImageView else { return } + circle.tintColor = (index == circleIndex) ? .primary700 : .neutral300 + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tabbar/BottomTabBar.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tabbar/BottomTabBar.swift new file mode 100644 index 00000000..03dc2c5d --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tabbar/BottomTabBar.swift @@ -0,0 +1,100 @@ +import UIKit + +import SnapKit + +// MARK: - Model +public struct TabItem { + var title: String + var icon: UIImage +} + +internal final class BottomTabBar: UIStackView { + // MARK: - Type + private enum Constant { + static let height: CGFloat = 64 + static let buttonSize: CGFloat = 64 + } + + // MARK: - Properties + public var onTabSelected: ((Int) -> Void)? + + private var tabButtons: [TabButton] = [] + private var selectedIndex: Int = 0 { + didSet { + selectIndex() + } + } + + // MARK: - Init + public init(tabItems: [TabItem], selectedIndex: Int) { + self.selectedIndex = selectedIndex + super.init(frame: .zero) + setUpConstraints() + configureUI(tabItems: tabItems) + setupStackView() + selectIndex() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setUpConstraints() { + snp.makeConstraints { make in + make.height.equalTo(Constant.height) + } + } + + private func configureUI(tabItems: [TabItem]) { + tabButtons.forEach { $0.removeFromSuperview() } + tabButtons.removeAll() + arrangedSubviews.forEach { removeArrangedSubview($0); $0.removeFromSuperview() } + + for (index, item) in tabItems.enumerated() { + if index > 0 { + let spacer = UIView() + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + addArrangedSubview(spacer) + } + + let button = TabButton(icon: item.icon, text: item.title) + button.tag = index + button.addTarget(self, action: #selector(tabButtonTapped(_:)), for: .touchUpInside) + addArrangedSubview(button) + tabButtons.append(button) + + button.snp.makeConstraints { make in + make.width.equalTo(Constant.buttonSize) + } + } + } + + private func setupStackView() { + backgroundColor = .systemBackground + axis = .horizontal + distribution = .equalSpacing + spacing = 0 + } + + @objc private func tabButtonTapped(_ sender: TabButton) { + let newIndex = sender.tag + guard newIndex != selectedIndex else { return } + selectedIndex = newIndex + onTabSelected?(newIndex) + } + + private func selectIndex() { + for (index, button) in tabButtons.enumerated() { + button.isSelected = (index == selectedIndex) + } + } + + public func selectTab(index: Int) { + guard index != selectedIndex else { return } + selectedIndex = index + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tabbar/BottomTabBarController.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tabbar/BottomTabBarController.swift new file mode 100644 index 00000000..0a3ebd03 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tabbar/BottomTabBarController.swift @@ -0,0 +1,105 @@ +import UIKit + +import SnapKit + +public final class BottomTabBarController: UITabBarController { + // MARK: - Type + private enum Constant { + static let horizontalInset: CGFloat = 24 + } + + // MARK: - Components + private let divider = DividerView() + private let tabItems: [TabItem] + private let customTabBar: BottomTabBar + + // MARK: - Init + public init(viewControllers: [UIViewController], initialIndex: Int = 0) { + tabItems = [ + TabItem(title: "도감", icon: DesignSystemAsset.image(named: "dictionary")), + TabItem(title: "북마크", icon: DesignSystemAsset.image(named: "bookmarkList")), + TabItem(title: "MY", icon: DesignSystemAsset.image(named: "mypage")) + ] + customTabBar = BottomTabBar(tabItems: tabItems, selectedIndex: initialIndex) + super.init(nibName: nil, bundle: nil) + configureUI(controllers: viewControllers) + selectedIndex = initialIndex + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstraints() + } +} + +// MARK: - SetUp +private extension BottomTabBarController { + func addViews() { + view.addSubview(customTabBar) + view.addSubview(divider) + } + + func setupConstraints() { + divider.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview() + make.bottom.equalTo(customTabBar.snp.top) + } + + customTabBar.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.bottom.equalTo(view.safeAreaLayoutGuide) + } + } + + func configureUI(controllers: [UIViewController]) { + viewControllers = controllers.map { + if $0 is UINavigationController { + return $0 + } else { + return UINavigationController(rootViewController: $0) + } + } + tabBar.isHidden = true + + customTabBar.onTabSelected = { [weak self] index in + UIView.performWithoutAnimation { + self?.selectedIndex = index + self?.customTabBar.selectTab(index: index) + } + } + } +} + +public extension BottomTabBarController { + func setHidden(hidden: Bool, animated: Bool = false) { + guard customTabBar.isHidden != hidden else { return } + + if animated { + UIView.animate(withDuration: 0.3) { + self.customTabBar.alpha = hidden ? 0 : 1 + self.divider.alpha = hidden ? 0 : 1 + } completion: { _ in + self.customTabBar.isHidden = hidden + self.divider.isHidden = hidden + } + } else { + customTabBar.isHidden = hidden + customTabBar.alpha = hidden ? 0 : 1 + divider.isHidden = hidden + divider.alpha = hidden ? 0 : 1 + } + } + + func selectTab(index: Int, animated: Bool = false) { + UIView.performWithoutAnimation { + selectedIndex = index + customTabBar.selectTab(index: index) + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tabbar/TabButton.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tabbar/TabButton.swift new file mode 100644 index 00000000..07646fac --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tabbar/TabButton.swift @@ -0,0 +1,73 @@ +import UIKit + +import SnapKit + +internal final class TabButton: UIButton { + // MARK: - Type + private enum Constant { + static let spacing: CGFloat = 4 + static let iconSize: CGFloat = 24 + } + + // MARK: - Properties + override public var isSelected: Bool { + didSet { + updateUI() + } + } + + // MARK: - Components + private lazy var contentView: UIStackView = { + let view = UIStackView(arrangedSubviews: [iconView, textLabel]) + view.axis = .vertical + view.spacing = Constant.spacing + view.alignment = .center + view.isUserInteractionEnabled = false + return view + }() + + private let iconView = UIImageView() + private let textLabel: UILabel = { + let label = UILabel() + label.textAlignment = .center + return label + }() + + // MARK: - Init + public init(icon: UIImage, text: String) { + super.init(frame: .zero) + iconView.image = icon.withRenderingMode(.alwaysTemplate) + textLabel.text = text + addViews() + setupConstraints() + updateUI() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension TabButton { + func addViews() { + addSubview(contentView) + } + + func setupConstraints() { + contentView.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + iconView.snp.makeConstraints { make in + make.size.equalTo(Constant.iconSize) + } + } + + func updateUI() { + iconView.tintColor = isSelected ? .primary700 : .neutral300 + textLabel.textColor = isSelected ? .primary700 : .neutral700 + textLabel.font = .systemFont(ofSize: 11, weight: isSelected ? .semibold : .regular) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/TagChip.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/TagChip.swift new file mode 100644 index 00000000..61c23aa8 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/TagChip.swift @@ -0,0 +1,162 @@ +import UIKit + +import SnapKit + +public final class TagChip: UIButton { + // MARK: - Type + public enum TagChipStyle { + case normal + case search + case text + + var borderWidth: CGFloat { + switch self { + case .normal, .text: return 0 + case .search: return 1 + } + } + + var borderColor: CGColor { + switch self { + case .normal, .text: return UIColor.clearMLS.cgColor + case .search: return UIColor.neutral300.cgColor + } + } + + var fontColor: UIColor { + switch self { + case .normal, .text: return .primary700 + case .search: return .textColor + } + } + + var backgroundColor: UIColor { + switch self { + case .normal, .text: return .primary50 + case .search: return .clearMLS + } + } + + var radius: CGFloat { + switch self { + case .normal: return 16 + case .text: return 12 + case .search: return 8 + } + } + + var contentInsets: NSDirectionalEdgeInsets { + switch self { + case .normal: + return .init(top: 4, leading: 12, bottom: 4, trailing: 8) + case .text: + return .init(top: 4, leading: 12, bottom: 4, trailing: 12) + case .search: + return .init(top: 4, leading: 10, bottom: 4, trailing: 10) + } + } + + var font: UIFont? { + switch self { + case .text: return .cp_s_sb + case .normal, .search: return .cp_s_r + } + } + + var isHiddenButton: Bool { + switch self { + case .normal, .search: return false + case .text: return true + } + } + } + + private enum Constant { + static let height: CGFloat = 32 + static let imageSize: CGFloat = 24 + } + + // MARK: - Properties + public var style: TagChipStyle { + didSet { updateUI() } + } + + public var text: String? { + didSet { updateUI() } + } + + public let mainTitleLabel = UILabel() + + public let cancelButton: UIButton = { + let button = UIButton(type: .custom) + return button + }() + + private var cancelButtonWidthConstraint: Constraint? + + // MARK: - init + public init(style: TagChipStyle, text: String?) { + self.style = style + self.text = text + super.init(frame: .zero) + + setupLayout() + configureUI() + updateUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError() + } +} + +private extension TagChip { + func setupLayout() { + addSubview(mainTitleLabel) + addSubview(cancelButton) + + snp.makeConstraints { make in + make.height.equalTo(Constant.height) + } + + mainTitleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(style.contentInsets.leading) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(cancelButton.snp.leading).offset(-4) + } + + cancelButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(style.contentInsets.trailing) + make.centerY.equalToSuperview() + cancelButtonWidthConstraint = make.width.equalTo(Constant.imageSize).constraint + make.height.equalTo(Constant.imageSize) + } + } + + func configureUI() { + let image = DesignSystemAsset.image(named: "smallX") + .withRenderingMode(.alwaysTemplate) + cancelButton.setImage(image, for: .normal) + } + + func updateUI() { + backgroundColor = style.backgroundColor + + mainTitleLabel.attributedText = .makeStyledString( + font: style.font, + text: text, + color: style.fontColor + ) + + layer.borderColor = style.borderColor + layer.borderWidth = style.borderWidth + layer.cornerRadius = style.radius + + cancelButton.tintColor = style.fontColor + + let hidden = style.isHiddenButton + cancelButton.isHidden = hidden + cancelButtonWidthConstraint?.update(offset: hidden ? 0 : Constant.imageSize) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/TapButton.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/TapButton.swift new file mode 100644 index 00000000..629fd51b --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/TapButton.swift @@ -0,0 +1,75 @@ +import UIKit + +import SnapKit + +public final class TapButton: UIButton { + // MARK: - Type + private enum Constant { + static let height: CGFloat = 34 + static let borderWidth: CGFloat = 1 + static let radius: CGFloat = 17 + static let contentInsets: NSDirectionalEdgeInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16) + } + + public let mainTitleLabel: UILabel = { + let label = UILabel() + return label + }() + + // MARK: - Properties + override public var isSelected: Bool { + didSet { + updateUI() + } + } + + public var text: String? { + didSet { + updateUI() + } + } + + // MARK: - init + public init(text: String? = nil) { + self.text = text + super.init(frame: .zero) + + setupConstraints() + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension TapButton { + func setupConstraints() { + snp.makeConstraints { make in + make.height.equalTo(Constant.height) + } + addSubview(mainTitleLabel) + mainTitleLabel.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.contentInsets.leading) + make.verticalEdges.equalToSuperview().inset(Constant.contentInsets.top) + } + } + + func configureUI() { + backgroundColor = .clear + layer.borderWidth = Constant.borderWidth + layer.cornerRadius = Constant.radius + layer.borderColor = isSelected ? UIColor.primary700.cgColor : UIColor.neutral200.cgColor + } + + func updateUI() { + mainTitleLabel.attributedText = .makeStyledString( + font: isSelected ? .cp_s_sb : .cp_s_r, + text: text, + color: isSelected ? .primary700 : .neutral700 + ) + layer.borderColor = isSelected ? UIColor.primary700.cgColor : UIColor.neutral200.cgColor + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/TextButton.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/TextButton.swift new file mode 100644 index 00000000..3b05ec06 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/TextButton.swift @@ -0,0 +1,75 @@ +import UIKit + +import SnapKit + +public final class TextButton: UIButton { + // MARK: - Type + + private enum Constant { + static let height: CGFloat = 32 + static let iconSize: CGFloat = 16 + static let horizontalInset: CGFloat = 12 + static let spacing: CGFloat = 4 + static let radius: CGFloat = 16 + } + + // MARK: - Properties + public let iconView: UIImageView = { + let view = UIImageView() + view.image = UIImage(named: "edit")?.withRenderingMode(.alwaysTemplate) + view.tintColor = .neutral700 + return view + }() + + public let textLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .btn_s_r, text: "편집", color: .neutral700) + return label + }() + + // MARK: - init + public init() { + super.init(frame: .zero) + + addViews() + setupConstraints() + configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension TextButton { + func addViews() { + addSubview(iconView) + addSubview(textLabel) + } + + func setupConstraints() { + snp.makeConstraints { make in + make.height.equalTo(Constant.height) + } + + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(Constant.horizontalInset) + make.centerY.equalToSuperview() + make.size.equalTo(Constant.iconSize) + } + + textLabel.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(Constant.spacing) + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(Constant.horizontalInset) + } + } + + func configureUI() { + backgroundColor = .whiteMLS + layer.cornerRadius = Constant.radius + layer.borderWidth = 1 + layer.borderColor = UIColor.neutral300.cgColor + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Toast.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Toast.swift new file mode 100644 index 00000000..a889b4c6 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Toast.swift @@ -0,0 +1,59 @@ +import UIKit + +import SnapKit + +public final class Toast: UIView { + private enum Constant { + static let verticalEdgesInset: CGFloat = 16 + static let horizontalEdges: CGFloat = 16 + static let cornerRadius: CGFloat = 8 + } + + // MARK: - Properties + private let toastContentView: UIView = { + let view = UIView() + view.backgroundColor = .neutral900 + return view + }() + + private let label: UILabel = .init() + + // MARK: - init + public init(message: String?) { + super.init(frame: .zero) + + self.addViews() + self.setupConstraints() + self.configureUI(message: message) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension Toast { + func addViews() { + addSubview(self.toastContentView) + toastContentView.addSubview(self.label) + } + + func setupConstraints() { + toastContentView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + label.snp.makeConstraints { make in + make.verticalEdges.equalToSuperview().inset(Constant.verticalEdgesInset) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalEdges) + } + } + + func configureUI(message: String?) { + layer.cornerRadius = Constant.cornerRadius + clipsToBounds = true + label.attributedText = .makeStyledString(font: .b_s_r, text: message, color: .whiteMLS) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/ToggleBox.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/ToggleBox.swift new file mode 100644 index 00000000..890de020 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/ToggleBox.swift @@ -0,0 +1,69 @@ +import UIKit + +import SnapKit + +public final class ToggleBox: UIView { + // MARK: - Type + enum Constant { + static let margin: CGFloat = 20 + static let toggleWidth: CGFloat = 51 + static let toggleHeight: CGFloat = 31 + static let radius: CGFloat = 8 + static let height: CGFloat = 60 + } + + // MARK: - Components + private let textLabel = UILabel() + + public let toggle: UISwitch = { + let button = UISwitch() + button.thumbTintColor = .whiteMLS + button.onTintColor = .primary700 + button.isOn = false + return button + }() + + public init(text: String?) { + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI(text: text) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension ToggleBox { + func addViews() { + addSubview(textLabel) + addSubview(toggle) + } + + func setupConstraints() { + snp.makeConstraints { make in + make.height.equalTo(Constant.height) + } + + textLabel.snp.makeConstraints { make in + make.verticalEdges.equalToSuperview().inset(Constant.margin) + make.leading.equalToSuperview().inset(Constant.margin) + } + + toggle.snp.makeConstraints { make in + make.leading.equalTo(textLabel.snp.trailing) + make.trailing.centerY.equalToSuperview().inset(Constant.margin) + make.width.equalTo(Constant.toggleWidth) + make.height.equalTo(Constant.toggleHeight) + } + } + + func configureUI(text: String?) { + textLabel.attributedText = .makeStyledString(font: .sub_m_b, text: text, alignment: .left) + backgroundColor = .neutral100 + layer.cornerRadius = Constant.radius + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipOverlayView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipOverlayView.swift new file mode 100644 index 00000000..db5fe8fa --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipOverlayView.swift @@ -0,0 +1,26 @@ +import UIKit + +import RxCocoa +import RxGesture +import RxSwift + +internal final class TooltipOverlayView: UIView { + // MARK: - Properties + private let disposeBag = DisposeBag() + let dismiss = PublishRelay() + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + + rx.tapGesture() + .when(.recognized) + .map { _ in } + .bind(to: dismiss) + .disposed(by: disposeBag) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipView.swift new file mode 100644 index 00000000..4eddb2ed --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipView.swift @@ -0,0 +1,140 @@ +import UIKit + +import SnapKit + +/// Tooltip이 뻗어나가는 방향 +public enum TooltipPosition { + case topLeading + case topTrailing + case bottomLeading + case bottomTrailing +} + +private enum Constants { + static let arrowSize = CGSize(width: 16, height: 10) + static let cornerRadius: CGFloat = 16 + static let arrowInset: CGFloat = 28 /// arrow는 툴팁 내부에서 고정 위치 +} + +final class TooltipView: UIView { + + // MARK: - Properties + private let tooltipPosition: TooltipPosition + private let shapeLayer = CAShapeLayer() + + // MARK: - Components + private let label = UILabel() + + // MARK: - init + init(text: String, tooltipPosition: TooltipPosition) { + self.tooltipPosition = tooltipPosition + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI(text: text) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - Layout +private extension TooltipView { + + func addViews() { + addSubview(label) + } + + func setupConstraints() { + switch tooltipPosition { + + /// 툴팁이 아래에 위치 + case .bottomLeading, .bottomTrailing: + label.snp.makeConstraints { make in + make.top.equalToSuperview().inset(11) + make.horizontalEdges.equalToSuperview().inset(18) + make.bottom.equalToSuperview().inset(11 + Constants.arrowSize.height) + } + + /// 툴팁이 위에 위치 + case .topLeading, .topTrailing: + label.snp.makeConstraints { make in + make.top.equalToSuperview().inset(11 + Constants.arrowSize.height) + make.horizontalEdges.equalToSuperview().inset(18) + make.bottom.equalToSuperview().inset(11) + } + } + } + + func configureUI(text: String) { + backgroundColor = .clear + + label.attributedText = .makeStyledString(font: .b_s_r, text: text) + label.numberOfLines = 0 + + layer.insertSublayer(shapeLayer, at: 0) + } +} + +// MARK: - Draw Bubble +extension TooltipView { + + override func layoutSubviews() { + super.layoutSubviews() + drawBubble() + } + + /// 말풍선 + arrow path 생성 + private func drawBubble() { + + let rect = bounds + let arrowHeight = Constants.arrowSize.height + let arrowWidth = Constants.arrowSize.width + + /// 툴팁 상하 위치 판단 + let isTop = tooltipPosition == .bottomLeading || tooltipPosition == .bottomTrailing + + /// 실제 툴팁 영역 + let bubbleRect = CGRect( + x: 0, + y: isTop ? 0 : arrowHeight, + width: rect.width, + height: rect.height - arrowHeight + ) + + let bubblePath = UIBezierPath( + roundedRect: bubbleRect, + cornerRadius: Constants.cornerRadius + ) + + let arrowX: CGFloat + switch tooltipPosition { + case .topLeading, .bottomLeading: + arrowX = Constants.arrowInset + + case .topTrailing, .bottomTrailing: + arrowX = bubbleRect.width - Constants.arrowInset + } + + let arrowPath = UIBezierPath() + + if isTop { + arrowPath.move(to: CGPoint(x: arrowX - arrowWidth/2, y: bubbleRect.minY)) + arrowPath.addLine(to: CGPoint(x: arrowX, y: bubbleRect.minY - arrowHeight)) + arrowPath.addLine(to: CGPoint(x: arrowX + arrowWidth/2, y: bubbleRect.minY)) + } else { + arrowPath.move(to: CGPoint(x: arrowX - arrowWidth/2, y: bubbleRect.maxY)) + arrowPath.addLine(to: CGPoint(x: arrowX, y: bubbleRect.maxY + arrowHeight)) + arrowPath.addLine(to: CGPoint(x: arrowX + arrowWidth/2, y: bubbleRect.maxY)) + } + + arrowPath.close() + bubblePath.append(arrowPath) + + shapeLayer.frame = bounds + shapeLayer.path = bubblePath.cgPath + shapeLayer.fillColor = UIColor.whiteMLS.cgColor + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/BaseErrorViewController.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/BaseErrorViewController.swift new file mode 100644 index 00000000..03e79c3f --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/BaseErrorViewController.swift @@ -0,0 +1,100 @@ +import UIKit + +import RxCocoa +import RxSwift +import SnapKit + +@MainActor +public final class BaseErrorViewController: UIViewController { + // MARK: - Type + private enum Constant { + static let imageHeight: CGFloat = 171 + static let imageWidth: CGFloat = 165 + static let componentsSpacing: CGFloat = 24 + static let bottomButtonBottomSpacing: CGFloat = 16 + static let centerYMultiplied: CGFloat = 0.7 + } + + // MARK: - Properties + private var disposeBag = DisposeBag() + + private let containerView: UIView = UIView() + + private let imageView: UIImageView = { + let image = DesignSystemAsset.image(named: "errorImage") + let view = UIImageView(image: image) + return view + }() + + private let descriptionLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.attributedText = .makeStyledString(font: .b_m_r, text: "알 수 없는 오류가 발생했어요.\n이전 화면으로 돌아가 다시 시도해 주세요.") + return label + }() + + private let backButton = CommonButton(style: .normal, title: "뒤로가기", disabledTitle: nil) + + public init() { + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } + + @MainActor public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension BaseErrorViewController { + public override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + bind() + } +} + +// MARK: - SetUp +private extension BaseErrorViewController { + func addViews() { + view.addSubview(backButton) + containerView.addSubview(imageView) + containerView.addSubview(descriptionLabel) + view.addSubview(containerView) + } + + func setupConstraints() { + backButton.snp.makeConstraints { make in + make.bottom.horizontalEdges.equalTo(view.safeAreaLayoutGuide).inset(Constant.bottomButtonBottomSpacing) + } + imageView.snp.makeConstraints { make in + make.height.equalTo(Constant.imageHeight) + make.width.equalTo(Constant.imageWidth) + make.top.equalToSuperview().inset(Constant.componentsSpacing) + make.centerX.equalToSuperview() + } + descriptionLabel.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom).offset(Constant.componentsSpacing) + make.horizontalEdges.equalToSuperview() + make.bottom.equalToSuperview() + } + containerView.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalToSuperview().multipliedBy(Constant.centerYMultiplied) + } + } + + func configureUI() { } + + func bind() { + backButton.rx.tap + .withUnretained(self) + .subscribe { (owner, _) in + owner.dismiss(animated: true) + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/BaseListView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/BaseListView.swift new file mode 100644 index 00000000..9463d6c8 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/BaseListView.swift @@ -0,0 +1,175 @@ +import UIKit + +import SnapKit + +open class BaseListView: UIView { + // MARK: - Type + enum Constant { + static let filterInset: CGFloat = 6 + static let filterHeight: CGFloat = 32 + static let iconSize: CGFloat = 24 + static let stackViewSpacing: CGFloat = 12 + static let topMargin: CGFloat = 12 + static let cellSpacing: CGFloat = 10 + static let cellWidth: CGFloat = 343 + static let cellHeight: CGFloat = 104 + static let horizontalMargin: CGFloat = 16 + static let bottomInset: CGFloat = 64 + } + + // MARK: - Components + public let editButton: UIButton? + public let listCollectionView: UICollectionView + public let sortButton: UIButton + public let filterButton: UIButton + public let emptyView: DataEmptyView + + private lazy var filterStackView: UIStackView = { + var subviews: [UIView] = [] + + if let editButton = editButton { + subviews.append(editButton) + } + subviews.append(UIView()) + subviews.append(sortButton) + subviews.append(filterButton) + + let view = UIStackView(arrangedSubviews: subviews) + view.axis = .horizontal + view.spacing = Constant.stackViewSpacing + view.alignment = .fill + return view + }() + + // MARK: - Init + public init(editButton: UIButton? = nil, + sortButton: UIButton, + filterButton: UIButton, + emptyView: DataEmptyView, + isFilterHidden: Bool) { + self.editButton = editButton + self.sortButton = sortButton + self.filterButton = filterButton + self.emptyView = emptyView + self.listCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout()) + + super.init(frame: .zero) + addViews(isFilterHidden: isFilterHidden) + setupConstraints(isFilterHidden: isFilterHidden) + configureUI() + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { fatalError() } +} + +// MARK: - Setup +private extension BaseListView { + func addViews(isFilterHidden: Bool) { + if !isFilterHidden { + addSubview(filterStackView) + } + addSubview(listCollectionView) + addSubview(emptyView) + } + + func setupConstraints(isFilterHidden: Bool) { + if isFilterHidden { + listCollectionView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + emptyView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } else { + filterStackView.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.topMargin) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalMargin) + make.height.equalTo(Constant.filterHeight) + } + + listCollectionView.snp.makeConstraints { make in + make.top.equalTo(filterStackView.snp.bottom).offset(Constant.topMargin) + make.horizontalEdges.bottom.equalToSuperview() + } + + emptyView.snp.makeConstraints { make in + make.top.equalTo(filterStackView.snp.bottom).offset(Constant.topMargin) + make.horizontalEdges.bottom.equalToSuperview() + } + } + } + + func configureUI() { + backgroundColor = .neutral100 + listCollectionView.backgroundColor = .neutral100 + } +} + +// MARK: - Methods +public extension BaseListView { + func updateFilter(sortType: String?) { + let hasFilter = sortType != nil + filterStackView.isHidden = !hasFilter + + listCollectionView.snp.remakeConstraints { make in + if hasFilter { + make.top.equalTo(filterStackView.snp.bottom).offset(Constant.topMargin) + } else { + make.top.equalToSuperview() + } + make.horizontalEdges.bottom.equalToSuperview() + } + + if let sortType = sortType { + sortButton.setAttributedTitle(.makeStyledString(font: .b_s_r, text: sortType, color: sortButton.tintColor), for: .normal) + } + } + + func updateBookmarkFilter(type: String) { + if type == "total" { + filterButton.isHidden = true + } + } + + static func makeSortButton(title: String, tintColor: UIColor) -> UIButton { + let button = UIButton() + button.setAttributedTitle(.makeStyledString(font: .b_s_r, text: title), for: .normal) + button.setImage(DesignSystemAsset.image(named: "lineArrowDown").withRenderingMode(.alwaysTemplate), for: .normal) + button.tintColor = tintColor + button.setTitleColor(tintColor, for: .normal) + button.semanticContentAttribute = .forceRightToLeft + return button + } + + static func makeFilterButton(title: String, tintColor: UIColor) -> UIButton { + let button = UIButton() + button.setAttributedTitle(.makeStyledString(font: .b_s_r, text: title), for: .normal) + button.setImage(DesignSystemAsset.image(named: "filter").withRenderingMode(.alwaysTemplate), for: .normal) + button.tintColor = tintColor + button.setTitleColor(tintColor, for: .normal) + button.semanticContentAttribute = .forceRightToLeft + return button + } + + func selectSort(selectedType: String) { + sortButton.setAttributedTitle(.makeStyledString(font: .b_s_r, text: selectedType, color: .primary700), for: .normal) + sortButton.tintColor = .primary700 + } + + func selectFilter() { + filterButton.setAttributedTitle(.makeStyledString(font: .b_s_r, text: "필터", color: .primary700), for: .normal) + filterButton.tintColor = .primary700 + } + + func resetFilter() { + filterButton.setAttributedTitle(.makeStyledString(font: .b_s_r, text: "필터"), for: .normal) + filterButton.tintColor = .black + } + + func checkEmptyData(isEmpty: Bool) { + emptyView.isHidden = !isEmpty + listCollectionView.isHidden = isEmpty + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CharacterInputView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CharacterInputView.swift new file mode 100644 index 00000000..b69613e0 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CharacterInputView.swift @@ -0,0 +1,146 @@ +import UIKit + +import RxCocoa +import RxSwift +import SnapKit + +public enum CharacterViewType { + case normal + case recommend + + var title: String { + switch self { + case .normal: + "현재 레벨과 직업을\n입력해주세요." + case .recommend: + "사냥터 추천을 위해\n현재 레벨과 직업을 입력해주세요." + } + } +} + +open class CharacterInputView: UIView { + // MARK: - Type + public enum Constant { + public static let horizontalInset: CGFloat = 16 + public static let verticalInset: CGFloat = 40 + static let verticalSpacing: CGFloat = 28 + static let horizontalSpacing: CGFloat = 8 + public static let bottomInset: CGFloat = 16 + static let messageSpacing: CGFloat = 8 + static let boxInset: CGFloat = (horizontalInset + (horizontalSpacing / 2)) / 2 + } + + // MARK: - Properties + private let disposeBag = DisposeBag() + public var nextButtonBottomConstraint: Constraint? + + // MARK: - Components + public let descriptionLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 2 + return label + }() + + public let inputBox: InputBox = { + let box = InputBox(label: "레벨", placeHodler: "1~200") + box.textField.keyboardType = .numberPad + return box + }() + + public let dropDownBox = DropDownBox(label: "직업", placeHodler: "선택", items: []) + + public let errorMessage = ErrorMessage(message: "1에서 200까지 숫자만 입력해주세요") + + public let nextButton = CommonButton(style: .normal, title: "다음", disabledTitle: "다음") + + // MARK: - init + public init(type: CharacterViewType = .normal) { + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI(type: type) + setGesture() + } + + @available(*, unavailable) + required public init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension CharacterInputView { + func addViews() { + addSubview(descriptionLabel) + addSubview(inputBox) + addSubview(dropDownBox) + addSubview(errorMessage) + addSubview(nextButton) + } + + func setupConstraints() { + inputBox.snp.makeConstraints { make in + make.top.equalTo(descriptionLabel.snp.bottom).offset(Constant.verticalSpacing) + make.leading.equalToSuperview().inset(Constant.horizontalInset) + make.width.equalToSuperview().multipliedBy(0.5).inset(Constant.boxInset) + } + + dropDownBox.snp.makeConstraints { make in + make.top.equalTo(inputBox) + make.leading.equalTo(inputBox.snp.trailing).offset(Constant.horizontalSpacing) + make.trailing.equalToSuperview().inset(Constant.horizontalInset) + make.width.equalToSuperview().multipliedBy(0.5).inset(Constant.boxInset) + } + + errorMessage.snp.makeConstraints { make in + make.centerX.equalToSuperview() + } + + nextButton.snp.makeConstraints { make in + make.top.equalTo(errorMessage.snp.bottom).offset(Constant.messageSpacing) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + nextButtonBottomConstraint = make.bottom.equalToSuperview().inset(Constant.bottomInset).constraint + } + } + + func configureUI(type: CharacterViewType) { + inputBox.textField.delegate = self + errorMessage.isHidden = true + + descriptionLabel.attributedText = .makeStyledString(font: .h_xxl_b, text: type.title, alignment: .left) + } + + /// inputBox를 제외한 영역 선택시 키보드 제거 + func setGesture() { + let tapGesture = UITapGestureRecognizer() + tapGesture.cancelsTouchesInView = false + addGestureRecognizer(tapGesture) + + Observable.merge( + tapGesture.rx.event.map { $0.location(in: self) }.asObservable() + ) + .withUnretained(self) + .filter { owner, location in + !owner.inputBox.frame.contains(location) + } + .subscribe { owner, _ in + owner.inputBox.textField.resignFirstResponder() + } + .disposed(by: disposeBag) + } +} + +// MARK: - UITextFieldDelegate +extension CharacterInputView: UITextFieldDelegate { + /// textField의 붙여넣기 기능 차단 + /// - Parameters: + /// - action: 선택자를 나타내는 selector + /// - sender: 동작을 트리거한 객체 + /// - Returns: 붙여넣기가 허용되면 true / 허용되지 않으면 false + override public func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(paste(_:)) { + return false + } + return super.canPerformAction(action, withSender: sender) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/DataEmptyView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/DataEmptyView.swift new file mode 100644 index 00000000..cf7bc3dd --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/DataEmptyView.swift @@ -0,0 +1,102 @@ +import UIKit + +import SnapKit + +public enum EmptyViewType { + case dictionary + case bookmark +} + +public final class DataEmptyView: UIView { + // MARK: - Type + enum Constant { + static let imageSize: CGFloat = 220 + static let textSpacing: CGFloat = 10 + static let buttonSpacing: CGFloat = 24 + static let buttonWidth: CGFloat = 186 + } + + // MARK: - Components + public let imageView = UIImageView() + private let mainLabel = UILabel() + private let subLabel = UILabel() + + public let button = CommonButton() + + // MARK: - Init + public init(type: EmptyViewType) { + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI(type: type) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension DataEmptyView { + func addViews() { + addSubview(imageView) + addSubview(mainLabel) + addSubview(subLabel) + addSubview(button) + } + + func setupConstraints() { + imageView.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.size.equalTo(Constant.imageSize) + } + + mainLabel.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom) + make.centerY.equalToSuperview() + make.horizontalEdges.equalToSuperview() + } + + subLabel.snp.makeConstraints { make in + make.top.equalTo(mainLabel.snp.bottom).offset(Constant.textSpacing) + make.centerX.equalToSuperview() + } + + button.snp.makeConstraints { make in + make.top.equalTo(subLabel.snp.bottom).offset(Constant.buttonSpacing) + make.centerX.equalToSuperview() + make.width.equalTo(Constant.buttonWidth) + } + } + + func configureUI(type: EmptyViewType) { + backgroundColor = .neutral100 + + switch type { + case .dictionary: + imageView.image = DesignSystemAsset.image(named: "noResult") + mainLabel.attributedText = .makeStyledString( + font: .b_m_r, + text: "검색 결과가 없습니다." + ) + + subLabel.isHidden = true + button.isHidden = true + case .bookmark: + imageView.image = DesignSystemAsset.image(named: "noShowList") + mainLabel.attributedText = .makeStyledString( + font: .h_xl_b, + text: "아직 아무것도 없어요!" + ) + + subLabel.attributedText = .makeStyledString( + font: .cp_s_r, + text: "북마크해서 추가해보세요.", + color: .neutral600 + ) + + button.updateTitle(title: "북마크하러 가기") + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/GuideAlertFactory.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/GuideAlertFactory.swift new file mode 100644 index 00000000..a19c01ca --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/GuideAlertFactory.swift @@ -0,0 +1,108 @@ +import UIKit + +import RxCocoa +import RxSwift +import SnapKit + +@MainActor +public enum GuideAlertFactory { + private static var currentAlertView: GuideAlert? + private static var dimmedView: UIView? + private static var containerView: UIView? + private static var disposeBag = DisposeBag() + + public static func show( + mainText: String, + ctaText: String, + cancelText: String? = nil, + ctaAction: @escaping () -> Void, + cancelAction: (() -> Void)? = nil + ) { + let alert = GuideAlert(mainText: mainText, ctaText: ctaText, cancelText: cancelText) + presentAlert(alert: alert, ctaAction: ctaAction, cancelAction: cancelAction) + } + + public static func showAuthAlert( + type: AuthGuideAlert.AuthGuideAlertType, + ctaAction: @escaping () -> Void, + cancelAction: (() -> Void)? = nil + ) { + let alert = AuthGuideAlert(type: type) + presentAlert(alert: alert, ctaAction: ctaAction, cancelAction: cancelAction) + } + + private static func presentAlert( + alert: GuideAlert, + ctaAction: @escaping () -> Void, + cancelAction: (() -> Void)? = nil + ) { + guard currentAlertView == nil, dimmedView == nil else { return } + guard let windowScene = UIApplication.shared.connectedScenes + .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) else { return } + + let container = UIView(frame: window.bounds) + window.addSubview(container) + + let dimmed = UIView() + dimmed.backgroundColor = UIColor.black.withAlphaComponent(0.4) + dimmed.alpha = 0 + container.addSubview(dimmed) + dimmed.snp.makeConstraints { $0.edges.equalToSuperview() } + + alert.alpha = 0 + container.addSubview(alert) + alert.snp.makeConstraints { + $0.center.equalToSuperview() + $0.leading.greaterThanOrEqualToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) + } + + disposeBag = DisposeBag() + + alert.ctaButton.rx.tap + .bind { + dismiss() + ctaAction() + } + .disposed(by: disposeBag) + + if let cancelButton = alert.cancelButton { + cancelButton.rx.tap + .bind { + dismiss() + cancelAction?() + } + .disposed(by: disposeBag) + } + + currentAlertView = alert + dimmedView = dimmed + containerView = container + + UIView.animate(withDuration: 0.25) { + dimmed.alpha = 1 + alert.alpha = 1 + } + } + + public static func dismiss() { + guard let alert = currentAlertView, + let dimmed = dimmedView, + let container = containerView + else { return } + + UIView.animate(withDuration: 0.25, animations: { + alert.alpha = 0 + dimmed.alpha = 0 + }, completion: { _ in + alert.removeFromSuperview() + dimmed.removeFromSuperview() + container.removeFromSuperview() + currentAlertView = nil + dimmedView = nil + containerView = nil + disposeBag = DisposeBag() + }) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/SnackBarFactory.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/SnackBarFactory.swift new file mode 100644 index 00000000..55da2afa --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/SnackBarFactory.swift @@ -0,0 +1,101 @@ +import UIKit + +import RxSwift +import SnapKit + +@MainActor +public enum SnackBarFactory { + // MARK: - Properties + + /// 현재 디바이스 최상단 Window를 지정 + static var window: UIWindow? { + return UIApplication + .shared + .connectedScenes + .flatMap { ($0 as? UIWindowScene)?.windows ?? [] } + .first { $0.isKeyWindow } + } + + /// 최상단의 ViewController를 가져오는 메서드 + private static func topViewController( + _ rootViewController: UIViewController? = window?.rootViewController + ) -> UIViewController? { + if let navigationController = rootViewController as? UINavigationController { + return topViewController(navigationController.visibleViewController) + } + if let tabBarController = rootViewController as? UITabBarController { + return topViewController(tabBarController.selectedViewController) + } + if let presentedViewController = rootViewController?.presentedViewController { + return topViewController(presentedViewController) + } + return rootViewController + } + + private static var currentSnackBar: SnackBar? + private static var disposeBag = DisposeBag() +} + +public extension SnackBarFactory { + // MARK: - Method + + /// SnackBar를 생성하는 메소드 + /// - Parameters: + /// - type: normal / delete + /// - image: 스낵바 이미지 + /// - imageBackgroundColor: 이미지 배경색상 + /// - text: 스낵바에 들어갈 내용 + /// - buttonText: 버튼 제목 + /// - buttonAction: 버튼이 눌렸을때의 액션 + static func createSnackBar( + type: SnackBar.SnackBarType, + image: UIImage, + imageBackgroundColor: UIColor, + text: String, + buttonText: String, + buttonAction: (() -> Void)?, + bottomMargin: CGFloat = 76 + ) { + DispatchQueue.main.async { + currentSnackBar?.removeFromSuperview() + currentSnackBar = nil + + let snackBar = SnackBar( + type: type, + image: image, + imageBackgroundColor: imageBackgroundColor, + text: text, + buttonText: buttonText, + buttonAction: buttonAction + ) + snackBar.alpha = 0 + + // ✅ window 대신 topViewController의 view 사용 + guard let topVC = topViewController() else { return } + topVC.view.addSubview(snackBar) + currentSnackBar = snackBar + + snackBar.snp.makeConstraints { make in + make.bottom.equalTo(topVC.view.safeAreaLayoutGuide.snp.bottom).inset(bottomMargin) + make.centerX.equalToSuperview() + } + + snackBar.imageView.setImage(image: image, backgroundColor: imageBackgroundColor) + + UIView.animate(withDuration: 0.25) { + snackBar.alpha = 1 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.3) { + UIView.animate(withDuration: 0.6, animations: { + snackBar.alpha = 0 + }, completion: { _ in + snackBar.removeFromSuperview() + if currentSnackBar == snackBar { + currentSnackBar = nil + } + }) + } + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/ToastFactory.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/ToastFactory.swift new file mode 100644 index 00000000..096afadd --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/ToastFactory.swift @@ -0,0 +1,76 @@ +import UIKit + +import RxSwift +import SnapKit + +@MainActor +public enum ToastFactory { + + // MARK: - Properties + + /// 현재 디바이스 최상단 Window를 지정 + static var window: UIWindow? { + return UIApplication + .shared + .connectedScenes + .flatMap { ($0 as? UIWindowScene)?.windows ?? [] } + .first { $0.isKeyWindow } + } + + /// 최상단의 ViewController를 가져오는 메서드 + private static func topViewController( + _ rootViewController: UIViewController? = window?.rootViewController + ) -> UIViewController? { + if let navigationController = rootViewController as? UINavigationController { + return topViewController(navigationController.visibleViewController) + } + if let tabBarController = rootViewController as? UITabBarController { + return topViewController(tabBarController.selectedViewController) + } + if let presentedViewController = rootViewController?.presentedViewController { + return topViewController(presentedViewController) + } + return rootViewController + } + + private static var currentToast: Toast? + private static var disposeBag = DisposeBag() +} + +extension ToastFactory { + + // MARK: - Method + + /// 토스트 메시지를 생성하는 메서드 + /// - Parameter message: 토스트 메세지에 담길 String 타입 + public static func createToast(message: String) { + + currentToast?.removeFromSuperview() + currentToast = nil + let toastMSG = Toast(message: message) + guard let window = window else { return } + window.addSubview(toastMSG) + currentToast = toastMSG + + toastMSG.snp.makeConstraints { make in + make.bottom.equalTo(window.snp.bottom).inset(120) + make.centerX.equalTo(window.snp.centerX) + } + + toastMSG.alpha = 0 + UIView.animate(withDuration: 0.25) { + toastMSG.alpha = 1 + } + + UIView.animate( + withDuration: 0.6, + delay: 2.3, + options: .curveEaseOut + ) { + toastMSG.alpha = 0 + } completion: { _ in + toastMSG.removeFromSuperview() + if currentToast == toastMSG { currentToast = nil } + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/TooltipFactory.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/TooltipFactory.swift new file mode 100644 index 00000000..55c86e10 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/TooltipFactory.swift @@ -0,0 +1,115 @@ +import UIKit + +import RxSwift +import SnapKit + +@MainActor +public enum TooltipFactory { + // MARK: - Properties + private static let disposeBag = DisposeBag() + + /// 현재 디바이스 최상단 Window를 지정 + static var window: UIWindow? { + UIApplication.shared + .connectedScenes + .flatMap { ($0 as? UIWindowScene)?.windows ?? [] } + .first { $0.isKeyWindow } + } + + private static var currentTooltip: TooltipView? + + /// 전체 터치 dismiss용 overlay + private static var overlayView: TooltipOverlayView? +} + +public extension TooltipFactory { + /// Tooltip 노출 메소드 + static func show( + text: String, + anchorView: UIView, + tooltipPosition: TooltipPosition + ) { + currentTooltip?.removeFromSuperview() + currentTooltip = nil + + guard let window = window else { return } + + /// 전체 영역 터치 dismiss overlay + let overlay = TooltipOverlayView(frame: window.bounds) + window.addSubview(overlay) + overlayView = overlay + + let tooltip = TooltipView(text: text, tooltipPosition: tooltipPosition) + overlay.addSubview(tooltip) + currentTooltip = tooltip + + overlay.dismiss + .bind { _ in + dismiss() + } + .disposed(by: disposeBag) + + let frame = anchorView.convert(anchorView.bounds, to: window) + + tooltip.frame.origin = CGPoint(x: 0, y: 0) + tooltip.setNeedsLayout() + tooltip.layoutIfNeeded() + + let tooltipSize = tooltip.systemLayoutSizeFitting( + UIView.layoutFittingCompressedSize + ) + + /// arrow가 툴팁 내부에서 위치하는 고정 inset + let arrowInset: CGFloat = 28 + let anchorCenterX = frame.midX + + /// 툴팁 내부 arrow 중심 위치 + let arrowCenterInTooltip: CGFloat + switch tooltipPosition { + case .topLeading, .bottomLeading: + arrowCenterInTooltip = arrowInset + + case .topTrailing, .bottomTrailing: + arrowCenterInTooltip = tooltipSize.width - arrowInset + } + + /// arrow 중심 = 버튼 중심 + let x = anchorCenterX - arrowCenterInTooltip + + let y: CGFloat + switch tooltipPosition { + case .topLeading, .topTrailing: + y = frame.minY - tooltipSize.height - 8 + case .bottomLeading, .bottomTrailing: + y = frame.maxY + 8 + } + + tooltip.frame = CGRect( + x: x, + y: y, + width: tooltipSize.width, + height: tooltipSize.height + ) + + tooltip.alpha = 0 + UIView.animate(withDuration: 0.25) { + tooltip.alpha = 1 + } + } + + /// 툴팁 제거 + static func dismiss() { + guard let tooltip = currentTooltip else { return } + + UIView.animate(withDuration: 0.2, animations: { + tooltip.alpha = 0 + overlayView?.alpha = 0 + }, completion: { _ in + tooltip.removeFromSuperview() + overlayView?.removeFromSuperview() + + currentTooltip = nil + overlayView = nil + }) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/ModalPresentable/ModalPresentable.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/ModalPresentable/ModalPresentable.swift new file mode 100644 index 00000000..8d2b0a76 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/ModalPresentable/ModalPresentable.swift @@ -0,0 +1,25 @@ +import UIKit + +public protocol ModalPresentable { + var modalHeight: CGFloat? { get } + var allowsTapToDismiss: Bool { get } +} + +public extension ModalPresentable { + var allowsTapToDismiss: Bool { return false } +} + +// 모달 구성 관련 상수 정의 +internal enum ModalConfig { + static let containerTransformY: CGFloat = 400 + static let containerBottomInset: CGFloat = 8 + static let containerHorizontalInset: CGFloat = 8 + static let containerCornerRadius: CGFloat = 20 + static let bottomSheetStyleBottomInset: CGFloat = 34 + static let bottomSheetStyleHorizontalInset: CGFloat = 20 + static let alertSheetStyleInset: CGFloat = 20 + + static let gestureBarTopInset: CGFloat = 12 + static let gestureBarWidth: CGFloat = 60 + static let gestureBarHeight: CGFloat = 4 +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/ModalPresentable/ModalWrapperView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/ModalPresentable/ModalWrapperView.swift new file mode 100644 index 00000000..177357e8 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/ModalPresentable/ModalWrapperView.swift @@ -0,0 +1,72 @@ +import UIKit + +import SnapKit + +final class ModalWrapperView: UIView { + + weak var parentViewController: UIViewController? + + let dimView = UIView() + let containerView = UIView() + + private var initialY: CGFloat = 0 + private var containerBottomConstraint: Constraint? + + init(contentViewController: UIViewController & ModalPresentable, parent: UIViewController) { + super.init(frame: .zero) + self.parentViewController = parent + + // 뒷배경 뷰 (반투명) + dimView.backgroundColor = .overlays + dimView.alpha = 0 + addSubview(dimView) + dimView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + if contentViewController.allowsTapToDismiss { + // 탭 시 모달 닫기 + let tap = UITapGestureRecognizer(target: parent, action: #selector(parent.dismissCurrentModal)) + dimView.addGestureRecognizer(tap) + } + + // 모달 컨테이너 + containerView.backgroundColor = .systemBackground + containerView.layer.cornerRadius = ModalConfig.containerCornerRadius + containerView.clipsToBounds = true + containerView.transform = CGAffineTransform(translationX: 0, y: ModalConfig.containerTransformY) + addSubview(containerView) + + containerView.snp.makeConstraints { make in + self.containerBottomConstraint = make.bottom.equalToSuperview().constraint + make.horizontalEdges.equalToSuperview() + } + + // 자식 뷰컨트롤러 embed + parent.addChild(contentViewController) + containerView.addSubview(contentViewController.view) + contentViewController.view.snp.makeConstraints { make in + make.bottom.equalToSuperview().inset(ModalConfig.bottomSheetStyleBottomInset) + make.top.horizontalEdges.equalToSuperview() + if let height = contentViewController.modalHeight { make.height.equalTo(height) } + } + contentViewController.didMove(toParent: parent) + } + + func animateDismiss(completion: @escaping () -> Void) { + if let bottomConstraint = containerBottomConstraint { + // 아래로 내리기 위해 음수 inset + bottomConstraint.update(inset: -containerView.bounds.height) + UIView.animate(withDuration: 0.35, delay: 0, options: [.curveEaseIn], animations: { + self.layoutIfNeeded() + self.dimView.alpha = 0 + }, completion: { _ in + completion() + }) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/TabBarUnderlineController.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/TabBarUnderlineController.swift new file mode 100644 index 00000000..572b7462 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/TabBarUnderlineController.swift @@ -0,0 +1,143 @@ +import UIKit + +import RxCocoa +import RxSwift +import SnapKit + +@MainActor +public final class TabBarUnderlineController { + // MARK: - UI Components + + /// 선택된 탭 아래를 표시하는 인디케이터 뷰 + private let selectionIndicatorView: UIView = { + let view = UIView() + view.backgroundColor = .textColor + return view + }() + + /// 탭 바 하단의 구분선 + private let bottomUnderlineView: UIView = { + let view = UIView() + view.backgroundColor = .neutral300 + return view + }() + + // MARK: - Properties + + private var collectionView: UICollectionView? + private let disposeBag = DisposeBag() + + /// 현재 컬렉션 뷰의 스크롤 오프셋 (인디케이터 위치 계산용) + private var currentScrollOffset: CGPoint? + + // MARK: - Initialization + + public init() {} + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Setup + +private extension TabBarUnderlineController { + func addIndicatorViews(to collectionView: UICollectionView) { + guard let superview = collectionView.superview else { return } + + superview.addSubview(bottomUnderlineView) + bottomUnderlineView.snp.makeConstraints { make in + make.horizontalEdges.bottom.equalTo(collectionView) + make.height.equalTo(1) + } + + superview.addSubview(selectionIndicatorView) + selectionIndicatorView.frame = CGRect( + x: 0, + y: collectionView.frame.maxY - 2, + width: 0, + height: 2 + ) + } +} + +// MARK: - Public Interface + +public extension TabBarUnderlineController { + /// 컬렉션 뷰에 인디케이터 컨트롤러 연결 + func configure(with collectionView: UICollectionView) { + self.collectionView = collectionView + addIndicatorViews(to: collectionView) + } + + /// 컬렉션 뷰 스크롤 오프셋이 변경될 때 호출 (scrollViewDidScroll 등에서) + func updateScrollOffset(_ offset: CGPoint) { + currentScrollOffset = offset + updateIndicatorFrameWithoutAnimation() + } + + /// 선택된 셀 위치에 인디케이터를 즉시 이동 + func updateIndicatorFrameWithoutAnimation() { + guard let collectionView, + let indexPath = collectionView.indexPathsForSelectedItems?.first, + let selectedFrame = collectionView.cellForItem(at: indexPath)?.frame + else { return } + + let xOffset = selectedFrame.minX - (currentScrollOffset?.x ?? 0) + selectionIndicatorView.frame.origin.x = xOffset + } + + /// 선택된 셀 위치로 인디케이터를 애니메이션으로 이동 + func animateIndicatorToSelectedItem() { + guard let collectionView, + let indexPath = collectionView.indexPathsForSelectedItems?.first, + let selectedFrame = collectionView.cellForItem(at: indexPath)?.frame + else { return } + + let xOffset = selectedFrame.minX - (currentScrollOffset?.x ?? 0) + let targetFrame = CGRect( + x: xOffset, + y: collectionView.frame.maxY - 2, + width: selectedFrame.width, + height: 2 + ) + + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: { [weak self] in + self?.selectionIndicatorView.frame = targetFrame + }) + } + + /// 선택된 셀 위치로 인디케이터를 애니메이션으로 이동 + func setInitialIndicator() { + guard let collectionView, + let indexPath = collectionView.indexPathsForSelectedItems?.first, + let selectedFrame = collectionView.cellForItem(at: indexPath)?.frame + else { return } + + let xOffset = selectedFrame.minX - (currentScrollOffset?.x ?? 0) + let targetFrame = CGRect( + x: xOffset, + y: collectionView.frame.maxY - 2, + width: selectedFrame.width, + height: 2 + ) + selectionIndicatorView.frame = targetFrame + } + + func setHidden(hidden: Bool, animated: Bool = false) { + let alpha: CGFloat = hidden ? 0 : 1 + if animated { + UIView.animate(withDuration: 0.25) { + self.selectionIndicatorView.alpha = alpha + self.bottomUnderlineView.alpha = alpha + } + } else { + selectionIndicatorView.alpha = alpha + bottomUnderlineView.alpha = alpha + } + + selectionIndicatorView.isHidden = hidden + bottomUnderlineView.isHidden = hidden + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/ToLoginView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/ToLoginView.swift new file mode 100644 index 00000000..3cad3a23 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/ToLoginView.swift @@ -0,0 +1,107 @@ +import UIKit + +import SnapKit + +public enum LoginViewType { + case bookmark + case recommend + + var mainText: String { + switch self { + case .bookmark: + "북마크는 로그인 후 이용 가능해요!" + case .recommend: + "로그인하면 추천 기능이 열려요!" + } + } + + var subText: String { + switch self { + case .bookmark: + "자주 보는 정보, 검색 없이 바로 확인 할 수 있어요" + case .recommend: + "내 레벨과 직업에 맞춰\n사냥터를 추천받을 수 있어요" + } + } +} + +public final class ToLoginView: UIView { + // MARK: - Type + enum Constant { + static let imageSize: CGFloat = 220 + static let textSpacing: CGFloat = 10 + static let buttonSpacing: CGFloat = 24 + static let buttonWidth: CGFloat = 186 + } + + // MARK: - Components + public let imageView = UIImageView() + private let mainLabel = UILabel() + private let subLabel = UILabel() + + public let button = CommonButton() + + // MARK: - Init + public init(type: LoginViewType) { + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI(type: type) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension ToLoginView { + func addViews() { + addSubview(imageView) + addSubview(mainLabel) + addSubview(subLabel) + addSubview(button) + } + + func setupConstraints() { + imageView.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.size.equalTo(Constant.imageSize) + } + + mainLabel.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom) + make.centerY.equalToSuperview() + make.horizontalEdges.equalToSuperview() + } + + subLabel.snp.makeConstraints { make in + make.top.equalTo(mainLabel.snp.bottom).offset(Constant.textSpacing) + make.centerX.equalToSuperview() + } + + button.snp.makeConstraints { make in + make.top.equalTo(subLabel.snp.bottom).offset(Constant.buttonSpacing) + make.centerX.equalToSuperview() + make.width.equalTo(Constant.buttonWidth) + } + } + + func configureUI(type: LoginViewType) { + backgroundColor = .neutral100 + imageView.image = DesignSystemAsset.image(named: "noShowList") + mainLabel.attributedText = .makeStyledString( + font: .h_xl_b, + text: type.mainText + ) + + subLabel.attributedText = .makeStyledString( + font: .cp_s_r, + text: type.subText, + color: .neutral600 + ) + + button.updateTitle(title: "로그인하러 가기") + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Font/Pretendard-Bold.ttf b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Font/Pretendard-Bold.ttf new file mode 100644 index 00000000..fb07fc65 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Font/Pretendard-Bold.ttf differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Font/Pretendard-Medium.ttf b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Font/Pretendard-Medium.ttf new file mode 100644 index 00000000..1db67c68 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Font/Pretendard-Medium.ttf differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Font/Pretendard-Regular.ttf b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Font/Pretendard-Regular.ttf new file mode 100644 index 00000000..01147e99 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Font/Pretendard-Regular.ttf differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Font/Pretendard-SemiBold.ttf b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Font/Pretendard-SemiBold.ttf new file mode 100644 index 00000000..9f2690f0 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Font/Pretendard-SemiBold.ttf differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideAlert.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideAlert.imageset/Contents.json new file mode 100644 index 00000000..7911bc12 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideAlert.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "guideAlert.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideAlert.imageset/guideAlert.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideAlert.imageset/guideAlert.png new file mode 100644 index 00000000..c7fa2f9f Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideAlert.imageset/guideAlert.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideArrow1.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideArrow1.imageset/Contents.json new file mode 100644 index 00000000..c540110a --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideArrow1.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "guideArrow1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideArrow1.imageset/guideArrow1.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideArrow1.imageset/guideArrow1.png new file mode 100644 index 00000000..07e674cb Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideArrow1.imageset/guideArrow1.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideArrow2.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideArrow2.imageset/Contents.json new file mode 100644 index 00000000..4e3272ad --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideArrow2.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "guideArrow2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideArrow2.imageset/guideArrow2.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideArrow2.imageset/guideArrow2.png new file mode 100644 index 00000000..2b33ee8b Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideArrow2.imageset/guideArrow2.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideIcon.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideIcon.imageset/Contents.json new file mode 100644 index 00000000..6d1fa50c --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "guideIcon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideIcon.imageset/guideIcon.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideIcon.imageset/guideIcon.png new file mode 100644 index 00000000..d92f19e2 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/BookmarkOnboarding/guideIcon.imageset/guideIcon.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/DropDown.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/DropDown.imageset/Contents.json new file mode 100644 index 00000000..cd7ddaee --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/DropDown.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "dropDown.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/DropDown.imageset/dropDown.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/DropDown.imageset/dropDown.svg new file mode 100644 index 00000000..67225443 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/DropDown.imageset/dropDown.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/Fab.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/Fab.imageset/Contents.json new file mode 100644 index 00000000..4b7e95d7 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/Fab.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Fab.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/Fab.imageset/Fab.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/Fab.imageset/Fab.svg new file mode 100644 index 00000000..b3840368 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/Fab.imageset/Fab.svg @@ -0,0 +1,4 @@ + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/addIcon.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/addIcon.imageset/Contents.json new file mode 100644 index 00000000..b4988ec2 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/addIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "add.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/addIcon.imageset/add.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/addIcon.imageset/add.svg new file mode 100644 index 00000000..0530ce26 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/addIcon.imageset/add.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/appleLogo.imageset/AppleLogo.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/appleLogo.imageset/AppleLogo.svg new file mode 100644 index 00000000..39e20f52 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/appleLogo.imageset/AppleLogo.svg @@ -0,0 +1,3 @@ + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/appleLogo.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/appleLogo.imageset/Contents.json new file mode 100644 index 00000000..52803e85 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/appleLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "AppleLogo.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowBack.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowBack.imageset/Contents.json new file mode 100644 index 00000000..fa1b15e0 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowBack.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "arrowBack.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowBack.imageset/arrowBack.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowBack.imageset/arrowBack.svg new file mode 100644 index 00000000..690091c6 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowBack.imageset/arrowBack.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowDropUp.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowDropUp.imageset/Contents.json new file mode 100644 index 00000000..9df632b0 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowDropUp.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "arrowDropUp.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowDropUp.imageset/arrowDropUp.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowDropUp.imageset/arrowDropUp.svg new file mode 100644 index 00000000..20352b31 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowDropUp.imageset/arrowDropUp.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowDropdown.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowDropdown.imageset/Contents.json new file mode 100644 index 00000000..62f4cae1 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowDropdown.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "arrowDropdown.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowDropdown.imageset/arrowDropdown.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowDropdown.imageset/arrowDropdown.svg new file mode 100644 index 00000000..bc11335c --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowDropdown.imageset/arrowDropdown.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowForward.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowForward.imageset/Contents.json new file mode 100644 index 00000000..21e06750 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowForward.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "arrwoForward.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowForward.imageset/arrwoForward.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowForward.imageset/arrwoForward.svg new file mode 100644 index 00000000..93f8e30d --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowForward.imageset/arrwoForward.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowForwardSmall.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowForwardSmall.imageset/Contents.json new file mode 100644 index 00000000..2d79acc7 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowForwardSmall.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "arrowForwordSmall.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowForwardSmall.imageset/arrowForwordSmall.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowForwardSmall.imageset/arrowForwordSmall.svg new file mode 100644 index 00000000..f0d29991 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/arrowForwardSmall.imageset/arrowForwordSmall.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bell.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bell.imageset/Contents.json new file mode 100644 index 00000000..b0db2947 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bell.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bell.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bell.imageset/bell.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bell.imageset/bell.svg new file mode 100644 index 00000000..389cbbae --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bell.imageset/bell.svg @@ -0,0 +1,3 @@ + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmark.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmark.imageset/Contents.json new file mode 100644 index 00000000..b9a0914c --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmark.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bookmark.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmark.imageset/bookmark.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmark.imageset/bookmark.svg new file mode 100644 index 00000000..4df98315 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmark.imageset/bookmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkBorder.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkBorder.imageset/Contents.json new file mode 100644 index 00000000..718b074b --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkBorder.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bookmark_border.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkBorder.imageset/bookmark_border.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkBorder.imageset/bookmark_border.svg new file mode 100644 index 00000000..bf456cff --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkBorder.imageset/bookmark_border.svg @@ -0,0 +1,3 @@ + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkBorderList.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkBorderList.imageset/Contents.json new file mode 100644 index 00000000..684a4e90 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkBorderList.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bookmarkBorderList.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkBorderList.imageset/bookmarkBorderList.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkBorderList.imageset/bookmarkBorderList.png new file mode 100644 index 00000000..9fe9661a Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkBorderList.imageset/bookmarkBorderList.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkGrayBorder.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkGrayBorder.imageset/Contents.json new file mode 100644 index 00000000..dda7fb83 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkGrayBorder.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bookmarkBorder.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkGrayBorder.imageset/bookmarkBorder.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkGrayBorder.imageset/bookmarkBorder.svg new file mode 100644 index 00000000..c3875680 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkGrayBorder.imageset/bookmarkBorder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkGrayBorderList.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkGrayBorderList.imageset/Contents.json new file mode 100644 index 00000000..e279269b --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkGrayBorderList.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bookmarkGrayBorderList.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkGrayBorderList.imageset/bookmarkGrayBorderList.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkGrayBorderList.imageset/bookmarkGrayBorderList.svg new file mode 100644 index 00000000..d5ace6ae --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkGrayBorderList.imageset/bookmarkGrayBorderList.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkList.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkList.imageset/Contents.json new file mode 100644 index 00000000..78cce243 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkList.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bookmarkList.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkList.imageset/bookmarkList.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkList.imageset/bookmarkList.svg new file mode 100644 index 00000000..dc45cef0 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/bookmarkList.imageset/bookmarkList.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkCircle.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkCircle.imageset/Contents.json new file mode 100644 index 00000000..ce4dc702 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkCircle.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "checkCircle.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkCircle.imageset/checkCircle.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkCircle.imageset/checkCircle.svg new file mode 100644 index 00000000..fd0968fd --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkCircle.imageset/checkCircle.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkCircleFill.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkCircleFill.imageset/Contents.json new file mode 100644 index 00000000..e2e75972 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkCircleFill.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "checkCircleFill.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkCircleFill.imageset/checkCircleFill.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkCircleFill.imageset/checkCircleFill.svg new file mode 100644 index 00000000..cc39a399 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkCircleFill.imageset/checkCircleFill.svg @@ -0,0 +1,3 @@ + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkMark.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkMark.imageset/Contents.json new file mode 100644 index 00000000..7f105edc --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkMark.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "checkMark.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkMark.imageset/checkMark.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkMark.imageset/checkMark.svg new file mode 100644 index 00000000..d6f5372f --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkMark.imageset/checkMark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkMarkFill.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkMarkFill.imageset/Contents.json new file mode 100644 index 00000000..9976bef5 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkMarkFill.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "checkMarkFill.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkMarkFill.imageset/checkMarkFill.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkMarkFill.imageset/checkMarkFill.svg new file mode 100644 index 00000000..b8409aae --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkMarkFill.imageset/checkMarkFill.svg @@ -0,0 +1,3 @@ + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkSquare.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkSquare.imageset/Contents.json new file mode 100644 index 00000000..c9e9b7f5 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkSquare.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "checkSquare.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkSquare.imageset/checkSquare.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkSquare.imageset/checkSquare.svg new file mode 100644 index 00000000..4707e286 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkSquare.imageset/checkSquare.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkSquareFill.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkSquareFill.imageset/Contents.json new file mode 100644 index 00000000..f5c7ed27 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkSquareFill.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "checkSquareFill.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkSquareFill.imageset/checkSquareFill.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkSquareFill.imageset/checkSquareFill.svg new file mode 100644 index 00000000..7e06b1ad --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/checkSquareFill.imageset/checkSquareFill.svg @@ -0,0 +1,3 @@ + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/circle.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/circle.imageset/Contents.json new file mode 100644 index 00000000..acd6921d --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/circle.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "icon_circle.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/circle.imageset/icon_circle.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/circle.imageset/icon_circle.svg new file mode 100644 index 00000000..54655880 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/circle.imageset/icon_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/dictionary.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/dictionary.imageset/Contents.json new file mode 100644 index 00000000..2475455d --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/dictionary.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "dictionary.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/dictionary.imageset/dictionary.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/dictionary.imageset/dictionary.svg new file mode 100644 index 00000000..f09313b4 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/dictionary.imageset/dictionary.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/edit.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/edit.imageset/Contents.json new file mode 100644 index 00000000..13f9713f --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/edit.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "edit.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/edit.imageset/edit.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/edit.imageset/edit.svg new file mode 100644 index 00000000..35c2ed7d --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/edit.imageset/edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/error.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/error.imageset/Contents.json new file mode 100644 index 00000000..8d95c25b --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/error.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "error.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/error.imageset/error.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/error.imageset/error.svg new file mode 100644 index 00000000..049788c6 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/error.imageset/error.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/errorBlack.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/errorBlack.imageset/Contents.json new file mode 100644 index 00000000..8d95c25b --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/errorBlack.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "error.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/errorBlack.imageset/error.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/errorBlack.imageset/error.svg new file mode 100644 index 00000000..e74491e2 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/errorBlack.imageset/error.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/errorImage.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/errorImage.imageset/Contents.json new file mode 100644 index 00000000..5179fc52 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/errorImage.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "errorImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/errorImage.imageset/errorImage.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/errorImage.imageset/errorImage.png new file mode 100644 index 00000000..e5204398 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/errorImage.imageset/errorImage.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/favorite.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/favorite.imageset/Contents.json new file mode 100644 index 00000000..33e62908 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/favorite.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "favorite_true.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/favorite.imageset/favorite_true.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/favorite.imageset/favorite_true.svg new file mode 100644 index 00000000..d47569a5 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/favorite.imageset/favorite_true.svg @@ -0,0 +1,3 @@ + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/filter.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/filter.imageset/Contents.json new file mode 100644 index 00000000..01d6c7dd --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/filter.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "filter.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/filter.imageset/filter.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/filter.imageset/filter.svg new file mode 100644 index 00000000..131b98da --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/filter.imageset/filter.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/blueSnail.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/blueSnail.imageset/Contents.json new file mode 100644 index 00000000..f0818241 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/blueSnail.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "blueSnail.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/blueSnail.imageset/blueSnail.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/blueSnail.imageset/blueSnail.png new file mode 100644 index 00000000..50788978 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/blueSnail.imageset/blueSnail.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/blueSnailSelected.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/blueSnailSelected.imageset/Contents.json new file mode 100644 index 00000000..b936cfb1 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/blueSnailSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "blueSnailSelected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/blueSnailSelected.imageset/blueSnailSelected.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/blueSnailSelected.imageset/blueSnailSelected.png new file mode 100644 index 00000000..ccac9342 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/blueSnailSelected.imageset/blueSnailSelected.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/juniorYeti.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/juniorYeti.imageset/Contents.json new file mode 100644 index 00000000..d25c043c --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/juniorYeti.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "juniorYeti.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/juniorYeti.imageset/juniorYeti.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/juniorYeti.imageset/juniorYeti.png new file mode 100644 index 00000000..7d692953 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/juniorYeti.imageset/juniorYeti.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/juniorYetiSelected.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/juniorYetiSelected.imageset/Contents.json new file mode 100644 index 00000000..52ceecf0 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/juniorYetiSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "juniorYetiSelected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/juniorYetiSelected.imageset/juniorYetiSelected.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/juniorYetiSelected.imageset/juniorYetiSelected.png new file mode 100644 index 00000000..c81772ef Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/juniorYetiSelected.imageset/juniorYetiSelected.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroom.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroom.imageset/Contents.json new file mode 100644 index 00000000..3a330589 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroom.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "mushroom.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroom.imageset/mushroom.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroom.imageset/mushroom.png new file mode 100644 index 00000000..0773ae4f Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroom.imageset/mushroom.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroomSelected.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroomSelected.imageset/Contents.json new file mode 100644 index 00000000..be7cc9af --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroomSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "mushroomSelected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroomSelected.imageset/mushroomSelected.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroomSelected.imageset/mushroomSelected.png new file mode 100644 index 00000000..a502b616 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroomSelected.imageset/mushroomSelected.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroomTest.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroomTest.imageset/Contents.json new file mode 100644 index 00000000..a6efc45b --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroomTest.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "mushroomTest.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroomTest.imageset/mushroomTest.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroomTest.imageset/mushroomTest.png new file mode 100644 index 00000000..9a90b6c9 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/mushroomTest.imageset/mushroomTest.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/pepe.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/pepe.imageset/Contents.json new file mode 100644 index 00000000..b50a2622 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/pepe.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "pepe.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/pepe.imageset/pepe.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/pepe.imageset/pepe.png new file mode 100644 index 00000000..69653047 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/pepe.imageset/pepe.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/pepeSelected.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/pepeSelected.imageset/Contents.json new file mode 100644 index 00000000..d2fd897d --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/pepeSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "pepeSelected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/pepeSelected.imageset/pepeSelected.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/pepeSelected.imageset/pepeSelected.png new file mode 100644 index 00000000..3657aa09 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/pepeSelected.imageset/pepeSelected.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/rash.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/rash.imageset/Contents.json new file mode 100644 index 00000000..9c8da93a --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/rash.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "rash.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/rash.imageset/rash.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/rash.imageset/rash.png new file mode 100644 index 00000000..5f095627 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/rash.imageset/rash.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/rashSelected.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/rashSelected.imageset/Contents.json new file mode 100644 index 00000000..11fa673c --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/rashSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "rashSelected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/rashSelected.imageset/rashSelected.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/rashSelected.imageset/rashSelected.png new file mode 100644 index 00000000..f63ae757 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/rashSelected.imageset/rashSelected.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/slime.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/slime.imageset/Contents.json new file mode 100644 index 00000000..4ced3bf3 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/slime.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "slime.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/slime.imageset/slime.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/slime.imageset/slime.png new file mode 100644 index 00000000..4278e35a Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/slime.imageset/slime.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/slimeSelected.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/slimeSelected.imageset/Contents.json new file mode 100644 index 00000000..5722705d --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/slimeSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "slimeSelected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/slimeSelected.imageset/slimeSelected.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/slimeSelected.imageset/slimeSelected.png new file mode 100644 index 00000000..fdb91b6f Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/slimeSelected.imageset/slimeSelected.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/starPixie.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/starPixie.imageset/Contents.json new file mode 100644 index 00000000..70545b1b --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/starPixie.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "starpixie.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/starPixie.imageset/starpixie.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/starPixie.imageset/starpixie.png new file mode 100644 index 00000000..6cd9d7de Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/starPixie.imageset/starpixie.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/starPixieSelected.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/starPixieSelected.imageset/Contents.json new file mode 100644 index 00000000..e68397ab --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/starPixieSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "starPixieSelected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/starPixieSelected.imageset/starPixieSelected.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/starPixieSelected.imageset/starPixieSelected.png new file mode 100644 index 00000000..09fd755c Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/starPixieSelected.imageset/starPixieSelected.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/wraith.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/wraith.imageset/Contents.json new file mode 100644 index 00000000..18c2f1df --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/wraith.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "wraith.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/wraith.imageset/wraith.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/wraith.imageset/wraith.png new file mode 100644 index 00000000..b6c4015e Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/wraith.imageset/wraith.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/wraithSelected.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/wraithSelected.imageset/Contents.json new file mode 100644 index 00000000..170ee7e2 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/wraithSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "wraithSelected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/wraithSelected.imageset/wraithSelected.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/wraithSelected.imageset/wraithSelected.png new file mode 100644 index 00000000..335885e3 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/wraithSelected.imageset/wraithSelected.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/yeti.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/yeti.imageset/Contents.json new file mode 100644 index 00000000..01fd28ee --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/yeti.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "yeti.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/yeti.imageset/yeti.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/yeti.imageset/yeti.png new file mode 100644 index 00000000..190ceb41 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/yeti.imageset/yeti.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/yetiSelected.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/yetiSelected.imageset/Contents.json new file mode 100644 index 00000000..439a9018 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/yetiSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "yetiSelected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/yetiSelected.imageset/yetiSelected.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/yetiSelected.imageset/yetiSelected.png new file mode 100644 index 00000000..4db0974f Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/illustration/yetiSelected.imageset/yetiSelected.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/Login_KV_img.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/Login_KV_img.imageset/Contents.json new file mode 100644 index 00000000..0ec129e8 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/Login_KV_img.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Login_KV_img.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/Login_KV_img.imageset/Login_KV_img.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/Login_KV_img.imageset/Login_KV_img.png new file mode 100644 index 00000000..1e6d15d3 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/Login_KV_img.imageset/Login_KV_img.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/addToCollection.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/addToCollection.imageset/Contents.json new file mode 100644 index 00000000..ed70f822 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/addToCollection.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "addToCollection.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/addToCollection.imageset/addToCollection.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/addToCollection.imageset/addToCollection.png new file mode 100644 index 00000000..4c38fbf8 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/addToCollection.imageset/addToCollection.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/appleImage.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/appleImage.imageset/Contents.json new file mode 100644 index 00000000..b3712ae4 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/appleImage.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "appleImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/appleImage.imageset/appleImage.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/appleImage.imageset/appleImage.png new file mode 100644 index 00000000..e6038fb5 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/appleImage.imageset/appleImage.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/connectionError.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/connectionError.imageset/Contents.json new file mode 100644 index 00000000..fac1a53c --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/connectionError.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "connectionError.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/connectionError.imageset/connectionError.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/connectionError.imageset/connectionError.png new file mode 100644 index 00000000..75604d9b Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/connectionError.imageset/connectionError.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/fabHint.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/fabHint.imageset/Contents.json new file mode 100644 index 00000000..e1484a1b --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/fabHint.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "fabHint.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/fabHint.imageset/fabHint.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/fabHint.imageset/fabHint.png new file mode 100644 index 00000000..82a2ea35 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/fabHint.imageset/fabHint.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/getNotify.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/getNotify.imageset/Contents.json new file mode 100644 index 00000000..d14b19a0 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/getNotify.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "getNotify.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/getNotify.imageset/getNotify.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/getNotify.imageset/getNotify.png new file mode 100644 index 00000000..9fee5ecf Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/getNotify.imageset/getNotify.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/kakaoImage.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/kakaoImage.imageset/Contents.json new file mode 100644 index 00000000..8298ca5d --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/kakaoImage.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "kakaoImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/kakaoImage.imageset/kakaoImage.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/kakaoImage.imageset/kakaoImage.png new file mode 100644 index 00000000..ef883fa6 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/kakaoImage.imageset/kakaoImage.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/noResult.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/noResult.imageset/Contents.json new file mode 100644 index 00000000..e0b54f5a --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/noResult.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "noResult.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/noResult.imageset/noResult.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/noResult.imageset/noResult.png new file mode 100644 index 00000000..574e37d7 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/noResult.imageset/noResult.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/noShowList.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/noShowList.imageset/Contents.json new file mode 100644 index 00000000..402e6280 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/noShowList.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "noShowList.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/noShowList.imageset/noShowList.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/noShowList.imageset/noShowList.png new file mode 100644 index 00000000..e90300c0 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/noShowList.imageset/noShowList.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/onBoardingBookmark.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/onBoardingBookmark.imageset/Contents.json new file mode 100644 index 00000000..ed18840a --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/onBoardingBookmark.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bookmarkList.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/onBoardingBookmark.imageset/bookmarkList.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/onBoardingBookmark.imageset/bookmarkList.png new file mode 100644 index 00000000..988fc8e7 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/onBoardingBookmark.imageset/bookmarkList.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/questionNotify.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/questionNotify.imageset/Contents.json new file mode 100644 index 00000000..a71a5df4 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/questionNotify.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "questionNotify.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/questionNotify.imageset/questionNotify.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/questionNotify.imageset/questionNotify.png new file mode 100644 index 00000000..d7c51a5d Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/questionNotify.imageset/questionNotify.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/recentLoginLogo.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/recentLoginLogo.imageset/Contents.json new file mode 100644 index 00000000..c1fb5668 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/recentLoginLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "recentLoginLogo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/recentLoginLogo.imageset/recentLoginLogo.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/recentLoginLogo.imageset/recentLoginLogo.png new file mode 100644 index 00000000..4afde34f Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/recentLoginLogo.imageset/recentLoginLogo.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/settingsHint.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/settingsHint.imageset/Contents.json new file mode 100644 index 00000000..4c241a10 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/settingsHint.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "settingsHint.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/settingsHint.imageset/settingsHint.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/settingsHint.imageset/settingsHint.png new file mode 100644 index 00000000..76be1394 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/image/settingsHint.imageset/settingsHint.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/kakaoLogo.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/kakaoLogo.imageset/Contents.json new file mode 100644 index 00000000..332f97e7 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/kakaoLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "kakaoLogo.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/kakaoLogo.imageset/kakaoLogo.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/kakaoLogo.imageset/kakaoLogo.svg new file mode 100644 index 00000000..43ac1413 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/kakaoLogo.imageset/kakaoLogo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/largeX.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/largeX.imageset/Contents.json new file mode 100644 index 00000000..f93a7f69 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/largeX.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "x-black_big.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/largeX.imageset/x-black_big.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/largeX.imageset/x-black_big.svg new file mode 100644 index 00000000..d793ff8a --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/largeX.imageset/x-black_big.svg @@ -0,0 +1,3 @@ + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/lineArrowDown.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/lineArrowDown.imageset/Contents.json new file mode 100644 index 00000000..1012cc97 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/lineArrowDown.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "lineArrowDown.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/lineArrowDown.imageset/lineArrowDown.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/lineArrowDown.imageset/lineArrowDown.svg new file mode 100644 index 00000000..67225443 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/lineArrowDown.imageset/lineArrowDown.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/logo.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/logo.imageset/Contents.json new file mode 100644 index 00000000..5f670ca8 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/logo.imageset/logo.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/logo.imageset/logo.png new file mode 100644 index 00000000..0c524885 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/logo.imageset/logo.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/mypage.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/mypage.imageset/Contents.json new file mode 100644 index 00000000..171be441 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/mypage.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "mypage_true.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/mypage.imageset/mypage_true.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/mypage.imageset/mypage_true.svg new file mode 100644 index 00000000..89cd6bd5 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/mypage.imageset/mypage_true.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/plusIcon.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/plusIcon.imageset/Contents.json new file mode 100644 index 00000000..ccdb0f7e --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/plusIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "plusicon.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/plusIcon.imageset/plusicon.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/plusIcon.imageset/plusicon.svg new file mode 100644 index 00000000..c1ecbac2 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/plusIcon.imageset/plusicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/rightArrow.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/rightArrow.imageset/Contents.json new file mode 100644 index 00000000..bfef48d9 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/rightArrow.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "rightArrow.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/rightArrow.imageset/rightArrow.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/rightArrow.imageset/rightArrow.svg new file mode 100644 index 00000000..d722791e --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/rightArrow.imageset/rightArrow.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/search.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/search.imageset/Contents.json new file mode 100644 index 00000000..e4d0b2c4 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/search.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "seach.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/search.imageset/seach.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/search.imageset/seach.svg new file mode 100644 index 00000000..e1f774eb --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/search.imageset/seach.svg @@ -0,0 +1,3 @@ + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/smallX.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/smallX.imageset/Contents.json new file mode 100644 index 00000000..e29acd20 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/smallX.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "x-black.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/smallX.imageset/x-black.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/smallX.imageset/x-black.svg new file mode 100644 index 00000000..e530a67c --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/smallX.imageset/x-black.svg @@ -0,0 +1,3 @@ + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/testImage.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/testImage.imageset/Contents.json new file mode 100644 index 00000000..6a9ec0f9 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/testImage.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "testImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/testImage.imageset/testImage.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/testImage.imageset/testImage.png new file mode 100644 index 00000000..95973c2a Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/testImage.imageset/testImage.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/testImage2.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/testImage2.imageset/Contents.json new file mode 100644 index 00000000..d114ab7e --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/testImage2.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "image.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/testImage2.imageset/image.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/testImage2.imageset/image.png new file mode 100644 index 00000000..0fc3ba70 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/testImage2.imageset/image.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/textFieldClear.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/textFieldClear.imageset/Contents.json new file mode 100644 index 00000000..7080485b --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/textFieldClear.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "textFieldClear.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/textFieldClear.imageset/textFieldClear.png b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/textFieldClear.imageset/textFieldClear.png new file mode 100644 index 00000000..5be83d97 Binary files /dev/null and b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/textFieldClear.imageset/textFieldClear.png differ diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/warning.imageset/Contents.json b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/warning.imageset/Contents.json new file mode 100644 index 00000000..d07182d3 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/warning.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "warning.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/warning.imageset/warning.svg b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/warning.imageset/warning.svg new file mode 100644 index 00000000..e6980bf6 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Resources/Image.xcassets/warning.imageset/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIButton+.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIButton+.swift new file mode 100644 index 00000000..5c6822be --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIButton+.swift @@ -0,0 +1,21 @@ +import UIKit + +extension UIButton { + func setUnderlinedTitle(title: String, font: UIFont?, state: UIControl.State = .normal, textInsets: UIEdgeInsets = .zero) { + guard let font = font else { return } + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.minimumLineHeight = 0 + paragraphStyle.lineBreakMode = .byTruncatingTail + paragraphStyle.maximumLineHeight = font.lineHeight * 1.17 + + let attributes: [NSAttributedString.Key: Any] = [ + .underlineStyle: NSUnderlineStyle.single.rawValue, + .font: font, + .paragraphStyle: paragraphStyle + ] + + let attributedString = NSAttributedString(string: title, attributes: attributes) + setAttributedTitle(attributedString, for: state) + titleEdgeInsets = textInsets + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIColor+.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIColor+.swift new file mode 100644 index 00000000..b3c62f6e --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIColor+.swift @@ -0,0 +1,58 @@ +import UIKit + +public extension UIColor { + static let primary900 = UIColor(hexCode: "EE500C") + static let primary700 = UIColor(hexCode: "FF5C00") + static let primary500 = UIColor(hexCode: "FF7D33") + static let primary300 = UIColor(hexCode: "FF9D66") + static let primary100 = UIColor(hexCode: "FFBE9A") + static let primary50 = UIColor(hexCode: "FFE6D8") + static let primary25 = UIColor(hexCode: "FFF1E9") + + static let secondary = UIColor(hexCode: "FFAA00") + static let textColor = UIColor(hexCode: "1D1D1F") + + static let neutral900 = UIColor(hexCode: "313131") + static let neutral700 = UIColor(hexCode: "575757") + static let neutral600 = UIColor(hexCode: "848484") + static let neutral500 = UIColor(hexCode: "AFAFAF") + static let neutral300 = UIColor(hexCode: "CFCFCF") + static let neutral200 = UIColor(hexCode: "E9E9E9") + static let neutral100 = UIColor(hexCode: "F5F5F5") + + static let whiteMLS = UIColor(hexCode: "FFFFFF") + static let clearMLS = UIColor(hexCode: "FFFFFF", alpha: 0) + + static let error900 = UIColor(hexCode: "FF4B4B") + static let error100 = UIColor(hexCode: "FFEFEF") + + static let success = UIColor(hexCode: "15CC00") + static let redMLS = UIColor(hexCode: "FF0000") + static let overlays = UIColor(hexCode: "000000", alpha: 0.2) + + static let listMonster = UIColor(hexCode: "FFEFCE") + static let listItem = UIColor(hexCode: "F1EEFC") + static let listMap = UIColor(hexCode: "EEF9E0") + static let listNPC = UIColor(hexCode: "E0EFF9") + static let listQuest = UIColor(hexCode: "FFECEF") + + convenience init(hexCode: String, alpha: CGFloat = 1.0) { + var hexFormatted: String = hexCode.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).uppercased() + + if hexFormatted.hasPrefix("#") { + hexFormatted = String(hexFormatted.dropFirst()) + } + + assert(hexFormatted.count == 6, "Invalid hex code used.") + + var rgbValue: UInt64 = 0 + Scanner(string: hexFormatted).scanHexInt64(&rgbValue) + + self.init( + red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0, + green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0, + blue: CGFloat(rgbValue & 0x0000FF) / 255.0, + alpha: alpha + ) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIFont+.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIFont+.swift new file mode 100644 index 00000000..3873351e --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIFont+.swift @@ -0,0 +1,48 @@ +// swiftlint:disable all + +import UIKit + +public extension UIFont { + static let h_xxxl_b = korFont(style: .bold, size: 24) + static let h_xxxl_sb = korFont(style: .semiBold, size: 24) + + static let h_xxl_b = korFont(style: .bold, size: 22) + + static let h_xl_b = korFont(style: .bold, size: 20) + static let h_xl_sb = korFont(style: .semiBold, size: 20) + static let h_xl_r = korFont(style: .regular, size: 20) + + static let b_l_r = korFont(style: .regular, size: 18) + static let b_m_r = korFont(style: .regular, size: 16) + static let b_s_sb = korFont(style: .semiBold, size: 14) + static let b_s_m = korFont(style: .medium, size: 14) + static let b_s_r = korFont(style: .regular, size: 14) + + static let sub_l_b = korFont(style: .bold, size: 18) + static let sub_l_m = korFont(style: .medium, size: 18) + static let sub_m_b = korFont(style: .bold, size: 16) + static let sub_m_sb = korFont(style: .semiBold, size: 16) + static let sub_m_m = korFont(style: .medium, size: 16) + + static let cp_s_sb = korFont(style: .semiBold, size: 14) + static let cp_s_m = korFont(style: .medium, size: 14) + static let cp_s_r = korFont(style: .regular, size: 14) + static let cp_xs_sb = korFont(style: .semiBold, size: 12) + static let cp_xs_r = korFont(style: .regular, size: 12) + + static let btn_m_b = korFont(style: .bold, size: 16) + static let btn_m_r = korFont(style: .regular, size: 16) + static let btn_s_r = korFont(style: .regular, size: 14) + static let btn_xs_r = korFont(style: .regular, size: 12) + + static func korFont(style: FontStyle, size: CGFloat) -> UIFont? { + return UIFont(name: "Pretendard\(style.rawValue)", size: size) + } + + enum FontStyle: String { + case bold = "-Bold" + case semiBold = "-SemiBold" + case medium = "-Medium" + case regular = "-Regular" + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIImage+.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIImage+.swift new file mode 100644 index 00000000..90112d88 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIImage+.swift @@ -0,0 +1,29 @@ +import UIKit + +extension UIImage { + static func fromColor(_ color: UIColor?) -> UIImage { + guard let color else { return UIImage() } + let rect = CGRect(x: 0, y: 0, width: 1, height: 1) + UIGraphicsBeginImageContext(rect.size) + color.setFill() + UIRectFill(rect) + let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage() + UIGraphicsEndImageContext() + return image + } + + func resizeImage(to targetSize: CGSize, preserveAspectRatio: Bool = true) -> UIImage? { + let size: CGSize + if preserveAspectRatio { + let aspectRatio = min(targetSize.width / self.size.width, targetSize.height / self.size.height) + size = CGSize(width: self.size.width * aspectRatio, height: self.size.height * aspectRatio) + } else { + size = targetSize + } + + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + self.draw(in: CGRect(origin: .zero, size: size)) + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIViewController+.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIViewController+.swift new file mode 100644 index 00000000..2e41286b --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UIViewController+.swift @@ -0,0 +1,133 @@ +import UIKit + +import SnapKit + +@MainActor private var modalWrapperKey: UInt8 = 0 +@MainActor private var modalHideTabBarKey: UInt8 = 0 + +public extension UIViewController { + + private var modalWrapperView: ModalWrapperView? { + get { objc_getAssociatedObject(self, &modalWrapperKey) as? ModalWrapperView } + set { objc_setAssociatedObject(self, &modalWrapperKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + private var modalHideTabBar: Bool { + get { (objc_getAssociatedObject(self, &modalHideTabBarKey) as? Bool) ?? false } + set { objc_setAssociatedObject(self, &modalHideTabBarKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + /// 커스텀 모달 프레젠트 + /// - Parameters: + /// - viewController: 표시할 모달 뷰컨 + /// - hideTabBar: 탭바를 숨길지 여부 (기본값: true) + func presentModal( + _ viewController: UIViewController & ModalPresentable, + hideTabBar: Bool = false + ) { + let wrapper = ModalWrapperView(contentViewController: viewController, parent: self) + + // 이전 상태 초기화 + modalHideTabBar = false + modalWrapperView = wrapper + + // 새 설정 적용 + modalHideTabBar = hideTabBar + + view.addSubview(wrapper) + wrapper.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + // 필요 시 탭바 숨김 + if hideTabBar, let tabBarController = findTabBarController() { + tabBarController.setHidden(hidden: true, animated: false) + } + + // present 애니메이션 + UIView.animate( + withDuration: 0.5, + delay: 0, + usingSpringWithDamping: 0.85, + initialSpringVelocity: 0.8, + options: [.curveEaseOut] + ) { + wrapper.dimView.alpha = 1 + wrapper.containerView.transform = .identity + DispatchQueue.main.async { + viewController.beginAppearanceTransition(true, animated: true) + viewController.endAppearanceTransition() + } + } + } + + /// 현재 모달 닫기 + @objc internal func dismissCurrentModal() { + guard let wrapper = modalWrapperView else { return } + + let shouldKeepHidden = modalHideTabBar + let tabBarController = findTabBarController() + + if shouldKeepHidden, let tabBarController { + tabBarController.setHidden(hidden: true, animated: false) + } + + UIView.animate(withDuration: 0.35, delay: 0, options: [.curveEaseInOut]) { + wrapper.dimView.alpha = 0 + wrapper.containerView.transform = CGAffineTransform(translationX: 0, y: 300) + } completion: { _ in + wrapper.removeFromSuperview() + self.modalWrapperView = nil + + // false인 경우 복원 + if !shouldKeepHidden, let tabBarController { + tabBarController.setHidden(hidden: false, animated: false) + } + + self.modalHideTabBar = false + } + } + + private func findTabBarController() -> BottomTabBarController? { + var parentVC: UIViewController? = self + while let current = parentVC { + if let tabBarController = current as? BottomTabBarController { + return tabBarController + } + parentVC = current.parent + } + return nil + } +} + +// 모달 내부에서 닫기 기능 제공 +@MainActor +extension ModalPresentable where Self: UIViewController { + public func dismissCurrentModal() { + parent?.dismissCurrentModal() + } +} + +@MainActor +private var fabKey: UInt8 = 0 + +public extension UIViewController { + func addFloatingButton(_ action: @escaping () -> Void) { + let fab = FloatingActionButton(action: action) + objc_setAssociatedObject(self, &fabKey, fab, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + view.addSubview(fab) + fab.snp.makeConstraints { make in + make.trailing.equalTo(view.safeAreaLayoutGuide).inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) + make.size.equalTo(CGSize(width: 48, height: 48)) + } + } + + func removeFloatingButton() { + if let fab = objc_getAssociatedObject(self, &fabKey) as? UIView { + fab.removeFromSuperview() + objc_setAssociatedObject(self, &fabKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignSystemAsset.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignSystemAsset.swift new file mode 100644 index 00000000..9ac04ad1 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignSystemAsset.swift @@ -0,0 +1,47 @@ +import UIKit + +public enum DesignSystemAsset { + static let bundle: Bundle = .module + + public static func image(named name: String) -> UIImage { + guard let image = UIImage(named: name, in: .module, compatibleWith: nil) else { + fatalError("❌ Image not found: \(name)") + } + return image + } +} + +public enum MapleIllustration { + case mushroom + case slime + case blueSnail + case juniorYeti + case yeti + case pepe + case wraith + case starPixie + case rash + + public var url: String { + switch self { + case .mushroom: + "https://maple-db-team-s3.s3.ap-northeast-2.amazonaws.com/profile-images/profile_1.jpg" + case .slime: + "https://maple-db-team-s3.s3.ap-northeast-2.amazonaws.com/profile-images/profile_2.jpg" + case .blueSnail: + "https://maple-db-team-s3.s3.ap-northeast-2.amazonaws.com/profile-images/profile_3.jpg" + case .juniorYeti: + "https://maple-db-team-s3.s3.ap-northeast-2.amazonaws.com/profile-images/profile_4.jpg" + case .yeti: + "https://maple-db-team-s3.s3.ap-northeast-2.amazonaws.com/profile-images/profile_5.jpg" + case .pepe: + "https://maple-db-team-s3.s3.ap-northeast-2.amazonaws.com/profile-images/profile_6.jpg" + case .wraith: + "https://maple-db-team-s3.s3.ap-northeast-2.amazonaws.com/profile-images/profile_7.jpg" + case .starPixie: + "https://maple-db-team-s3.s3.ap-northeast-2.amazonaws.com/profile-images/profile_8.jpg" + case .rash: + "https://maple-db-team-s3.s3.ap-northeast-2.amazonaws.com/profile-images/profile_9.jpg" + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/FontManager.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/FontManager.swift new file mode 100644 index 00000000..ed79dfd9 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/FontManager.swift @@ -0,0 +1,30 @@ +import os +import UIKit + +public class FontManager { + /// 폰트를 등록하는 메서드 + public static func registerFonts() { + let fontNames = [ + "Pretendard-Bold", + "Pretendard-SemiBold", + "Pretendard-Medium", + "Pretendard-Regular" + ] + + fontNames.forEach { fontName in + guard let fontURL = Bundle.module.url(forResource: fontName, withExtension: "ttf") else { + os_log(.error, "Font file not found: \(fontName)") + return + } + + var error: Unmanaged? + CTFontManagerRegisterFontsForURL(fontURL as CFURL, .process, &error) + + if let error = error { + os_log(.error, "Error registering font: \(error.takeUnretainedValue())") + } else { + os_log(.error, "\(fontName) registered successfully") + } + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/NSAttributedString.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/NSAttributedString.swift new file mode 100644 index 00000000..663be1f3 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/NSAttributedString.swift @@ -0,0 +1,61 @@ +import UIKit + +// core의 extension에 위치하는게 올바를지? +public extension NSAttributedString { + static func makeStyledString( + font: UIFont?, + text: String?, + color: UIColor? = .textColor, + alignment: NSTextAlignment = .center, + lineHeight: CGFloat = 1.17 + ) -> NSAttributedString? { + guard let text, let color, let font else { return nil } + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.minimumLineHeight = 0 + paragraphStyle.lineBreakMode = .byTruncatingTail + paragraphStyle.lineHeightMultiple = lineHeight + paragraphStyle.alignment = alignment + + let actualLineHeight = font.lineHeight * lineHeight + let baselineOffset = (actualLineHeight - font.lineHeight) / 2 + + let attributedString = NSAttributedString( + string: text, + attributes: [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraphStyle, + .baselineOffset: baselineOffset + ] + ) + return attributedString + } + + static func makeStyledUnderlinedString( + font: UIFont?, + text: String?, + color: UIColor? = .textColor, + alignment: NSTextAlignment = .center, + lineHeight: CGFloat = 1.17, + underlineStyle: NSUnderlineStyle = .single + ) -> NSAttributedString? { + guard let text, let color, let font else { return nil } + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.minimumLineHeight = 0 + paragraphStyle.lineBreakMode = .byTruncatingTail + paragraphStyle.maximumLineHeight = font.lineHeight * lineHeight + paragraphStyle.alignment = alignment + + let attributedString = NSAttributedString( + string: text, + attributes: [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraphStyle, + .underlineStyle: underlineStyle.rawValue + ] + ) + + return attributedString + } +} diff --git a/MLS/MLSDesignSystemExample/Application/AppDelegate.swift b/MLS/MLSDesignSystemExample/Application/AppDelegate.swift new file mode 100644 index 00000000..64289c01 --- /dev/null +++ b/MLS/MLSDesignSystemExample/Application/AppDelegate.swift @@ -0,0 +1,17 @@ +import UIKit + +import MLSDesignSystem + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + FontManager.registerFonts() + return true + } + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {} +} diff --git a/MLS/MLSDesignSystemExample/Application/SceneDelegate.swift b/MLS/MLSDesignSystemExample/Application/SceneDelegate.swift new file mode 100644 index 00000000..6ef7f387 --- /dev/null +++ b/MLS/MLSDesignSystemExample/Application/SceneDelegate.swift @@ -0,0 +1,28 @@ +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + window = UIWindow(windowScene: windowScene) + window?.rootViewController = UINavigationController(rootViewController: ViewController()) + window?.makeKeyAndVisible() + } + + func sceneDidDisconnect(_ scene: UIScene) { + } + + func sceneDidBecomeActive(_ scene: UIScene) { + } + + func sceneWillResignActive(_ scene: UIScene) { + } + + func sceneWillEnterForeground(_ scene: UIScene) { + } + + func sceneDidEnterBackground(_ scene: UIScene) { + } +} diff --git a/MLS/MLSDesignSystemExample/Assets.xcassets/AccentColor.colorset/Contents.json b/MLS/MLSDesignSystemExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/MLS/MLSDesignSystemExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystemExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/MLS/MLSDesignSystemExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/MLS/MLSDesignSystemExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystemExample/Assets.xcassets/Contents.json b/MLS/MLSDesignSystemExample/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/MLS/MLSDesignSystemExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDesignSystemExample/Base.lproj/LaunchScreen.storyboard b/MLS/MLSDesignSystemExample/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..865e9329 --- /dev/null +++ b/MLS/MLSDesignSystemExample/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/BadgeTestController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/BadgeTestController.swift new file mode 100644 index 00000000..5d45db6e --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/BadgeTestController.swift @@ -0,0 +1,71 @@ +import UIKit + +import MLSDesignSystem + +import RxSwift +import SnapKit + +final class BadgeTestController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + private let currentBadge = Badge(style: .currentQuest) + private let preBadge = Badge(style: .preQuest) + private let nextBadge = Badge(style: .nextQuest) + private let elementBadge = Badge(style: .element("불 약점")) + + init() { + super.init(nibName: nil, bundle: nil) + title = "Badge" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension BadgeTestController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension BadgeTestController { + func addViews() { + view.addSubview(currentBadge) + view.addSubview(preBadge) + view.addSubview(nextBadge) + view.addSubview(elementBadge) + } + + func setupConstraints() { + currentBadge.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + preBadge.snp.makeConstraints { make in + make.top.equalTo(currentBadge.snp.bottom).offset(16) + make.centerX.equalToSuperview() + } + + nextBadge.snp.makeConstraints { make in + make.top.equalTo(preBadge.snp.bottom).offset(16) + make.centerX.equalToSuperview() + } + + elementBadge.snp.makeConstraints { make in + make.top.equalTo(nextBadge.snp.bottom).offset(16) + make.centerX.equalToSuperview() + } + } + + func configureUI() { + view.backgroundColor = .systemBackground + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/CardListTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/CardListTestViewController.swift new file mode 100644 index 00000000..f6b156b5 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/CardListTestViewController.swift @@ -0,0 +1,140 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class CardListTestViewController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + private let cardList = CardList() + + private let mainTextTextField: UITextField = { + let view = UITextField() + view.placeholder = "main" + view.text = "text" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + return view + }() + + private let mainTextTextLabel: UILabel = { + let label = UILabel() + label.text = "main" + return label + }() + + private let subTextTextField: UITextField = { + let view = UITextField() + view.placeholder = "sub" + view.text = "text" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + return view + }() + + private let subTextTextLabel: UILabel = { + let label = UILabel() + label.text = "sub" + return label + }() + + private let cardListToggle = ToggleBox(text: "isBookmark") + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "CardList" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension CardListTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + bind() + } +} + +// MARK: - SetUp +private extension CardListTestViewController { + func addViews() { + view.addSubview(cardList) + view.addSubview(mainTextTextLabel) + view.addSubview(mainTextTextField) + view.addSubview(subTextTextLabel) + view.addSubview(subTextTextField) + view.addSubview(cardListToggle) + } + + func setupConstraints() { + cardList.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).inset(16) + make.horizontalEdges.equalToSuperview().inset(16) + } + + mainTextTextLabel.snp.makeConstraints { make in + make.top.equalTo(cardList.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + mainTextTextField.snp.makeConstraints { make in + make.top.equalTo(mainTextTextLabel.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + + subTextTextLabel.snp.makeConstraints { make in + make.top.equalTo(mainTextTextField.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + subTextTextField.snp.makeConstraints { make in + make.top.equalTo(subTextTextLabel.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + + cardListToggle.snp.makeConstraints { make in + make.top.equalTo(subTextTextField.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + } + + func configureUI() { + view.backgroundColor = .systemBackground + self.cardList.setType(type: CardList.CardListType.recommended(rank: 1)) + self.cardList.setImage(image: DesignSystemAsset.image(named: "testImage"), backgroundColor: .listMap) + } + + func bind() { + mainTextTextField.rx.text + .withUnretained(self) + .subscribe { (owner, text) in + owner.cardList.mainText = text + } + .disposed(by: disposeBag) + + subTextTextField.rx.text + .withUnretained(self) + .subscribe { (owner, text) in + owner.cardList.subText = text + } + .disposed(by: disposeBag) + + cardListToggle.toggle.rx.isOn + .withUnretained(self) + .subscribe { (owner, isOn) in + owner.cardList.isIconSelected = isOn + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/CheckBoxButtonTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/CheckBoxButtonTestViewController.swift new file mode 100644 index 00000000..14f23c5b --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/CheckBoxButtonTestViewController.swift @@ -0,0 +1,211 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class CheckBoxButtonTestViewController: UIViewController { + // MARK: - Properties + private var disposeBag = DisposeBag() + private var normalButton = CheckBoxButton(style: .normal, mainTitle: nil, subTitle: nil) + private var smallButton = CheckBoxButton(style: .listSmall, mainTitle: nil, subTitle: nil) + private var mediumButton = CheckBoxButton(style: .listMedium, mainTitle: nil, subTitle: nil) + private var largeButton = CheckBoxButton(style: .listLarge, mainTitle: nil, subTitle: nil) + + private let typeSegmentControl: UISegmentedControl = { + let items = ["normal", "listSmall", "listMedium", "listLarge"] + let control = UISegmentedControl(items: items) + control.selectedSegmentIndex = 0 + return control + }() + + private let mainTitleTextField: UITextField = { + let view = UITextField() + view.placeholder = "MainTitle" + view.text = "MainTitle" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + return view + }() + + private let subTitleTextField: UITextField = { + let view = UITextField() + view.placeholder = "SubTitle" + view.text = "SubTitle" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + return view + }() + + private let mainTitleTextLabel: UILabel = { + let label = UILabel() + label.text = "MainTitle" + return label + }() + + private let subTitleTextLabel: UILabel = { + let label = UILabel() + label.text = "SubTitle" + return label + }() + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "CheckBoxButton" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - Life Cycle +extension CheckBoxButtonTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + self.addViews() + self.setupConstraints() + self.configureUI() + self.bind() + } +} + +// MARK: - SetUp +private extension CheckBoxButtonTestViewController { + func addViews() { + view.addSubview(normalButton) + view.addSubview(smallButton) + view.addSubview(mediumButton) + view.addSubview(largeButton) + view.addSubview(typeSegmentControl) + view.addSubview(mainTitleTextLabel) + view.addSubview(mainTitleTextField) + view.addSubview(subTitleTextLabel) + view.addSubview(subTitleTextField) + } + + func setupConstraints() { + normalButton.snp.makeConstraints { make in + make.horizontalEdges.top.equalTo(view.safeAreaLayoutGuide).inset(16) + } + + smallButton.snp.makeConstraints { make in + make.horizontalEdges.top.equalTo(view.safeAreaLayoutGuide).inset(16) + } + + mediumButton.snp.makeConstraints { make in + make.horizontalEdges.top.equalTo(view.safeAreaLayoutGuide).inset(16) + } + + largeButton.snp.makeConstraints { make in + make.horizontalEdges.top.equalTo(view.safeAreaLayoutGuide).inset(16) + } + + typeSegmentControl.snp.makeConstraints { make in + make.top.equalTo(normalButton.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + mainTitleTextLabel.snp.makeConstraints { make in + make.top.equalTo(typeSegmentControl.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + mainTitleTextField.snp.makeConstraints { make in + make.top.equalTo(mainTitleTextLabel.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + + subTitleTextLabel.snp.makeConstraints { make in + make.top.equalTo(mainTitleTextField.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + subTitleTextField.snp.makeConstraints { make in + make.top.equalTo(subTitleTextLabel.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + } + + func configureUI() { + view.backgroundColor = .systemBackground + } + + func bind() { + normalButton.rx.tap + .withUnretained(self) + .subscribe { (owner, _) in + owner.normalButton.isSelected.toggle() + } + .disposed(by: disposeBag) + + smallButton.rx.tap + .withUnretained(self) + .subscribe { (owner, _) in + owner.smallButton.isSelected.toggle() + } + .disposed(by: disposeBag) + + mediumButton.rx.tap + .withUnretained(self) + .subscribe { (owner, _) in + owner.mediumButton.isSelected.toggle() + } + .disposed(by: disposeBag) + + largeButton.rx.tap + .withUnretained(self) + .subscribe { (owner, _) in + owner.largeButton.isSelected.toggle() + } + .disposed(by: disposeBag) + + typeSegmentControl.rx.selectedSegmentIndex + .withUnretained(self) + .subscribe { (owner, index) in + switch index { + case 0: + owner.normalButton.isHidden = false + owner.smallButton.isHidden = true + owner.mediumButton.isHidden = true + owner.largeButton.isHidden = true + case 1: + owner.normalButton.isHidden = true + owner.smallButton.isHidden = false + owner.mediumButton.isHidden = true + owner.largeButton.isHidden = true + case 2: + owner.normalButton.isHidden = true + owner.smallButton.isHidden = true + owner.mediumButton.isHidden = false + owner.largeButton.isHidden = true + default: + owner.normalButton.isHidden = true + owner.smallButton.isHidden = true + owner.mediumButton.isHidden = true + owner.largeButton.isHidden = false + } + } + .disposed(by: disposeBag) + + mainTitleTextField.rx.text + .withUnretained(self) + .subscribe { (owner, text) in + owner.normalButton.mainTitle = text + owner.smallButton.mainTitle = text + owner.mediumButton.mainTitle = text + owner.largeButton.mainTitle = text + } + .disposed(by: disposeBag) + + subTitleTextField.rx.text + .withUnretained(self) + .subscribe { (owner, text) in + owner.normalButton.subTitle = text + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/CollectionListTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/CollectionListTestViewController.swift new file mode 100644 index 00000000..62bd76a7 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/CollectionListTestViewController.swift @@ -0,0 +1,54 @@ +import UIKit + +import MLSDesignSystem + +import RxSwift +import SnapKit + +final class CollectionListTestViewController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + + let collection = CollectionList() + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "CollectionList" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension CollectionListTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + self.addViews() + self.setupConstraints() + self.configureUI() + } +} + +// MARK: - SetUp +private extension CollectionListTestViewController { + func addViews() { + self.view.addSubview(collection) + } + + func setupConstraints() { + collection.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).inset(16) + make.horizontalEdges.equalToSuperview().inset(16) + } + } + + func configureUI() { + self.view.backgroundColor = .neutral200 + collection.setTitle(text: "글자수는 10글자 이후부터 생략입니다.") + collection.setSubtitle(text: "$n개") + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/CommonButtonTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/CommonButtonTestViewController.swift new file mode 100644 index 00000000..313178b0 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/CommonButtonTestViewController.swift @@ -0,0 +1,119 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class CommonButtonTestViewController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + private let commonButton = CommonButton(style: .normal, title: "NormalTitle", disabledTitle: "DisabledTitle") + private let textButton = CommonButton(style: .text, title: "NormalTitle", disabledTitle: "DisabledTitle") + private let borderButton = CommonButton(style: .border, title: "NormalTitle", disabledTitle: "DisabledTitle") + + private let typeSegmentControl: UISegmentedControl = { + let items = ["normal", "text", "border"] + let control = UISegmentedControl(items: items) + control.selectedSegmentIndex = 0 + return control + }() + + private let buttonStateToggle = ToggleBox(text: "isEnabled") + init() { + super.init(nibName: nil, bundle: nil) + self.title = "CommonButton" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension CommonButtonTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + bind() + } +} + +// MARK: - SetUp +private extension CommonButtonTestViewController { + func addViews() { + view.addSubview(commonButton) + view.addSubview(textButton) + view.addSubview(borderButton) + view.addSubview(typeSegmentControl) + view.addSubview(buttonStateToggle) + } + + func setupConstraints() { + commonButton.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).inset(16) + make.horizontalEdges.equalToSuperview().inset(16) + } + + textButton.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).inset(16) + make.centerX.equalToSuperview() + } + + borderButton.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).inset(16) + make.horizontalEdges.equalToSuperview().inset(16) + } + + typeSegmentControl.snp.makeConstraints { make in + make.top.equalTo(textButton.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + buttonStateToggle.snp.makeConstraints { make in + make.top.equalTo(typeSegmentControl.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + } + + func configureUI() { + self.view.backgroundColor = .systemBackground + self.title = "CommonButton" + } + + func bind() { + self.typeSegmentControl.rx.selectedSegmentIndex + .withUnretained(self) + .subscribe { owner, selectedIndex in + switch selectedIndex { + case 0: + owner.commonButton.isHidden = false + owner.textButton.isHidden = true + owner.borderButton.isHidden = true + case 1: + owner.commonButton.isHidden = true + owner.textButton.isHidden = false + owner.borderButton.isHidden = true + default: + owner.commonButton.isHidden = true + owner.textButton.isHidden = true + owner.borderButton.isHidden = false + } + } + .disposed(by: disposeBag) + + self.buttonStateToggle.toggle.rx.isOn + .withUnretained(self) + .subscribe { (owner, isOn) in + owner.commonButton.isEnabled = isOn + owner.textButton.isEnabled = isOn + owner.borderButton.isEnabled = isOn + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/DictionaryDetailViewTestController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/DictionaryDetailViewTestController.swift new file mode 100644 index 00000000..ca7e3c12 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/DictionaryDetailViewTestController.swift @@ -0,0 +1,75 @@ +import UIKit + +import MLSDesignSystem + +import RxSwift +import SnapKit + +final class DictionaryDetailViewTestController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + private let first = DictionaryDetailListView() + private let second = DictionaryDetailListView() + private let third = DictionaryDetailListView() + private let forth = DictionaryDetailListView() + + init() { + super.init(nibName: nil, bundle: nil) + title = "DictionaryDetailView" + first.update(clickableMainText: "mainText", additionalText: "text", clickableSubText: "text") + second.update(mainText: "mainText", clickableSubText: "text") + third.update(mainText: "mainText", subText: "text") + forth.update(clickableMainText: "mainText", clickableSubText: "text") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension DictionaryDetailViewTestController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension DictionaryDetailViewTestController { + func addViews() { + view.addSubview(first) + view.addSubview(second) + view.addSubview(third) + view.addSubview(forth) + } + + func setupConstraints() { + first.snp.makeConstraints { make in + make.horizontalEdges.centerY.equalToSuperview() + } + + second.snp.makeConstraints { make in + make.top.equalTo(first.snp.bottom).offset(16) + make.horizontalEdges.equalToSuperview() + } + + third.snp.makeConstraints { make in + make.top.equalTo(second.snp.bottom).offset(16) + make.horizontalEdges.equalToSuperview() + } + + forth.snp.makeConstraints { make in + make.top.equalTo(third.snp.bottom).offset(16) + make.horizontalEdges.equalToSuperview() + } + } + + func configureUI() { + view.backgroundColor = .systemBackground + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/DropDwonBoxTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/DropDwonBoxTestViewController.swift new file mode 100644 index 00000000..431e81a4 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/DropDwonBoxTestViewController.swift @@ -0,0 +1,161 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class DropDownBoxTextViewController: UIViewController { + // MARK: - Properties + private var disposeBag = DisposeBag() + private var dropDownBox = DropDownBox(items: [DropDownBox.Item(name: "하나", id: 1), DropDownBox.Item(name: "둘", id: 2)]) + private lazy var inputBox = dropDownBox.inputBox + + private let labelTextField: UITextField = { + let view = UITextField() + view.placeholder = "label" + view.text = "label" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + return view + }() + + private let placeHolderTextField: UITextField = { + let view = UITextField() + view.placeholder = "placeHolder" + view.text = "placeHolder" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + return view + }() + + private let countTextField: UITextField = { + let view = UITextField() + view.placeholder = "count" + view.text = "4" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + view.keyboardType = .numberPad + return view + }() + + private let labelTextLabel: UILabel = { + let label = UILabel() + label.text = "label" + return label + }() + + private let placeHolderTextLabel: UILabel = { + let label = UILabel() + label.text = "placeHolder" + return label + }() + + private let countTextLabel: UILabel = { + let label = UILabel() + label.text = "메뉴개수" + return label + }() + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "DropDownBox" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - Life Cycle +extension DropDownBoxTextViewController { + override func viewDidLoad() { + super.viewDidLoad() + self.addViews() + self.setupConstraints() + self.configureUI() + self.bind() + } +} + +// MARK: - SetUp +private extension DropDownBoxTextViewController { + func addViews() { + view.addSubview(labelTextLabel) + view.addSubview(labelTextField) + view.addSubview(placeHolderTextLabel) + view.addSubview(placeHolderTextField) + view.addSubview(countTextLabel) + view.addSubview(countTextField) + view.addSubview(dropDownBox) + } + + func setupConstraints() { + dropDownBox.snp.makeConstraints { make in + make.horizontalEdges.top.equalTo(view.safeAreaLayoutGuide).inset(16) + } + + labelTextLabel.snp.makeConstraints { make in + make.top.equalTo(dropDownBox.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + + labelTextField.snp.makeConstraints { make in + make.top.equalTo(labelTextLabel.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + + placeHolderTextLabel.snp.makeConstraints { make in + make.top.equalTo(labelTextField.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + placeHolderTextField.snp.makeConstraints { make in + make.top.equalTo(placeHolderTextLabel.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + + countTextLabel.snp.makeConstraints { make in + make.top.equalTo(placeHolderTextField.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + countTextField.snp.makeConstraints { make in + make.top.equalTo(countTextLabel.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + } + + func configureUI() { + view.backgroundColor = .systemBackground + } + + func bind() { + labelTextField.rx.text + .withUnretained(self) + .subscribe { owner, text in + owner.inputBox.label.attributedText = .makeStyledString(font: .b_s_r, text: text, color: .neutral700, alignment: .left) + } + .disposed(by: disposeBag) + + placeHolderTextField.rx.text + .withUnretained(self) + .subscribe { owner, text in + owner.inputBox.textField.attributedPlaceholder = .makeStyledString(font: .b_m_r, text: text, color: .neutral500, alignment: .left) + } + .disposed(by: disposeBag) + + countTextField.rx.text + .withUnretained(self) + .subscribe { (owner, count) in + guard let count = Int(count ?? "") else { return } + owner.dropDownBox.items = [] + for index in 1...count { + owner.dropDownBox.items.append(DropDownBox.Item(name: "메뉴\(index)", id: index)) + } + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/ErrorMessageTextViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/ErrorMessageTextViewController.swift new file mode 100644 index 00000000..7ab8b8e7 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/ErrorMessageTextViewController.swift @@ -0,0 +1,88 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class ErrorMessageTextViewController: UIViewController { + // MARK: - Properties + private var disposeBag = DisposeBag() + private var errorMessage = ErrorMessage(message: nil) + + private let messageTextField: UITextField = { + let view = UITextField() + view.placeholder = "message" + view.text = "error" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + return view + }() + + private let messageTextLabel: UILabel = { + let label = UILabel() + label.text = "message" + return label + }() + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "ErrorMessage" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - Life Cycle +extension ErrorMessageTextViewController { + override func viewDidLoad() { + super.viewDidLoad() + self.addViews() + self.setupConstraints() + self.configureUI() + self.bind() + } +} + +// MARK: - SetUp +private extension ErrorMessageTextViewController { + func addViews() { + view.addSubview(errorMessage) + view.addSubview(messageTextLabel) + view.addSubview(messageTextField) + } + + func setupConstraints() { + errorMessage.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).inset(16) + make.centerX.equalToSuperview() + } + + messageTextLabel.snp.makeConstraints { make in + make.top.equalTo(errorMessage.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + messageTextField.snp.makeConstraints { make in + make.top.equalTo(messageTextLabel.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + } + + func configureUI() { + view.backgroundColor = .systemBackground + } + + func bind() { + messageTextField.rx.text + .withUnretained(self) + .subscribe { owner, message in + owner.errorMessage.label.attributedText = .makeStyledString(font: .b_s_r, text: message, color: .error900) + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/GuideAlertTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/GuideAlertTestViewController.swift new file mode 100644 index 00000000..2271e12e --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/GuideAlertTestViewController.swift @@ -0,0 +1,105 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class GuideAlertTestViewController: UIViewController { + // MARK: - Properties + var disposeBag = DisposeBag() + + let oneButton = CommonButton(style: .normal, title: "oneButtonModal", disabledTitle: nil) + let twoButton = CommonButton(style: .normal, title: "twoButtonModal", disabledTitle: nil) + let logoutButton = CommonButton(style: .normal, title: "logoutButtonModal", disabledTitle: nil) + let withdrawButton = CommonButton(style: .normal, title: "withdrawButtonModal", disabledTitle: nil) + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "GuideAlert" + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension GuideAlertTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + bind() + } +} + +// MARK: - SetUp +private extension GuideAlertTestViewController { + func addViews() { + view.addSubview(oneButton) + view.addSubview(twoButton) + view.addSubview(logoutButton) + view.addSubview(withdrawButton) + } + + func setupConstraints() { + oneButton.snp.makeConstraints { make in + make.bottom.equalToSuperview().inset(16) + make.centerX.equalToSuperview() + } + + twoButton.snp.makeConstraints { make in + make.bottom.equalTo(oneButton.snp.top).offset(-16) + make.centerX.equalToSuperview() + } + + logoutButton.snp.makeConstraints { make in + make.bottom.equalTo(twoButton.snp.top).offset(-16) + make.centerX.equalToSuperview() + } + + withdrawButton.snp.makeConstraints { make in + make.bottom.equalTo(logoutButton.snp.top).offset(-16) + make.centerX.equalToSuperview() + } + } + + func configureUI() { + view.backgroundColor = .systemBackground + } + + func bind() { + oneButton.rx.tap + .withUnretained(self) + .subscribe { _, _ in + GuideAlertFactory.show(mainText: "버튼 하나", ctaText: "확인", ctaAction: {}) + } + .disposed(by: disposeBag) + + twoButton.rx.tap + .withUnretained(self) + .subscribe { _, _ in + GuideAlertFactory.show(mainText: "버튼 두개", ctaText: "확인", cancelText: "취소", ctaAction: {}) + } + .disposed(by: disposeBag) + + logoutButton.rx.tap + .withUnretained(self) + .subscribe { _, _ in + GuideAlertFactory.showAuthAlert(type: .logout, ctaAction: {}) + } + .disposed(by: disposeBag) + + withdrawButton.rx.tap + .withUnretained(self) + .subscribe { _, _ in + GuideAlertFactory.showAuthAlert(type: .withdraw, ctaAction: {}) + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/HeaderTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/HeaderTestViewController.swift new file mode 100644 index 00000000..38fdaa4c --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/HeaderTestViewController.swift @@ -0,0 +1,157 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class HeaderTestViewController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + private let mainHeader = Header(style: .main, title: "메인") + private let filterHeader = Header(style: .filter, title: "필터") + + private let typeSegmentControl: UISegmentedControl = { + let items = ["main", "filter"] + let control = UISegmentedControl(items: items) + control.selectedSegmentIndex = 0 + return control + }() + + private let mainTextTextField: UITextField = { + let view = UITextField() + view.placeholder = "text" + view.text = "text" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + return view + }() + + private let mainTextTextLabel: UILabel = { + let label = UILabel() + label.text = "text" + return label + }() + + private let filterTextTextField: UITextField = { + let view = UITextField() + view.placeholder = "text" + view.text = "text" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + return view + }() + + private let filterTextTextLabel: UILabel = { + let label = UILabel() + label.text = "text" + return label + }() + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "Header" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension HeaderTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + bind() + } +} + +// MARK: - SetUp +private extension HeaderTestViewController { + func addViews() { + view.addSubview(mainHeader) + view.addSubview(filterHeader) + view.addSubview(typeSegmentControl) + view.addSubview(mainTextTextLabel) + view.addSubview(mainTextTextField) + view.addSubview(filterTextTextLabel) + view.addSubview(filterTextTextField) + } + + func setupConstraints() { + mainHeader.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).inset(16) + make.horizontalEdges.equalToSuperview().inset(16) + } + + filterHeader.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).inset(16) + make.horizontalEdges.equalToSuperview().inset(16) + } + + typeSegmentControl.snp.makeConstraints { make in + make.top.equalTo(mainHeader.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + mainTextTextLabel.snp.makeConstraints { make in + make.top.equalTo(typeSegmentControl.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + mainTextTextField.snp.makeConstraints { make in + make.top.equalTo(mainTextTextLabel.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + + filterTextTextLabel.snp.makeConstraints { make in + make.top.equalTo(mainTextTextField.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + filterTextTextField.snp.makeConstraints { make in + make.top.equalTo(filterTextTextLabel.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + } + + func configureUI() { + self.view.backgroundColor = .systemBackground + } + + func bind() { + typeSegmentControl.rx.selectedSegmentIndex + .withUnretained(self) + .subscribe { owner, selectedIndex in + switch selectedIndex { + case 0: + owner.mainHeader.isHidden = false + owner.filterHeader.isHidden = true + default: + owner.mainHeader.isHidden = true + owner.filterHeader.isHidden = false + } + } + .disposed(by: disposeBag) + + mainTextTextField.rx.text + .withUnretained(self) + .subscribe { (owner, text) in + owner.mainHeader.titleLabel.attributedText = .makeStyledString(font: owner.mainHeader.style.titleFont, text: text) + } + .disposed(by: disposeBag) + + filterTextTextField.rx.text + .withUnretained(self) + .subscribe { (owner, text) in + owner.filterHeader.titleLabel.attributedText = .makeStyledString(font: owner.filterHeader.style.titleFont, text: text) + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/InputBoxTextViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/InputBoxTextViewController.swift new file mode 100644 index 00000000..32db74b4 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/InputBoxTextViewController.swift @@ -0,0 +1,179 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class InputBoxTextViewController: UIViewController { + // MARK: - Properties + private var disposeBag = DisposeBag() + private var inputBox = InputBox(label: "label", placeHodler: "placeHolder") + + private let typeSegmentControl: UISegmentedControl = { + let items = ["edit", "error"] + let control = UISegmentedControl(items: items) + control.selectedSegmentIndex = 0 + return control + }() + + private let labelTextField: UITextField = { + let view = UITextField() + view.placeholder = "label" + view.text = "label" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + return view + }() + + private let placeHolderTextField: UITextField = { + let view = UITextField() + view.placeholder = "placeHolder" + view.text = "placeHolder" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + return view + }() + + private let textTextField: UITextField = { + let view = UITextField() + view.placeholder = "text" + view.text = "text" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + return view + }() + + private let labelTextLabel: UILabel = { + let label = UILabel() + label.text = "label" + return label + }() + + private let placeHolderTextLabel: UILabel = { + let label = UILabel() + label.text = "placeHolder" + return label + }() + + private let textTextLabel: UILabel = { + let label = UILabel() + label.text = "text" + return label + }() + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "InputBox" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - Life Cycle +extension InputBoxTextViewController { + override func viewDidLoad() { + super.viewDidLoad() + self.addViews() + self.setupConstraints() + self.configureUI() + self.bind() + } +} + +// MARK: - SetUp +private extension InputBoxTextViewController { + func addViews() { + view.addSubview(inputBox) + view.addSubview(typeSegmentControl) + view.addSubview(labelTextLabel) + view.addSubview(labelTextField) + view.addSubview(placeHolderTextLabel) + view.addSubview(placeHolderTextField) + view.addSubview(textTextLabel) + view.addSubview(textTextField) + } + + func setupConstraints() { + inputBox.snp.makeConstraints { make in + make.horizontalEdges.top.equalTo(view.safeAreaLayoutGuide).inset(16) + } + + typeSegmentControl.snp.makeConstraints { make in + make.top.equalTo(inputBox.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + labelTextLabel.snp.makeConstraints { make in + make.top.equalTo(typeSegmentControl.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + labelTextField.snp.makeConstraints { make in + make.top.equalTo(labelTextLabel.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + + placeHolderTextLabel.snp.makeConstraints { make in + make.top.equalTo(labelTextField.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + placeHolderTextField.snp.makeConstraints { make in + make.top.equalTo(placeHolderTextLabel.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + + textTextLabel.snp.makeConstraints { make in + make.top.equalTo(placeHolderTextField.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + textTextField.snp.makeConstraints { make in + make.top.equalTo(textTextLabel.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(16) + } + } + + func configureUI() { + view.backgroundColor = .systemBackground + } + + func bind() { + typeSegmentControl.rx.selectedSegmentIndex + .withUnretained(self) + .subscribe { (owner, index) in + if index == 0 { + owner.inputBox.setType(type: .edit) + } else { + owner.inputBox.setType(type: .error) + } + } + .disposed(by: disposeBag) + + labelTextField.rx.text + .withUnretained(self) + .subscribe { owner, text in + owner.inputBox.label.attributedText = .makeStyledString(font: .b_s_r, text: text, color: .neutral700, alignment: .left) + } + .disposed(by: disposeBag) + + placeHolderTextField.rx.text + .withUnretained(self) + .subscribe { owner, text in + owner.inputBox.textField.attributedPlaceholder = .makeStyledString(font: .b_m_r, text: text, color: .neutral500, alignment: .left) + } + .disposed(by: disposeBag) + + textTextField.rx.text + .withUnretained(self) + .subscribe { (owner, text) in + owner.inputBox.textField.attributedText = .makeStyledString(font: .b_m_r, text: text, alignment: .left) + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/NavigationBarTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/NavigationBarTestViewController.swift new file mode 100644 index 00000000..04f48dc1 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/NavigationBarTestViewController.swift @@ -0,0 +1,99 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class NavigationBarTestViewController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + + private let headerView1: NavigationBar = { + let view = NavigationBar(type: .withUnderLine("null")) + return view + }() + + private let headerView2: NavigationBar = { + let view = NavigationBar(type: .arrowRightLeft) + return view + }() + + private let headerView3: NavigationBar = { + let view = NavigationBar(type: .arrowLeft) + return view + }() + + private let headerView4: NavigationBar = { + let view = NavigationBar(type: .withString("null")) + return view + }() + + private let headerView5: NavigationBar = { + let view = NavigationBar(type: .collection("컬렉션 이름")) + return view + }() + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "NavigationBar" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension NavigationBarTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension NavigationBarTestViewController { + func addViews() { + view.addSubview(headerView1) + view.addSubview(headerView2) + view.addSubview(headerView3) + view.addSubview(headerView4) + view.addSubview(headerView5) + } + + func setupConstraints() { + headerView1.snp.makeConstraints { make in + make.top.horizontalEdges.equalTo(view.safeAreaLayoutGuide) + } + + headerView2.snp.makeConstraints { make in + make.top.equalTo(headerView1.snp.bottom).offset(16) + make.horizontalEdges.equalTo(view.safeAreaLayoutGuide) + } + + headerView3.snp.makeConstraints { make in + make.top.equalTo(headerView2.snp.bottom).offset(16) + make.horizontalEdges.equalTo(view.safeAreaLayoutGuide) + } + + headerView4.snp.makeConstraints { make in + make.top.equalTo(headerView3.snp.bottom).offset(16) + make.horizontalEdges.equalTo(view.safeAreaLayoutGuide) + } + + headerView5.snp.makeConstraints { make in + make.top.equalTo(headerView4.snp.bottom).offset(16) + make.horizontalEdges.equalTo(view.safeAreaLayoutGuide) + } + } + + func configureUI() { + view.backgroundColor = .systemBackground + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/SearchBarTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/SearchBarTestViewController.swift new file mode 100644 index 00000000..e1d5b1c7 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/SearchBarTestViewController.swift @@ -0,0 +1,61 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class SearchBarTestViewController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + + let searchBar: SearchBar = SearchBar() + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "SearchBar" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension SearchBarTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + self.addViews() + self.setupConstraints() + self.configureUI() + } +} + +// MARK: - SetUp +private extension SearchBarTestViewController { + func addViews() { + view.addSubview(searchBar) + } + + func setupConstraints() { + searchBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).inset(16) + make.horizontalEdges.equalToSuperview() + } + } + + func configureUI() { + self.view.backgroundColor = .systemBackground + let tapGesture = UITapGestureRecognizer() + view.addGestureRecognizer(tapGesture) + + tapGesture.rx.event + .bind { [weak self] _ in + self?.view.endEditing(true) + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/SnackBarTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/SnackBarTestViewController.swift new file mode 100644 index 00000000..e11ba7b1 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/SnackBarTestViewController.swift @@ -0,0 +1,61 @@ +import UIKit + +import MLSDesignSystem + +import RxSwift +import SnapKit + +final class SnackBarTestViewController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + + let normalSnackBar = SnackBar(type: .normal, image: DesignSystemAsset.image(named: "appleLogo"), imageBackgroundColor: .listNPC, text: "제목제목", buttonText: "되돌리기", buttonAction: nil) + + let deleteSnackBar = SnackBar(type: .delete, image: DesignSystemAsset.image(named: "testImage"), imageBackgroundColor: .listNPC, text: "제목제목", buttonText: "되돌리기", buttonAction: nil) + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "SnackBar" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension SnackBarTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension SnackBarTestViewController { + func addViews() { + view.addSubview(normalSnackBar) + view.addSubview(deleteSnackBar) + } + + func setupConstraints() { + normalSnackBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).inset(16) + make.centerX.equalToSuperview() + } + + deleteSnackBar.snp.makeConstraints { make in + make.top.equalTo(normalSnackBar.snp.bottom).offset(16) + make.centerX.equalToSuperview() + } + } + + func configureUI() { + self.view.backgroundColor = .systemBackground + + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/StepIndicatorTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/StepIndicatorTestViewController.swift new file mode 100644 index 00000000..8405be63 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/StepIndicatorTestViewController.swift @@ -0,0 +1,70 @@ +import UIKit + +import MLSDesignSystem + +import RxSwift +import SnapKit + +final class StepIndicatorTestViewController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + + let firstIndicator = StepIndicator(circleCount: 3) + let secondIndicator = StepIndicator(circleCount: 3) + let thirdIndicator = StepIndicator(circleCount: 3) + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "StepIndicator" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension StepIndicatorTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension StepIndicatorTestViewController { + func addViews() { + view.addSubview(firstIndicator) + view.addSubview(secondIndicator) + view.addSubview(thirdIndicator) + } + + func setupConstraints() { + firstIndicator.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(16) + make.centerX.equalToSuperview() + } + + secondIndicator.snp.makeConstraints { make in + make.top.equalTo(firstIndicator.snp.bottom).offset(16) + make.centerX.equalToSuperview() + } + + thirdIndicator.snp.makeConstraints { make in + make.top.equalTo(secondIndicator.snp.bottom).offset(16) + make.centerX.equalToSuperview() + } + } + + func configureUI() { + view.backgroundColor = .systemBackground + + firstIndicator.selectIndicator(index: 0) + secondIndicator.selectIndicator(index: 1) + thirdIndicator.selectIndicator(index: 2) + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/TagChipTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/TagChipTestViewController.swift new file mode 100644 index 00000000..4890c6d1 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/TagChipTestViewController.swift @@ -0,0 +1,157 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class TagChipTestViewController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + private let normalTagChip = TagChip(style: .normal, text: "text") + private let searchTagChip = TagChip(style: .search, text: "text") + + private let typeSegmentControl: UISegmentedControl = { + let items = ["normal", "search"] + let control = UISegmentedControl(items: items) + control.selectedSegmentIndex = 0 + return control + }() + + private let normalTextLabel: UILabel = { + let label = UILabel() + label.text = "normal" + return label + }() + + private let normalTextField: UITextField = { + let view = UITextField() + view.placeholder = "text" + view.text = "text" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + return view + }() + + private let searchTextLabel: UILabel = { + let label = UILabel() + label.text = "search" + return label + }() + + private let searchTextField: UITextField = { + let view = UITextField() + view.placeholder = "text" + view.text = "text" + view.layer.borderColor = UIColor.gray.cgColor + view.layer.borderWidth = 1 + return view + }() + + init() { + super.init(nibName: nil, bundle: nil) + title = "TagChip" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension TagChipTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + bind() + } +} + +// MARK: - SetUp +private extension TagChipTestViewController { + func addViews() { + view.addSubview(normalTagChip) + view.addSubview(searchTagChip) + view.addSubview(typeSegmentControl) + view.addSubview(normalTextLabel) + view.addSubview(normalTextField) + view.addSubview(searchTextLabel) + view.addSubview(searchTextField) + } + + func setupConstraints() { + normalTagChip.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).inset(16) + make.centerX.equalToSuperview() + } + + searchTagChip.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).inset(16) + make.centerX.equalToSuperview() + } + + typeSegmentControl.snp.makeConstraints { make in + make.top.equalTo(normalTagChip.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + normalTextLabel.snp.makeConstraints { make in + make.top.equalTo(typeSegmentControl.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + normalTextField.snp.makeConstraints { make in + make.top.equalTo(normalTextLabel.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + searchTextLabel.snp.makeConstraints { make in + make.top.equalTo(normalTextField.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + + searchTextField.snp.makeConstraints { make in + make.top.equalTo(searchTextLabel.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + } + + func configureUI() { + view.backgroundColor = .systemBackground + } + + func bind() { + typeSegmentControl.rx.selectedSegmentIndex + .withUnretained(self) + .subscribe { owner, selectedIndex in + switch selectedIndex { + case 0: + owner.normalTagChip.isHidden = false + owner.searchTagChip.isHidden = true + default: + owner.normalTagChip.isHidden = true + owner.searchTagChip.isHidden = false + } + } + .disposed(by: disposeBag) + + normalTextField.rx.text.orEmpty + .withUnretained(self) + .subscribe { (owner, text) in + owner.normalTagChip.text = text + } + .disposed(by: disposeBag) + + searchTextField.rx.text.orEmpty + .withUnretained(self) + .subscribe { (owner, text) in + owner.searchTagChip.text = text + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/TapButtonTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/TapButtonTestViewController.swift new file mode 100644 index 00000000..235b113d --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/TapButtonTestViewController.swift @@ -0,0 +1,70 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class TapButtonTestViewController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + private let tapButton = TapButton(text: "text") + + private let buttonStateToggle = ToggleBox(text: "isSelected") + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "TapButton" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension TapButtonTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + bind() + } +} + +// MARK: - SetUp +private extension TapButtonTestViewController { + func addViews() { + view.addSubview(tapButton) + view.addSubview(buttonStateToggle) + } + + func setupConstraints() { + tapButton.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).inset(16) + make.centerX.equalToSuperview() + } + + buttonStateToggle.snp.makeConstraints { make in + make.top.equalTo(tapButton.snp.bottom).offset(30) + make.horizontalEdges.equalToSuperview().inset(16) + } + } + + func configureUI() { + view.backgroundColor = .systemBackground + } + + func bind() { + buttonStateToggle.toggle.rx.isOn + .withUnretained(self) + .subscribe { (owner, isOn) in + owner.tapButton.isSelected = isOn + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/TextButtonTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/TextButtonTestViewController.swift new file mode 100644 index 00000000..18ec11c8 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/TextButtonTestViewController.swift @@ -0,0 +1,47 @@ +import UIKit + +import MLSDesignSystem + +import RxSwift +import SnapKit + +final class TextButtonTestViewController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + + let button = TextButton() + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "TextButton" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension TextButtonTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .whiteMLS + addViews() + setupConstraints() + } +} + +// MARK: - SetUp +private extension TextButtonTestViewController { + func addViews() { + view.addSubview(button) + } + + func setupConstraints() { + button.snp.makeConstraints { make in + make.center.equalToSuperview() + } + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/ToastMakerTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/ToastMakerTestViewController.swift new file mode 100644 index 00000000..0c52b17f --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/ToastMakerTestViewController.swift @@ -0,0 +1,47 @@ +import UIKit + +import MLSDesignSystem + +import RxSwift +import SnapKit + +final class ToastMakerTestViewController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + + let toast = Toast(message: "토스트 테스트") + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "Toast" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension ToastMakerTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .whiteMLS + addViews() + setupConstraints() + } +} + +// MARK: - SetUp +private extension ToastMakerTestViewController { + func addViews() { + view.addSubview(toast) + } + + func setupConstraints() { + toast.snp.makeConstraints { make in + make.center.equalToSuperview() + } + } +} diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/TooltipTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/TooltipTestViewController.swift new file mode 100644 index 00000000..48bb3a51 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/TooltipTestViewController.swift @@ -0,0 +1,123 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class TooltipTestViewController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + + let button1 = { + let button = UIButton(type: .system) + button.setTitle("우상단 버튼입니다", for: .normal) + return button + }() + + let button2 = { + let button = UIButton(type: .system) + button.setTitle("좌상단", for: .normal) + return button + }() + + let button3 = { + let button = UIButton(type: .system) + button.setTitle("우하단", for: .normal) + return button + }() + + let button4 = { + let button = UIButton(type: .system) + button.setTitle("좌하단", for: .normal) + return button + }() + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "툴팁" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension TooltipTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .black + addViews() + setupConstraints() + bind() + } +} + +// MARK: - SetUp +private extension TooltipTestViewController { + func addViews() { + view.addSubview(button1) + view.addSubview(button2) + view.addSubview(button3) + view.addSubview(button4) + } + + func setupConstraints() { + button1.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + button2.snp.makeConstraints { make in + make.top.equalTo(button1.snp.bottom).offset(10) + make.centerX.equalToSuperview() + } + + button3.snp.makeConstraints { make in + make.top.equalTo(button2.snp.bottom).offset(10) + make.centerX.equalToSuperview() + } + + button4.snp.makeConstraints { make in + make.top.equalTo(button3.snp.bottom).offset(10) + make.centerX.equalToSuperview() + } + } + + func bind() { + button1.rx.tap + .withUnretained(self) + .subscribe { owner, _ in + TooltipFactory.show(text: "같은 레벨·직업 유저들이 자주 언급한\n사냥터를 기반으로 추천해요.", anchorView: owner.button1, tooltipPosition: .topLeading + ) + } + .disposed(by: disposeBag) + + button2.rx.tap + .withUnretained(self) + .subscribe { owner, _ in + TooltipFactory.show(text: "같은 레벨·직업 유저들이 자주 언급한\n사냥터를 기반으로 추천해요.", anchorView: owner.button2, tooltipPosition: .topTrailing + ) + } + .disposed(by: disposeBag) + + button3.rx.tap + .withUnretained(self) + .subscribe { owner, _ in + TooltipFactory.show(text: "같은 레벨·직업 유저들이 자주 언급한\n사냥터를 기반으로 추천해요.", anchorView: owner.button3, tooltipPosition: .bottomLeading + ) + } + .disposed(by: disposeBag) + + button4.rx.tap + .withUnretained(self) + .subscribe { owner, _ in + TooltipFactory.show(text: "같은 레벨·직업 유저들이 자주 언급한\n사냥터를 기반으로 추천해요.", anchorView: owner.button4, tooltipPosition: .bottomTrailing + ) + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDesignSystemExample/Info.plist b/MLS/MLSDesignSystemExample/Info.plist new file mode 100644 index 00000000..0eb786dc --- /dev/null +++ b/MLS/MLSDesignSystemExample/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/MLS/MLSDesignSystemExample/ViewController.swift b/MLS/MLSDesignSystemExample/ViewController.swift new file mode 100644 index 00000000..0c211648 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ViewController.swift @@ -0,0 +1,112 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +class ViewController: UIViewController { + let tableView: UITableView = { + let view = UITableView(frame: .zero, style: .plain) + return view + }() + + let bottomTabBarViewController = BottomTabBarController(viewControllers: [ + CheckBoxButtonTestViewController(), + NavigationBarTestViewController(), + CommonButtonTestViewController(), + InputBoxTextViewController() + ], initialIndex: 1) + + lazy var componentViews: [UIViewController] = [ + CheckBoxButtonTestViewController(), + NavigationBarTestViewController(), + CommonButtonTestViewController(), + InputBoxTextViewController(), + DropDownBoxTextViewController(), + ToastMakerTestViewController(), + ErrorMessageTextViewController(), + StepIndicatorTestViewController(), + HeaderTestViewController(), + TapButtonTestViewController(), + TagChipTestViewController(), + GuideAlertTestViewController(), + CardListTestViewController(), + bottomTabBarViewController, + SearchBarTestViewController(), + CollectionListTestViewController(), + SnackBarTestViewController(), + BadgeTestController(), + DictionaryDetailViewTestController(), + TextButtonTestViewController(), + TooltipTestViewController() + ] + + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .systemBackground + tableView.dataSource = self + tableView.delegate = self + navigationItem.title = "MLS Design System" + bottomTabBarViewController.title = "BottomTabBar" + + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } +} + +extension ViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { + return 2 + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch section { + case 0: + return "Components" + default: + return nil + } + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch section { + case 0: + return componentViews.count + default: + return 0 + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell() + let viewController: UIViewController + + switch indexPath.section { + case 0: + viewController = componentViews[indexPath.row] + default: + return cell + } + + cell.textLabel?.text = viewController.title + cell.selectionStyle = .none + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let nextController: UIViewController + + switch indexPath.section { + case 0: + nextController = componentViews[indexPath.row] + default: + return + } + + navigationController?.pushViewController(nextController, animated: true) + } +} diff --git a/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginView.swift b/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginView.swift index b6f1aad4..f6f1fa32 100644 --- a/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginView.swift +++ b/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginView.swift @@ -67,7 +67,7 @@ final class LoginView: UIView { }() private let appleLogoImageView: UIImageView = { - let image = DesignSystemAsset.image(named: "AppleLogo") + let image = DesignSystemAsset.image(named: "appleLogo") let view = UIImageView(image: image) view.contentMode = .scaleAspectFit return view diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationReactor.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationReactor.swift index 305f8bf7..20e8e42c 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationReactor.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationReactor.swift @@ -63,7 +63,7 @@ public final class DictionaryNotificationReactor: Reactor { let notificationStream: Observable = Observable.concat([ Observable.just(.setLoading(true)), - fetchAllAlarmUseCase.execute(cursor: nil, pageSize: 20) + fetchAllAlarmUseCase.execute(id: nil, pageSize: 20) .map { paged in Mutation.setNotifications(paged.items, hasMore: paged.hasMore, reset: true) }, @@ -78,11 +78,11 @@ public final class DictionaryNotificationReactor: Reactor { case .loadMore: guard currentState.hasMore, !currentState.isLoading else { return .empty() } - let cursor = currentState.notifications.last?.date + let cursor = currentState.notifications.last?.id return Observable.concat([ Observable.just(.setLoading(true)), - fetchAllAlarmUseCase.execute(cursor: cursor, pageSize: 20) + fetchAllAlarmUseCase.execute(id: cursor, pageSize: 20) .map { paged in Mutation.setNotifications(paged.items, hasMore: paged.hasMore, reset: false) }, diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature.xcodeproj/project.pbxproj b/MLS/Presentation/MyPageFeature/MyPageFeature.xcodeproj/project.pbxproj index 77c3b916..dae3ecdd 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature.xcodeproj/project.pbxproj +++ b/MLS/Presentation/MyPageFeature/MyPageFeature.xcodeproj/project.pbxproj @@ -27,10 +27,6 @@ 773A3F6F2E71736300F75B30 /* BaseFeature.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 773A3F6D2E71736300F75B30 /* BaseFeature.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 773A3F712E71736A00F75B30 /* BaseFeature.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 773A3F702E71736A00F75B30 /* BaseFeature.framework */; }; 773A42D02E717B8900F75B30 /* BaseFeature.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 773A42CF2E717B8900F75B30 /* BaseFeature.framework */; }; - 7746A7EF2E841C9C0046F603 /* Data.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7746A7EE2E841C9C0046F603 /* Data.framework */; }; - 7746A7F02E841C9C0046F603 /* Data.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 7746A7EE2E841C9C0046F603 /* Data.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 7746A7F22E841CEA0046F603 /* DataMock.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7746A7F12E841CEA0046F603 /* DataMock.framework */; }; - 7746A7F32E841CEA0046F603 /* DataMock.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 7746A7F12E841CEA0046F603 /* DataMock.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 7746ABAA2E84225A0046F603 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7746ABA92E84225A0046F603 /* Core.framework */; }; 7746ABAB2E84225A0046F603 /* Core.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 7746ABA92E84225A0046F603 /* Core.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 7746ABAD2E84227A0046F603 /* DomainInterface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7746ABAC2E84227A0046F603 /* DomainInterface.framework */; }; @@ -38,13 +34,11 @@ 7746ABB32E8425E00046F603 /* DesignSystem.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7746ABB22E8425E00046F603 /* DesignSystem.framework */; }; 7746ABB72E8427F80046F603 /* RxKeyboard in Frameworks */ = {isa = PBXBuildFile; productRef = 7746ABB62E8427F80046F603 /* RxKeyboard */; }; 775966F32EC30B3A00CC389B /* AuthFeatureInterface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 775966F22EC30B3A00CC389B /* AuthFeatureInterface.framework */; }; - 776FEA342EA4D29B0039ACE2 /* AuthFeatureInterface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 776FEA332EA4D29B0039ACE2 /* AuthFeatureInterface.framework */; }; - 776FEA352EA4D29B0039ACE2 /* AuthFeatureInterface.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 776FEA332EA4D29B0039ACE2 /* AuthFeatureInterface.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 777C28142E7D87B8000765F2 /* RxKeyboard in Frameworks */ = {isa = PBXBuildFile; productRef = 777C28132E7D87B8000765F2 /* RxKeyboard */; }; - 77BE55BA2E78596900522216 /* Domain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 77BE55B82E78596900522216 /* Domain.framework */; }; - 77BE55BB2E78596900522216 /* Domain.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 77BE55B82E78596900522216 /* Domain.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 77BE55BC2E78596900522216 /* DomainInterface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 77BE55B92E78596900522216 /* DomainInterface.framework */; }; 77BE55BD2E78596900522216 /* DomainInterface.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 77BE55B92E78596900522216 /* DomainInterface.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 77D0AD422F97DA78008B0C57 /* AuthFeatureInterface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 77D0AD412F97DA78008B0C57 /* AuthFeatureInterface.framework */; }; + 77D0AD432F97DA78008B0C57 /* AuthFeatureInterface.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 77D0AD412F97DA78008B0C57 /* AuthFeatureInterface.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D22195C52E83FA81001203D4 /* RxGesture in Frameworks */ = {isa = PBXBuildFile; productRef = D22195C42E83FA81001203D4 /* RxGesture */; }; D2DA8C322E798917009C53BA /* ReactorKit in Frameworks */ = {isa = PBXBuildFile; productRef = D2227A7C2E76A84A0095343A /* ReactorKit */; }; /* End PBXBuildFile section */ @@ -80,13 +74,10 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + 77D0AD432F97DA78008B0C57 /* AuthFeatureInterface.framework in Embed Frameworks */, 77BE55BD2E78596900522216 /* DomainInterface.framework in Embed Frameworks */, - 77BE55BB2E78596900522216 /* Domain.framework in Embed Frameworks */, - 7746A7F32E841CEA0046F603 /* DataMock.framework in Embed Frameworks */, - 776FEA352EA4D29B0039ACE2 /* AuthFeatureInterface.framework in Embed Frameworks */, 773A3F6F2E71736300F75B30 /* BaseFeature.framework in Embed Frameworks */, 773A3F6C2E71734600F75B30 /* DesignSystem.framework in Embed Frameworks */, - 7746A7F02E841C9C0046F603 /* Data.framework in Embed Frameworks */, 773A3F672E71731700F75B30 /* MyPageFeature.framework in Embed Frameworks */, 7746ABAB2E84225A0046F603 /* Core.framework in Embed Frameworks */, 773A3C002E7170A700F75B30 /* MyPageFeatureInterface.framework in Embed Frameworks */, @@ -113,6 +104,7 @@ 776FEA332EA4D29B0039ACE2 /* AuthFeatureInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AuthFeatureInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 77BE55B82E78596900522216 /* Domain.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Domain.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 77BE55B92E78596900522216 /* DomainInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = DomainInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 77D0AD412F97DA78008B0C57 /* AuthFeatureInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AuthFeatureInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D2227A762E76A7E70095343A /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -179,20 +171,17 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 7746A7EF2E841C9C0046F603 /* Data.framework in Frameworks */, D2DA8C322E798917009C53BA /* ReactorKit in Frameworks */, 77BE55BC2E78596900522216 /* DomainInterface.framework in Frameworks */, 773A3F6E2E71736300F75B30 /* BaseFeature.framework in Frameworks */, 773A3F6B2E71734600F75B30 /* DesignSystem.framework in Frameworks */, 7746ABB72E8427F80046F603 /* RxKeyboard in Frameworks */, 773A3F662E71731700F75B30 /* MyPageFeature.framework in Frameworks */, - 77BE55BA2E78596900522216 /* Domain.framework in Frameworks */, 7746ABB12E84245D0046F603 /* RxGesture in Frameworks */, + 77D0AD422F97DA78008B0C57 /* AuthFeatureInterface.framework in Frameworks */, 7746ABAA2E84225A0046F603 /* Core.framework in Frameworks */, 773A3BFC2E71701D00F75B30 /* RxSwift in Frameworks */, 773A3BFE2E71702300F75B30 /* SnapKit in Frameworks */, - 776FEA342EA4D29B0039ACE2 /* AuthFeatureInterface.framework in Frameworks */, - 7746A7F22E841CEA0046F603 /* DataMock.framework in Frameworks */, 773A3BFA2E71701D00F75B30 /* RxRelay in Frameworks */, 773A3BF82E71701D00F75B30 /* RxCocoa in Frameworks */, 773A3BFF2E7170A700F75B30 /* MyPageFeatureInterface.framework in Frameworks */, @@ -226,6 +215,7 @@ 773A3BF62E71701D00F75B30 /* Frameworks */ = { isa = PBXGroup; children = ( + 77D0AD412F97DA78008B0C57 /* AuthFeatureInterface.framework */, 775966F22EC30B3A00CC389B /* AuthFeatureInterface.framework */, 776FEA332EA4D29B0039ACE2 /* AuthFeatureInterface.framework */, 7746ABB22E8425E00046F603 /* DesignSystem.framework */, diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Announcement/AnnouncementReactor.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Announcement/AnnouncementReactor.swift index cfaf96ec..ff7ee125 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Announcement/AnnouncementReactor.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Announcement/AnnouncementReactor.swift @@ -42,7 +42,7 @@ public final class AnnouncementReactor: Reactor { case .viewWillAppear: return .concat([ .just(.setLoading(true)), - fetchNoticesUseCase.execute(cursor: nil, pageSize: 20) + fetchNoticesUseCase.execute(id: nil, pageSize: 20) .map { paged in .setAlarms(paged.items, hasMore: paged.hasMore, reset: true) }, @@ -50,10 +50,10 @@ public final class AnnouncementReactor: Reactor { ]) case .loadMore: guard currentState.hasMore, !currentState.isLoading else { return .empty() } - let lastCursor = currentState.alarms.last?.date + let lastCursor = currentState.alarms.last?.id return .concat([ .just(.setLoading(true)), - fetchNoticesUseCase.execute(cursor: lastCursor, pageSize: 20) + fetchNoticesUseCase.execute(id: lastCursor, pageSize: 20) .map { paged in .setAlarms(paged.items, hasMore: paged.hasMore, reset: false) }, diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Announcement/AnnouncementViewController.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Announcement/AnnouncementViewController.swift index 4261d67c..d7f8a2fd 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Announcement/AnnouncementViewController.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Announcement/AnnouncementViewController.swift @@ -50,8 +50,12 @@ extension AnnouncementViewController { .distinctUntilChanged() .withUnretained(self) .observe(on: MainScheduler.instance) - .bind(onNext: { owner, _ in - owner.createDetailItem(items: reactor.currentState.alarms) + .bind(onNext: { owner, items in + owner.mainView.detailItemStackView.arrangedSubviews.forEach { + owner.mainView.detailItemStackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + owner.createDetailItem(items: items) }) .disposed(by: disposeBag) } diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventReactor.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventReactor.swift index 654c0dab..92b9ebce 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventReactor.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventReactor.swift @@ -45,8 +45,8 @@ public final class EventReactor: Reactor { switch action { case let .selectTab(index): let fetchObservable = (index == 0 - ? fetchOngoingEventsUseCase.execute(cursor: nil, pageSize: 20) - : fetchOutdatedEventsUseCase.execute(cursor: nil, pageSize: 20)) + ? fetchOngoingEventsUseCase.execute(id: nil, pageSize: 20) + : fetchOutdatedEventsUseCase.execute(id: nil, pageSize: 20)) .map { paged -> Mutation in .setAlarms(paged.items, hasMore: paged.hasMore, reset: true) } @@ -64,11 +64,11 @@ public final class EventReactor: Reactor { case .loadMore: guard currentState.hasMore, !currentState.isLoading else { return .empty() } - let lastCursor = currentState.alarms.last?.date + let lastCursor = currentState.alarms.last?.id return .concat([ .just(.setLoading(true)), - (currentState.selectedIndex == 0 ? fetchOngoingEventsUseCase.execute(cursor: lastCursor, pageSize: 20) : fetchOutdatedEventsUseCase.execute(cursor: lastCursor, pageSize: 20)) + (currentState.selectedIndex == 0 ? fetchOngoingEventsUseCase.execute(id: lastCursor, pageSize: 20) : fetchOutdatedEventsUseCase.execute(id: lastCursor, pageSize: 20)) .map { paged in .setAlarms(paged.items, hasMore: paged.hasMore, reset: false) }, diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventViewController.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventViewController.swift index f49ec0cb..146c91c1 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventViewController.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventViewController.swift @@ -32,8 +32,6 @@ final class EventViewController: CustomerSupportBaseViewController, View { mainView.menuStackView.addArrangedSubview(endedButton) mainView.setupSpacerView() - reactor?.action.onNext(.selectTab(0)) - guard let reactor = reactor else { return } mainView.menuStackView.arrangedSubviews .compactMap { $0 as? UIButton } @@ -49,6 +47,7 @@ final class EventViewController: CustomerSupportBaseViewController, View { // MARK: - Bind extension EventViewController { func bind(reactor: Reactor) { + reactor.action.onNext(.selectTab(0)) bindViewState(reactor: reactor) } diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/PatchNote/PatchNoteReactor.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/PatchNote/PatchNoteReactor.swift index 465827d0..8aaa10c0 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/PatchNote/PatchNoteReactor.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/PatchNote/PatchNoteReactor.swift @@ -42,7 +42,7 @@ public final class PatchNoteReactor: Reactor { case .viewWillAppear: return .concat([ .just(.setLoading(true)), - fetchPatchNotesUseCase.execute(cursor: nil, pageSize: 20) + fetchPatchNotesUseCase.execute(id: nil, pageSize: 20) .map { paged in .setAlarms(paged.items, hasMore: paged.hasMore, reset: true) }, @@ -50,10 +50,10 @@ public final class PatchNoteReactor: Reactor { ]) case .loadMore: guard currentState.hasMore, !currentState.isLoading else { return .empty() } - let lastCursor = currentState.alarms.last?.date + let lastCursor = currentState.alarms.last?.id return .concat([ .just(.setLoading(true)), - fetchPatchNotesUseCase.execute(cursor: lastCursor, pageSize: 20) + fetchPatchNotesUseCase.execute(id: lastCursor, pageSize: 20) .map { paged in .setAlarms(paged.items, hasMore: paged.hasMore, reset: false) }, diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/PatchNote/PatchNoteViewController.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/PatchNote/PatchNoteViewController.swift index 56397f22..3935c851 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/PatchNote/PatchNoteViewController.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/PatchNote/PatchNoteViewController.swift @@ -50,8 +50,12 @@ extension PatchNoteViewController { .distinctUntilChanged() .withUnretained(self) .observe(on: MainScheduler.instance) - .bind(onNext: { owner, _ in - owner.createDetailItem(items: reactor.currentState.alarms) + .bind(onNext: { owner, alarms in + owner.mainView.detailItemStackView.arrangedSubviews.forEach { + owner.mainView.detailItemStackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + owner.createDetailItem(items: alarms) }) .disposed(by: disposeBag) } diff --git a/MLS/Presentation/MyPageFeature/MyPageFeatureDemo/AppDelegate.swift b/MLS/Presentation/MyPageFeature/MyPageFeatureDemo/AppDelegate.swift index a643b7e0..d71869df 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeatureDemo/AppDelegate.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeatureDemo/AppDelegate.swift @@ -5,10 +5,7 @@ import UIKit import AuthFeatureInterface import BaseFeature import Core -import Data -import DataMock import DesignSystem -import Domain import DomainInterface import MyPageFeature import MyPageFeatureInterface @@ -54,91 +51,69 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD private extension AppDelegate { func registerDependencies() { - registerProvider() - registerRepository() registerUseCase() registerFactory() } - func registerProvider() { - DIContainer.register(type: NetworkProvider.self) { - NetworkProviderImpl() - } - DIContainer.register(type: Interceptor.self) { - TokenInterceptor(fetchTokenUseCase: DIContainer.resolve(type: FetchTokenFromLocalUseCase.self)) - } - } - - func registerRepository() { - DIContainer.register(type: TokenRepository.self) { - KeyChainRepositoryImpl() - } - DIContainer.register(type: AuthAPIRepository.self) { - AuthAPIRepositoryImpl(provider: DIContainer.resolve(type: NetworkProvider.self), interceptor: TokenInterceptor(fetchTokenUseCase: DIContainer.resolve(type: FetchTokenFromLocalUseCase.self))) - } - DIContainer.register(type: AlarmAPIRepository.self) { - AlarmAPIRepositoryImpl(provider: DIContainer.resolve(type: NetworkProvider.self), interceptor: DIContainer.resolve(type: Interceptor.self)) - } - } - func registerUseCase() { - DIContainer.register(type: CheckNickNameUseCase.self) { - CheckNickNameUseCaseImpl() - } - DIContainer.register(type: CheckEmptyLevelAndRoleUseCase.self) { - CheckEmptyLevelAndRoleUseCaseImpl() - } - DIContainer.register(type: CheckValidLevelUseCase.self) { - CheckValidLevelUseCaseImpl() - } - DIContainer.register(type: FetchJobListUseCase.self) { - FetchJobListUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) - } - DIContainer.register(type: UpdateUserInfoUseCase.self) { - UpdateUserInfoUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) - } - DIContainer.register(type: UpdateNickNameUseCase.self) { - UpdateNickNameUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) - } - DIContainer.register(type: LogoutUseCase.self) { - LogoutUseCaseImpl(repository: DIContainer.resolve(type: TokenRepository.self)) - } - DIContainer.register(type: WithdrawUseCase.self) { - WithdrawUseCaseImpl(authRepository: DIContainer.resolve(type: AuthAPIRepository.self), tokenRepository: DIContainer.resolve(type: TokenRepository.self)) - } - DIContainer.register(type: FetchTokenFromLocalUseCase.self) { - FetchTokenFromLocalUseCaseImpl(repository: DIContainer.resolve(type: TokenRepository.self)) - } - DIContainer.register(type: FetchNoticesUseCase.self) { - FetchNoticesUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) - } - DIContainer.register(type: FetchOngoingEventsUseCase.self) { - FetchOngoingEventsUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) - } - DIContainer.register(type: FetchOutdatedEventsUseCase.self) { - FetchOutdatedEventsUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) - } - DIContainer.register(type: FetchPatchNotesUseCase.self) { - FetchPatchNotesUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) - } - DIContainer.register(type: SetReadUseCase.self) { - SetReadUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) - } - DIContainer.register(type: CheckNotificationPermissionUseCase.self) { - CheckNotificationPermissionUseCaseImpl() - } - DIContainer.register(type: UpdateNotificationAgreementUseCase.self) { - UpdateNotificationAgreementUseCaseImpl(authRepository: DIContainer.resolve(type: AuthAPIRepository.self)) - } - DIContainer.register(type: UpdateProfileImageUseCase.self) { - UpdateProfileImageUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) - } - DIContainer.register(type: FetchJobUseCase.self) { - FetchJobUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) - } - DIContainer.register(type: FetchProfileUseCase.self) { - FetchProfileUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self), fetchJobUseCase: DIContainer.resolve(type: FetchJobUseCase.self)) - } + /// 테스트 코드에 맞게 등록 +// DIContainer.register(type: CheckNickNameUseCase.self) { +// CheckNickNameUseCaseImpl() +// } +// DIContainer.register(type: CheckEmptyLevelAndRoleUseCase.self) { +// CheckEmptyLevelAndRoleUseCaseImpl() +// } +// DIContainer.register(type: CheckValidLevelUseCase.self) { +// CheckValidLevelUseCaseImpl() +// } +// DIContainer.register(type: FetchJobListUseCase.self) { +// FetchJobListUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) +// } +// DIContainer.register(type: UpdateUserInfoUseCase.self) { +// UpdateUserInfoUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) +// } +// DIContainer.register(type: UpdateNickNameUseCase.self) { +// UpdateNickNameUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) +// } +// DIContainer.register(type: LogoutUseCase.self) { +// LogoutUseCaseImpl(repository: DIContainer.resolve(type: TokenRepository.self)) +// } +// DIContainer.register(type: WithdrawUseCase.self) { +// WithdrawUseCaseImpl(authRepository: DIContainer.resolve(type: AuthAPIRepository.self), tokenRepository: DIContainer.resolve(type: TokenRepository.self)) +// } +// DIContainer.register(type: FetchTokenFromLocalUseCase.self) { +// FetchTokenFromLocalUseCaseImpl(repository: DIContainer.resolve(type: TokenRepository.self)) +// } +// DIContainer.register(type: FetchNoticesUseCase.self) { +// FetchNoticesUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) +// } +// DIContainer.register(type: FetchOngoingEventsUseCase.self) { +// FetchOngoingEventsUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) +// } +// DIContainer.register(type: FetchOutdatedEventsUseCase.self) { +// FetchOutdatedEventsUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) +// } +// DIContainer.register(type: FetchPatchNotesUseCase.self) { +// FetchPatchNotesUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) +// } +// DIContainer.register(type: SetReadUseCase.self) { +// SetReadUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) +// } +// DIContainer.register(type: CheckNotificationPermissionUseCase.self) { +// CheckNotificationPermissionUseCaseImpl() +// } +// DIContainer.register(type: UpdateNotificationAgreementUseCase.self) { +// UpdateNotificationAgreementUseCaseImpl(authRepository: DIContainer.resolve(type: AuthAPIRepository.self)) +// } +// DIContainer.register(type: UpdateProfileImageUseCase.self) { +// UpdateProfileImageUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) +// } +// DIContainer.register(type: FetchJobUseCase.self) { +// FetchJobUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) +// } +// DIContainer.register(type: FetchProfileUseCase.self) { +// FetchProfileUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self), fetchJobUseCase: DIContainer.resolve(type: FetchJobUseCase.self)) +// } } func registerFactory() { @@ -147,7 +122,7 @@ private extension AppDelegate { } DIContainer.register(type: SetProfileFactory.self) { - SetProfileFactoryImpl(selectImageFactory: DIContainer.resolve(type: SelectImageFactory.self), checkNickNameUseCase: DIContainer.resolve(type: CheckNickNameUseCase.self), updateNickNameUseCase: DIContainer.resolve(type: UpdateNickNameUseCase.self), logoutUseCase: DIContainer.resolve(type: LogoutUseCase.self), withdrawUseCase: DIContainer.resolve(type: WithdrawUseCase.self)) + SetProfileFactoryImpl(selectImageFactory: DIContainer.resolve(type: SelectImageFactory.self), checkNickNameUseCase: DIContainer.resolve(type: CheckNickNameUseCase.self), updateNickNameUseCase: DIContainer.resolve(type: UpdateNickNameUseCase.self), logoutUseCase: DIContainer.resolve(type: LogoutUseCase.self), withdrawUseCase: DIContainer.resolve(type: WithdrawUseCase.self), fetchProfileUseCase: DIContainer.resolve(type: FetchProfileUseCase.self)) } DIContainer.register(type: SetCharacterFactory.self) { @@ -177,14 +152,15 @@ private extension AppDelegate { } DIContainer.register(type: CustomerSupportFactory.self) { - CustomerSupportBaseViewFactoryImpl(fetchNoticesUseCase: DIContainer.resolve(type: FetchNoticesUseCase.self), fetchOngoingEventsUseCase: DIContainer.resolve(type: FetchOngoingEventsUseCase.self), fetchOutdatedEventsUseCase: DIContainer.resolve(type: FetchOutdatedEventsUseCase.self), fetchPatchNotesUseCase: DIContainer.resolve(type: FetchPatchNotesUseCase.self), setReadUseCase: DIContainer.resolve(type: SetReadUseCase.self)) + CustomerSupportBaseViewFactoryImpl(policyFactory: DIContainer.resolve(type: PolicyFactory.self), fetchNoticesUseCase: DIContainer.resolve(type: FetchNoticesUseCase.self), fetchOngoingEventsUseCase: DIContainer.resolve(type: FetchOngoingEventsUseCase.self), fetchOutdatedEventsUseCase: DIContainer.resolve(type: FetchOutdatedEventsUseCase.self), fetchPatchNotesUseCase: DIContainer.resolve(type: FetchPatchNotesUseCase.self), setReadUseCase: DIContainer.resolve(type: SetReadUseCase.self)) } DIContainer.register(type: NotificationSettingFactory.self) { NotificationSettingFactoryImpl(checkNotificationPermissionUseCase: DIContainer.resolve(type: CheckNotificationPermissionUseCase.self), updateNotificationAgreementUseCase: DIContainer.resolve(type: UpdateNotificationAgreementUseCase.self)) } + DIContainer.register(type: LoginFactory.self) { - LoginFactoryMock() + MockLoginFactory() } } } diff --git a/MLS/Presentation/MyPageFeature/MyPageFeatureDemo/Mock/MockLoginFactory.swift b/MLS/Presentation/MyPageFeature/MyPageFeatureDemo/Mock/MockLoginFactory.swift new file mode 100644 index 00000000..ff98072c --- /dev/null +++ b/MLS/Presentation/MyPageFeature/MyPageFeatureDemo/Mock/MockLoginFactory.swift @@ -0,0 +1,10 @@ +import AuthFeatureInterface +import BaseFeature + +public final class MockLoginFactory: LoginFactory { + public func make(exitRoute: LoginExitRoute, onLoginCompleted: (() -> Void)?) -> BaseViewController { + let viewcontroller = BaseViewController() + viewcontroller.view.backgroundColor = .redMLS + return viewcontroller + } +} diff --git a/MLS/Presentation/MyPageFeature/MyPageFeatureDemo/ViewController.swift b/MLS/Presentation/MyPageFeature/MyPageFeatureDemo/ViewController.swift index 6d46746a..d1f3a414 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeatureDemo/ViewController.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeatureDemo/ViewController.swift @@ -33,7 +33,7 @@ class ViewController: UIViewController { ]) let notiView = BottomTabBarController(viewControllers: [ - DIContainer.resolve(type: NotificationSettingFactory.self).make() + DIContainer.resolve(type: NotificationSettingFactory.self).make(isAgreeEventNotification: false, isAgreeNoticeNotification: false, isAgreePatchNoteNotification: false) ]) mainView.title = "마이페이지 메인"