diff --git a/MLS/MLS.xcodeproj/project.pbxproj b/MLS/MLS.xcodeproj/project.pbxproj index c59a4da2..250c07a5 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, ); }; }; @@ -135,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 = ""; }; @@ -159,6 +163,13 @@ ); 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 = ( @@ -177,6 +188,14 @@ 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; @@ -228,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; @@ -330,6 +359,7 @@ 087D3EEA2DA7972C002F924D /* MLS */, 77BEB0412DBA84B0002FFCFC /* MLSTests */, 77FA687B2F72C7360064B6EB /* MLSDesignSystemExample */, + 08F7A9242F86745C00EF5C06 /* MLSAuthFeatureExample */, 084A25312DB93A5400C395C0 /* Frameworks */, 087D3EE92DA7972C002F924D /* Products */, ); @@ -341,6 +371,7 @@ 087D3EE82DA7972C002F924D /* MLS.app */, 77BEB0402DBA84B0002FFCFC /* MLSTests.xctest */, 77FA687A2F72C7360064B6EB /* MLSDesignSystemExample.app */, + 08F7A9232F86745C00EF5C06 /* MLSAuthFeatureExample.app */, ); name = Products; sourceTree = ""; @@ -383,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" */; @@ -446,6 +502,9 @@ 087D3EE72DA7972C002F924D = { CreatedOnToolsVersion = 16.2; }; + 08F7A9222F86745C00EF5C06 = { + CreatedOnToolsVersion = 26.1.1; + }; 77BEB03F2DBA84B0002FFCFC = { CreatedOnToolsVersion = 16.2; TestTargetID = 087D3EE72DA7972C002F924D; @@ -507,6 +566,7 @@ 087D3EE72DA7972C002F924D /* MLS */, 77BEB03F2DBA84B0002FFCFC /* MLSTests */, 77FA68792F72C7360064B6EB /* MLSDesignSystemExample */, + 08F7A9222F86745C00EF5C06 /* MLSAuthFeatureExample */, ); }; /* End PBXProject section */ @@ -522,6 +582,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 08F7A9212F86745C00EF5C06 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 77BEB03E2DBA84B0002FFCFC /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -567,6 +634,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 08F7A91F2F86745C00EF5C06 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 77BEB03C2DBA84B0002FFCFC /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -795,6 +869,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 = { @@ -924,6 +1064,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 = ( @@ -1034,6 +1183,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" */; diff --git a/MLS/MLS.xcworkspace/contents.xcworkspacedata b/MLS/MLS.xcworkspace/contents.xcworkspacedata index c5c30238..99fde6a0 100644 --- a/MLS/MLS.xcworkspace/contents.xcworkspacedata +++ b/MLS/MLS.xcworkspace/contents.xcworkspacedata @@ -36,11 +36,14 @@ location = "group:Core/Core.xcodeproj"> - - - + location = "group:MLSDesignSystem"> + + + + + 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/Package.swift b/MLS/MLSCore/Package.swift index 4910c6a6..57a153fb 100644 --- a/MLS/MLSCore/Package.swift +++ b/MLS/MLSCore/Package.swift @@ -14,7 +14,8 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.7.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") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -24,8 +25,10 @@ let package = Package( dependencies: [ .product(name: "RxSwift", package: "RxSwift"), .product(name: "RxCocoa", package: "RxSwift"), - .product(name: "RxRelay", package: "RxSwift") - ] + .product(name: "RxRelay", package: "RxSwift"), + .product(name: "RxKeyboard", package: "RxKeyboard") + ], + swiftSettings: [.swiftLanguageMode(.v5)] ), .testTarget( name: "MLSCoreTests", diff --git a/MLS/MLSCore/Sources/MLSCore/BaseController/BaseViewController.swift b/MLS/MLSCore/Sources/MLSCore/BaseController/BaseViewController.swift index aa5e7877..d8f66210 100644 --- a/MLS/MLSCore/Sources/MLSCore/BaseController/BaseViewController.swift +++ b/MLS/MLSCore/Sources/MLSCore/BaseController/BaseViewController.swift @@ -4,12 +4,14 @@ import UIKit import RxKeyboard import RxSwift -open class BaseViewController: UIViewController { +open class BaseViewController: UIViewController, Loggable { private let disposeBag = DisposeBag() + public var isBottomTabbarHidden: Bool = false + public init() { super.init(nibName: nil, bundle: nil) - os_log("➕init: \(String(describing: self))") + logDebug("init \(String(describing: self))") } @available(*, unavailable) @@ -18,7 +20,7 @@ open class BaseViewController: UIViewController { } deinit { - os_log("➖deinit: \(String(describing: self))") + logDebug("deinit: \(String(describing: self))") } } 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) + } + } + } +}