diff --git a/MLS/MLS.xcodeproj/project.pbxproj b/MLS/MLS.xcodeproj/project.pbxproj index 95e87f02..46e458d9 100644 --- a/MLS/MLS.xcodeproj/project.pbxproj +++ b/MLS/MLS.xcodeproj/project.pbxproj @@ -37,11 +37,14 @@ 08F7DC822F9DEA8100EF5C06 /* MLSRecommendationFeatureInterface in Frameworks */ = {isa = PBXBuildFile; productRef = 08F7DC832F9DEA8100EF5C06 /* MLSRecommendationFeatureInterface */; }; 08F7DC842F9DEA8100EF5C06 /* MLSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 08F7DC852F9DEA8100EF5C06 /* MLSCore */; }; 770ADB1F2E433EDA00270506 /* RxKeyboard in Frameworks */ = {isa = PBXBuildFile; productRef = 770ADB1E2E433EDA00270506 /* RxKeyboard */; }; + 77217FD42F9A05D7000915EF /* MLSMyPageFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 77217FD32F9A05D7000915EF /* MLSMyPageFeature */; }; 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, ); }; }; 7721A5042E0EE7AE00A7B58C /* BaseFeature.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7721A5032E0EE7AE00A7B58C /* BaseFeature.framework */; }; 7721A5052E0EE7AE00A7B58C /* BaseFeature.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 7721A5032E0EE7AE00A7B58C /* BaseFeature.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 7721A5082E0EE7F100A7B58C /* ReactorKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7721A5072E0EE7F100A7B58C /* ReactorKit */; }; + 7736814E2F9A2CE3002DC773 /* MLSMyPageFeatureInterface in Frameworks */ = {isa = PBXBuildFile; productRef = 7736814D2F9A2CE3002DC773 /* MLSMyPageFeatureInterface */; }; + 773681502F9A2CE3002DC773 /* MLSMyPageFeatureTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 7736814F2F9A2CE3002DC773 /* MLSMyPageFeatureTesting */; }; 77660AD22DD0D361007A4EF3 /* KakaoConfig.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 77660AD12DD0D361007A4EF3 /* KakaoConfig.xcconfig */; }; 77660AD52DD0D3DD007A4EF3 /* KakaoSDKAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 77660AD42DD0D3DD007A4EF3 /* KakaoSDKAuth */; }; 77660AD72DD0D3DD007A4EF3 /* KakaoSDKUser in Frameworks */ = {isa = PBXBuildFile; productRef = 77660AD62DD0D3DD007A4EF3 /* KakaoSDKUser */; }; @@ -60,6 +63,7 @@ 77A293312F79989200845081 /* DesignSystem.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 77A293302F79989200845081 /* DesignSystem.framework */; }; 77A293322F79989200845081 /* DesignSystem.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 77A293302F79989200845081 /* DesignSystem.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 77B1F9952EE06A4E00AE4B4D /* RxGesture in Frameworks */ = {isa = PBXBuildFile; productRef = 77B1F9942EE06A4E00AE4B4D /* RxGesture */; }; + 77DE6DEF2F9F9EA1007FD8AC /* MLSAuthFeatureTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 77DE6DEE2F9F9EA1007FD8AC /* MLSAuthFeatureTesting */; }; 77E260412EEABEC40059E889 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 77E260402EEABEC40059E889 /* Settings.bundle */; }; 77EB18D62DED9256004FB380 /* AuthFeature.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 77EB18D52DED9256004FB380 /* AuthFeature.framework */; }; 77EB18D72DED9256004FB380 /* AuthFeature.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 77EB18D52DED9256004FB380 /* AuthFeature.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -142,6 +146,7 @@ 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; }; + 77217FBE2F9A04CF000915EF /* MLSMyPageFeatureExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLSMyPageFeatureExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 08F7DC612F9DEA8000EF5C06 /* MLSRecommendationFeatureExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLSRecommendationFeatureExample.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; }; @@ -174,12 +179,20 @@ ); target = 08F7A9222F86745C00EF5C06 /* MLSAuthFeatureExample */; }; - 08F7DC722F9DEA8100EF5C06 /* Exceptions for "MLSRecommendationFeatureExample" folder in "MLSRecommendationFeatureExample" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = 08F7DC602F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */; + 77217FCF2F9A04D0000915EF /* Exceptions for "MLSMyPageFeatureExample" folder in "MLSMyPageFeatureExample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 77217FBD2F9A04CF000915EF /* MLSMyPageFeatureExample */; + }; + + 08F7DC722F9DEA8100EF5C06 /* Exceptions for "MLSRecommendationFeatureExample" folder in "MLSRecommendationFeatureExample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 08F7DC602F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */; }; 77FA688B2F72C7380064B6EB /* Exceptions for "MLSDesignSystemExample" folder in "MLSDesignSystemExample" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; @@ -207,6 +220,14 @@ path = MLSAuthFeatureExample; sourceTree = ""; }; + 77217FBF2F9A04CF000915EF /* MLSMyPageFeatureExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 77217FCF2F9A04D0000915EF /* Exceptions for "MLSMyPageFeatureExample" folder in "MLSMyPageFeatureExample" target */, + ); + path = MLSMyPageFeatureExample; + sourceTree = ""; + }; 08F7DC622F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -276,6 +297,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 77217FBB2F9A04CF000915EF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 77217FD42F9A05D7000915EF /* MLSMyPageFeature in Frameworks */, + 77DE6DEF2F9F9EA1007FD8AC /* MLSAuthFeatureTesting in Frameworks */, + 773681502F9A2CE3002DC773 /* MLSMyPageFeatureTesting in Frameworks */, + 7736814E2F9A2CE3002DC773 /* MLSMyPageFeatureInterface in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 08F7DC5E2F9DEA8000EF5C06 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -389,6 +421,7 @@ 77BEB0412DBA84B0002FFCFC /* MLSTests */, 77FA687B2F72C7360064B6EB /* MLSDesignSystemExample */, 08F7A9242F86745C00EF5C06 /* MLSAuthFeatureExample */, + 77217FBF2F9A04CF000915EF /* MLSMyPageFeatureExample */, 08F7DC622F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */, 084A25312DB93A5400C395C0 /* Frameworks */, 087D3EE92DA7972C002F924D /* Products */, @@ -402,6 +435,7 @@ 77BEB0402DBA84B0002FFCFC /* MLSTests.xctest */, 77FA687A2F72C7360064B6EB /* MLSDesignSystemExample.app */, 08F7A9232F86745C00EF5C06 /* MLSAuthFeatureExample.app */, + 77217FBE2F9A04CF000915EF /* MLSMyPageFeatureExample.app */, 08F7DC612F9DEA8000EF5C06 /* MLSRecommendationFeatureExample.app */, ); name = Products; @@ -470,31 +504,57 @@ productReference = 08F7A9232F86745C00EF5C06 /* MLSAuthFeatureExample.app */; productType = "com.apple.product-type.application"; }; - 08F7DC602F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */ = { - isa = PBXNativeTarget; - buildConfigurationList = 08F7DC732F9DEA8100EF5C06 /* Build configuration list for PBXNativeTarget "MLSRecommendationFeatureExample" */; - buildPhases = ( - 08F7DC5D2F9DEA8000EF5C06 /* Sources */, - 08F7DC5E2F9DEA8000EF5C06 /* Frameworks */, - 08F7DC5F2F9DEA8000EF5C06 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 08F7DC622F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */, - ); - name = MLSRecommendationFeatureExample; - packageProductDependencies = ( - 08F7DC812F9DEA8100EF5C06 /* MLSRecommendationFeature */, - 08F7DC832F9DEA8100EF5C06 /* MLSRecommendationFeatureInterface */, - 08F7DC852F9DEA8100EF5C06 /* MLSCore */, - ); - productName = MLSRecommendationFeatureExample; - productReference = 08F7DC612F9DEA8000EF5C06 /* MLSRecommendationFeatureExample.app */; - productType = "com.apple.product-type.application"; - }; + 77217FBD2F9A04CF000915EF /* MLSMyPageFeatureExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 77217FD02F9A04D0000915EF /* Build configuration list for PBXNativeTarget "MLSMyPageFeatureExample" */; + buildPhases = ( + 77217FBA2F9A04CF000915EF /* Sources */, + 77217FBB2F9A04CF000915EF /* Frameworks */, + 77217FBC2F9A04CF000915EF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 77217FBF2F9A04CF000915EF /* MLSMyPageFeatureExample */, + ); + name = MLSMyPageFeatureExample; + packageProductDependencies = ( + 77217FD32F9A05D7000915EF /* MLSMyPageFeature */, + 7736814D2F9A2CE3002DC773 /* MLSMyPageFeatureInterface */, + 7736814F2F9A2CE3002DC773 /* MLSMyPageFeatureTesting */, + 77DE6DEE2F9F9EA1007FD8AC /* MLSAuthFeatureTesting */, + ); + productName = MLSMyPageFeatureExample; + productReference = 77217FBE2F9A04CF000915EF /* MLSMyPageFeatureExample.app */; + productType = "com.apple.product-type.application"; + }; + 08F7DC602F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 08F7DC732F9DEA8100EF5C06 /* Build configuration list for PBXNativeTarget "MLSRecommendationFeatureExample" */; + buildPhases = ( + 08F7DC5D2F9DEA8000EF5C06 /* Sources */, + 08F7DC5E2F9DEA8000EF5C06 /* Frameworks */, + 08F7DC5F2F9DEA8000EF5C06 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 08F7DC622F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */, + ); + name = MLSRecommendationFeatureExample; + packageProductDependencies = ( + 08F7DC812F9DEA8100EF5C06 /* MLSRecommendationFeature */, + 08F7DC832F9DEA8100EF5C06 /* MLSRecommendationFeatureInterface */, + 08F7DC852F9DEA8100EF5C06 /* MLSCore */, + ); + productName = MLSRecommendationFeatureExample; + productReference = 08F7DC612F9DEA8000EF5C06 /* MLSRecommendationFeatureExample.app */; + productType = "com.apple.product-type.application"; + }; 77BEB03F2DBA84B0002FFCFC /* MLSTests */ = { isa = PBXNativeTarget; buildConfigurationList = 77BEB0482DBA84B0002FFCFC /* Build configuration list for PBXNativeTarget "MLSTests" */; @@ -561,6 +621,9 @@ 08F7A9222F86745C00EF5C06 = { CreatedOnToolsVersion = 26.1.1; }; + 77217FBD2F9A04CF000915EF = { + CreatedOnToolsVersion = 26.1.1; + }; 08F7DC602F9DEA8000EF5C06 = { CreatedOnToolsVersion = 26.1.1; }; @@ -626,6 +689,7 @@ 77BEB03F2DBA84B0002FFCFC /* MLSTests */, 77FA68792F72C7360064B6EB /* MLSDesignSystemExample */, 08F7A9222F86745C00EF5C06 /* MLSAuthFeatureExample */, + 77217FBD2F9A04CF000915EF /* MLSMyPageFeatureExample */, 08F7DC602F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */, ); }; @@ -649,6 +713,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 77217FBC2F9A04CF000915EF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 08F7DC5F2F9DEA8000EF5C06 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -708,6 +779,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 77217FBA2F9A04CF000915EF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 08F7DC5D2F9DEA8000EF5C06 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -957,10 +1035,9 @@ 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; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -969,12 +1046,16 @@ PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLSAuthFeatureExample; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -990,10 +1071,9 @@ 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; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1002,12 +1082,88 @@ PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLSAuthFeatureExample; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 77217FD12F9A04D0000915EF /* 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 = MLSMyPageFeatureExample/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLSDesignSystemExample.MLSMyPageFeatureExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 77217FD22F9A04D0000915EF /* 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 = MLSMyPageFeatureExample/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLSDesignSystemExample.MLSMyPageFeatureExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; @@ -1231,6 +1387,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 77217FD02F9A04D0000915EF /* Build configuration list for PBXNativeTarget "MLSMyPageFeatureExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 77217FD12F9A04D0000915EF /* Debug */, + 77217FD22F9A04D0000915EF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 08F7DC732F9DEA8100EF5C06 /* Build configuration list for PBXNativeTarget "MLSRecommendationFeatureExample" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1379,11 +1544,23 @@ package = 770ADB1D2E433EDA00270506 /* XCRemoteSwiftPackageReference "RxKeyboard" */; productName = RxKeyboard; }; + 77217FD32F9A05D7000915EF /* MLSMyPageFeature */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSMyPageFeature; + }; 7721A5072E0EE7F100A7B58C /* ReactorKit */ = { isa = XCSwiftPackageProductDependency; package = 7721A5062E0EE7F100A7B58C /* XCRemoteSwiftPackageReference "ReactorKit" */; productName = ReactorKit; }; + 7736814D2F9A2CE3002DC773 /* MLSMyPageFeatureInterface */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSMyPageFeatureInterface; + }; + 7736814F2F9A2CE3002DC773 /* MLSMyPageFeatureTesting */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSMyPageFeatureTesting; + }; 77660AD42DD0D3DD007A4EF3 /* KakaoSDKAuth */ = { isa = XCSwiftPackageProductDependency; package = 77660AD32DD0D3DD007A4EF3 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */; @@ -1399,6 +1576,10 @@ package = 77B1F9932EE06A4E00AE4B4D /* XCRemoteSwiftPackageReference "RxGesture" */; productName = RxGesture; }; + 77DE6DEE2F9F9EA1007FD8AC /* MLSAuthFeatureTesting */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSAuthFeatureTesting; + }; 77FA68B72F72C9C10064B6EB /* RxCocoa */ = { isa = XCSwiftPackageProductDependency; package = 08ED49202DCFDE9C002C21A2 /* XCRemoteSwiftPackageReference "RxSwift" */; diff --git a/MLS/MLS.xcworkspace/contents.xcworkspacedata b/MLS/MLS.xcworkspace/contents.xcworkspacedata index f7ebda0c..c889074c 100644 --- a/MLS/MLS.xcworkspace/contents.xcworkspacedata +++ b/MLS/MLS.xcworkspace/contents.xcworkspacedata @@ -35,9 +35,6 @@ - - @@ -50,4 +47,10 @@ + + + + diff --git a/MLS/MLS.xcworkspace/xcshareddata/swiftpm/Package.resolved b/MLS/MLS.xcworkspace/xcshareddata/swiftpm/Package.resolved index 386ec176..00da3d9a 100644 --- a/MLS/MLS.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/MLS/MLS.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "bfbb1a7b185edd3389821cec79c508d208318809d55699ccbc2cee82e1a49c3d", + "originHash" : "77b034e581c0b3380d1189e9436d587eda4c5fe2ed62f75f4c83882cd5ab8a4c", "pins" : [ { "identity" : "abseil-cpp-binary", diff --git a/MLS/MLSCore/Sources/MLSCore/BaseController/WebViewController.swift b/MLS/MLSCore/Sources/MLSCore/BaseController/WebViewController.swift new file mode 100644 index 00000000..08e4128e --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/BaseController/WebViewController.swift @@ -0,0 +1,10 @@ +import SafariServices +import UIKit + +public final class WebViewController { + + public static func make(urlString: String) -> SFSafariViewController? { + guard let url = URL(string: urlString) else { return nil } + return SFSafariViewController(url: url) + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/Extension/String+.swift b/MLS/MLSCore/Sources/MLSCore/Extension/String+.swift new file mode 100644 index 00000000..72f0b89c --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Extension/String+.swift @@ -0,0 +1,36 @@ +import Foundation + +public extension String { + func isOnlyKorean() -> Bool { + return !self.isEmpty && + self.allSatisfy { char in + char.unicodeScalars.allSatisfy { scalar in + switch scalar.value { + case 0x30...0x39: + return true + case 0xac00...0xd7a3: + return true + case 0x3131...0x3163: + return true + default: + return false + } + } + } + } + + func toDisplayDateString() -> String { + let inputFormatter = DateFormatter() + inputFormatter.locale = Locale(identifier: "ko_KR") + inputFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") + inputFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + + guard let date = inputFormatter.date(from: self) else { return self } + + let outputFormatter = DateFormatter() + outputFormatter.locale = Locale(identifier: "ko_KR") + outputFormatter.dateFormat = "yyyy.MM.dd HH:mm" + + return outputFormatter.string(from: date) + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/CompositionalLayoutBuilder/CompositionalLayoutBuilder.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/CompositionalLayoutBuilder.swift similarity index 100% rename from MLS/MLSCore/Sources/MLSCore/CompositionalLayoutBuilder/CompositionalLayoutBuilder.swift rename to MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/CompositionalLayoutBuilder.swift diff --git a/MLS/MLSCore/Sources/MLSCore/CompositionalLayoutBuilder/CompositionalSectionBuilder.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/CompositionalSectionBuilder.swift similarity index 100% rename from MLS/MLSCore/Sources/MLSCore/CompositionalLayoutBuilder/CompositionalSectionBuilder.swift rename to MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/CompositionalSectionBuilder.swift diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/LayoutFactory.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/LayoutFactory.swift new file mode 100644 index 00000000..a2c3a549 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/LayoutFactory.swift @@ -0,0 +1,158 @@ +import UIKit + +@MainActor +public class LayoutFactory { + public init() {} + + public static func getPageTabbarLayout(underLineController: TabBarUnderlineController? = nil) -> CompositionalSectionBuilder { + return CompositionalSectionBuilder() + .item(width: .estimated(100), height: .absolute(40)) + .group(.horizontal, width: .estimated(100), height: .absolute(40)) + .buildSection() + .orthogonalScrolling(.continuous) + .interGroupSpacing(28) + .contentInsets(.init(top: 0, leading: 16, bottom: 0, trailing: 16)) + .visibleItemsInvalidationHandler { _, offset, _ in + underLineController?.updateScrollOffset(offset) + } + } + + public static func getItemTagListSection(width: CGFloat = 50) -> CompositionalSectionBuilder { + return CompositionalSectionBuilder() + .item(width: .estimated(width), height: .absolute(34)) + .group(.horizontal, width: .fractionalWidth(1), height: .absolute(34)) + .interItemSpacing(.fixed(8)) + .buildSection() + .header(height: 22) + .interGroupSpacing(8) + .contentInsets(.init(top: 12, leading: 16, bottom: 32, trailing: 16)) + } + + public static func getLevelRangeSection() -> CompositionalSectionBuilder { + return CompositionalSectionBuilder() + .item(width: .fractionalWidth(1), height: .estimated(100)) + .group(.horizontal, width: .fractionalWidth(1), height: .estimated(100)) + .buildSection() + .header(height: 22) + .contentInsets(.init(top: 12, leading: 16, bottom: 32, trailing: 16)) + } + + public static func getDictionaryListLayout(isFilterHidden: Bool = true) -> CompositionalSectionBuilder { + return CompositionalSectionBuilder() + .item(width: .fractionalWidth(1.0), height: .absolute(104)) + .group(.horizontal, width: .fractionalWidth(1.0), height: .absolute(104)) + .buildSection() + .interGroupSpacing(10) + .contentInsets(.init(top: isFilterHidden ? 20 : 0, leading: 16, bottom: 0, trailing: 16)) + } + + public static func getTagChipLayout() -> CompositionalSectionBuilder { + return CompositionalSectionBuilder() + .item(width: .estimated(70), height: .estimated(32)) + .group(.horizontal, width: .estimated(70), height: .estimated(32)) + .buildSection() + .header(height: 44) + .orthogonalScrolling(.continuous) + .interGroupSpacing(8) + .contentInsets(.init(top: 24, leading: 16, bottom: 24, trailing: 16)) + } + + public static func getDecorationSection() -> CompositionalSectionBuilder { + return CompositionalSectionBuilder() + .item(width: .fractionalWidth(1.0), height: .absolute(1)) + .group(.vertical, width: .fractionalWidth(1.0), height: .absolute(10)) + .buildSection() + .decorationItem(kind: SearchDividerView.identifier) + .contentInsets(.init(top: 5, leading: 0, bottom: 5, trailing: 0)) + } + + public static func getPopularResultLayout() -> CompositionalSectionBuilder { + return CompositionalSectionBuilder() + .item(width: .fractionalWidth(1.0), height: .estimated(40)) + .group(.horizontal, width: .fractionalWidth(1.0), height: .estimated(40), count: 2) + .buildSection() + .header(height: 44) + .contentInsets(.init(top: 16, leading: 16, bottom: 16, trailing: 16)) + } + + public static func getNotificationLayout() -> CompositionalSectionBuilder { + return CompositionalSectionBuilder() + .item(width: .fractionalWidth(1.0), height: .estimated(86)) + .group(.vertical, width: .fractionalWidth(1.0), height: .estimated(86)) + .buildSection() + .interGroupSpacing(8) + .contentInsets(.init(top: 0, leading: 16, bottom: 0, trailing: 16)) + } + + public static func getCollectionModalLayout() -> CompositionalSectionBuilder { + return CompositionalSectionBuilder() + .item(width: .fractionalWidth(1.0), height: .absolute(72)) + .group(.vertical, width: .fractionalWidth(1.0), height: .absolute(72)) + .buildSection() + .interGroupSpacing(1) + } + + public static func getCollectionListLayout() -> CompositionalSectionBuilder { + return CompositionalSectionBuilder() + .item(width: .fractionalWidth(1.0), height: .absolute(96)) + .group(.vertical, width: .fractionalWidth(1.0), height: .absolute(96)) + .buildSection() + .interGroupSpacing(10) + .contentInsets(.init(top: 0, leading: 16, bottom: 0, trailing: 16)) + } + + public static func getCollectionListEditLayout() -> CompositionalSectionBuilder { + return CompositionalSectionBuilder() + .item(width: .fractionalWidth(1.0), height: .absolute(104)) + .group(.vertical, width: .fractionalWidth(1.0), height: .absolute(104)) + .buildSection() + .interGroupSpacing(10) + .contentInsets(.init(top: 20, leading: 16, bottom: 20, trailing: 16)) + } + + // 이 아래는 정리 + public func getMyPageMainLayout() -> CompositionalSectionBuilder { + return CompositionalSectionBuilder() + .item(width: .fractionalWidth(1.0), height: .estimated(200)) + .group(.vertical, width: .fractionalWidth(1.0), height: .estimated(200)) + .buildSection() + } + + public func getMyPageSettingLayout() -> CompositionalSectionBuilder { + return CompositionalSectionBuilder() + .item(width: .fractionalWidth(1.0), height: .absolute(50)) + .group(.vertical, width: .fractionalWidth(1.0), height: .estimated(100)) + .buildSection() + .decorationItem(kind: SettingBackgroundView.identifier) + .contentInsets(.init(top: 20 + 10, leading: 16 + 10, bottom: 10, trailing: 16 + 10)) + } + + public func getMyPageSupportLayout() -> CompositionalSectionBuilder { + return CompositionalSectionBuilder() + .item(width: .fractionalWidth(1.0), height: .absolute(50)) + .group(.vertical, width: .fractionalWidth(1.0), height: .estimated(100)) + .buildSection() + .decorationItem(kind: SupportBackgroundView.identifier) + .contentInsets(.init(top: 16 + 10, leading: 16 + 10, bottom: 20 + 10, trailing: 16 + 10)) + } + + public func getSelectImageLayout() -> CompositionalSectionBuilder { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0/3.0), + heightDimension: .fractionalWidth(1.0/3.0) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalWidth(1.0/3.0) + ) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, item, item]) + group.interItemSpacing = .fixed(16) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 16, bottom: 16, trailing: 16) + + return CompositionalSectionBuilder(section: section) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/DescriptionBackgroundView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/DescriptionBackgroundView.swift new file mode 100644 index 00000000..c6846274 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/DescriptionBackgroundView.swift @@ -0,0 +1,44 @@ +import UIKit + +final public class DescriptionBackgroundView: UICollectionReusableView { + // MARK: - Type + enum Constant { + static let horizontalInset: CGFloat = 16 + static let topInset: CGFloat = 60 + static let bottomInset: CGFloat = 20 + } + + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .whiteMLS + view.layer.cornerRadius = 16 + view.layer.masksToBounds = true + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .neutral200 + addViews() + setConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +private extension DescriptionBackgroundView { + private func addViews() { + addSubview(containerView) + } + + func setConstraints() { + containerView.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.topInset) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.bottom.equalToSuperview().inset(Constant.bottomInset) + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/Neutral100BackgroundView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/Neutral100BackgroundView.swift new file mode 100644 index 00000000..f25a4d51 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/Neutral100BackgroundView.swift @@ -0,0 +1,34 @@ +import UIKit + +final public class Neutral100BackgroundView: UICollectionReusableView { + // MARK: - Type + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .neutral100 + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .neutral200 + addViews() + setConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +private extension Neutral100BackgroundView { + private func addViews() { + addSubview(containerView) + } + + func setConstraints() { + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/Neutral200DividerView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/Neutral200DividerView.swift new file mode 100644 index 00000000..7c9ea6bf --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/Neutral200DividerView.swift @@ -0,0 +1,18 @@ +import UIKit + +final public class Neutral200DividerView: UICollectionReusableView { + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .neutral200 + layer.zPosition = -1 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) { + super.apply(layoutAttributes) + self.frame.size.height = 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/Neutral300DividerView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/Neutral300DividerView.swift new file mode 100644 index 00000000..49911c86 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/Neutral300DividerView.swift @@ -0,0 +1,18 @@ +import UIKit + +final public class Neutral300DividerView: UICollectionReusableView { + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .neutral300 + layer.zPosition = -1 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) { + super.apply(layoutAttributes) + self.frame.size.height = 1 + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/PopularSearchHeaderView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/PopularSearchHeaderView.swift new file mode 100644 index 00000000..5db2bcbb --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/PopularSearchHeaderView.swift @@ -0,0 +1,76 @@ +import UIKit + +import SnapKit + +public final class PopularSearchHeaderView: UICollectionReusableView { + // MARK: - Type + private enum Constant { + static let spacing: CGFloat = 4 + static let topInset: CGFloat = 24 + } + + // MARK: - Components + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + addViews() + setupConstraints() + configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MAKR: - SetUp +private extension PopularSearchHeaderView { + func addViews() { + addSubview(titleLabel) + addSubview(subtitleLabel) + } + + func setupConstraints() { + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.topInset) + make.leading.equalToSuperview() + } + + subtitleLabel.snp.makeConstraints { make in + make.leading.equalTo(titleLabel.snp.trailing).priority(.low) + make.trailing.equalToSuperview().priority(.high) + make.centerY.equalTo(titleLabel) + } + } + + func configureUI() { + titleLabel.font = .sub_l_b + titleLabel.textAlignment = .left + + subtitleLabel.font = .cp_s_r + subtitleLabel.textColor = .neutral500 + subtitleLabel.textAlignment = .left + } +} + +// MARK: - Methods +public extension PopularSearchHeaderView { + func inject(mainText: String, subText: String, hasRecent: Bool) { + titleLabel.text = mainText + subtitleLabel.text = subText + if hasRecent { + titleLabel.snp.remakeConstraints { make in + make.top.equalToSuperview().inset(Constant.topInset) + make.horizontalEdges.equalToSuperview() + } + } else { + titleLabel.snp.remakeConstraints { make in + make.top.equalToSuperview() + make.horizontalEdges.equalToSuperview() + } + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/RecentSearchHeaderView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/RecentSearchHeaderView.swift new file mode 100644 index 00000000..e0ac47ae --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/RecentSearchHeaderView.swift @@ -0,0 +1,62 @@ +import UIKit + +import SnapKit + +public final class RecentSearchHeaderView: UICollectionReusableView { + // MARK: - Components + private let titleLabel = UILabel() + public let deleteButton = UIButton() + private let spacer = UIView() + + // MARK: - Init + public override init(frame: CGRect) { + super.init(frame: frame) + addViews() + setupConstraints() + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Setup +private extension RecentSearchHeaderView { + func addViews() { + addSubview(titleLabel) + addSubview(spacer) + addSubview(deleteButton) + } + + func setupConstraints() { + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview() + make.centerY.equalToSuperview() + } + + spacer.snp.makeConstraints { make in + make.verticalEdges.equalToSuperview() + make.leading.equalTo(titleLabel.snp.trailing) + make.trailing.equalTo(deleteButton.snp.leading) + } + + deleteButton.snp.makeConstraints { make in + make.trailing.equalToSuperview() + make.centerY.equalToSuperview() + } + } + + func configureUI() { + titleLabel.attributedText = .makeStyledString( + font: .sub_m_b, + text: "최근 검색어", + alignment: .left + ) + + deleteButton.setTitle("모두 지우기", for: .normal) + deleteButton.titleLabel?.font = .b_s_r + deleteButton.setTitleColor(.neutral600, for: .normal) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SearchDividerView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SearchDividerView.swift new file mode 100644 index 00000000..013dc110 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SearchDividerView.swift @@ -0,0 +1,33 @@ +import UIKit + +import SnapKit + +final public class SearchDividerView: UICollectionReusableView { + let view: UIView = { + let view = UIView() + view.backgroundColor = .neutral100 + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + addViews() + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func addViews() { + addSubview(view) + } + + func setupConstraints() { + view.snp.makeConstraints { make in + make.centerY.horizontalEdges.equalToSuperview() + make.height.equalTo(10) + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SettingBackgroundView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SettingBackgroundView.swift new file mode 100644 index 00000000..55ad6e60 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SettingBackgroundView.swift @@ -0,0 +1,43 @@ +import UIKit + +final public class SettingBackgroundView: UICollectionReusableView { + // MARK: - Type + enum Constant { + static let radius: CGFloat = 16 + static let topInset: CGFloat = 20 + static let horizontalInset: CGFloat = 16 + } + + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .whiteMLS + view.layer.cornerRadius = Constant.radius + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .neutral100 + addViews() + setConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +private extension SettingBackgroundView { + private func addViews() { + addSubview(containerView) + } + + func setConstraints() { + containerView.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.topInset) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.bottom.equalToSuperview() + } + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SubTitleBoldHeaderView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SubTitleBoldHeaderView.swift new file mode 100644 index 00000000..f16f0468 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SubTitleBoldHeaderView.swift @@ -0,0 +1,40 @@ +import UIKit + +import SnapKit + +final public class SubTitleBoldHeaderView: UICollectionReusableView { + + private let headerLabel: UILabel = { + let label = UILabel() + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addViews() + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension SubTitleBoldHeaderView { + func addViews() { + addSubview(headerLabel) + } + + func setupConstraints() { + headerLabel.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } +} + +public extension SubTitleBoldHeaderView { + func inject(title: String?) { + headerLabel.attributedText = .makeStyledString(font: .sub_m_b, text: title, alignment: .left) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SupportBackgroundView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SupportBackgroundView.swift new file mode 100644 index 00000000..9ab057e7 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SupportBackgroundView.swift @@ -0,0 +1,44 @@ +import UIKit + +final public class SupportBackgroundView: UICollectionReusableView { + // MARK: - Type + enum Constant { + static let radius: CGFloat = 16 + static let topInset: CGFloat = 16 + static let bottomInset: CGFloat = 20 + static let horizontalInset: CGFloat = 16 + } + + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .whiteMLS + view.layer.cornerRadius = Constant.radius + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .neutral100 + addViews() + setConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +private extension SupportBackgroundView { + private func addViews() { + addSubview(containerView) + } + + func setConstraints() { + containerView.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.topInset) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.bottom.equalToSuperview().inset(Constant.bottomInset) + } + } +} diff --git a/MLS/MLSCore/Sources/MLSCore/Extension/UICollectionReusableView+.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UICollectionReusableView+.swift similarity index 100% rename from MLS/MLSCore/Sources/MLSCore/Extension/UICollectionReusableView+.swift rename to MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions/UICollectionReusableView+.swift diff --git a/MLS/MLSMyPageFeature/.gitignore b/MLS/MLSMyPageFeature/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/MLS/MLSMyPageFeature/.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/MLSMyPageFeature/.swiftpm/xcode/xcshareddata/xcschemes/MLSMyPageFeature.xcscheme b/MLS/MLSMyPageFeature/.swiftpm/xcode/xcshareddata/xcschemes/MLSMyPageFeature.xcscheme new file mode 100644 index 00000000..3a096e4e --- /dev/null +++ b/MLS/MLSMyPageFeature/.swiftpm/xcode/xcshareddata/xcschemes/MLSMyPageFeature.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MLS/MLSMyPageFeature/.swiftpm/xcode/xcshareddata/xcschemes/MLSMyPageFeatureTesting.xcscheme b/MLS/MLSMyPageFeature/.swiftpm/xcode/xcshareddata/xcschemes/MLSMyPageFeatureTesting.xcscheme new file mode 100644 index 00000000..3aea6ebb --- /dev/null +++ b/MLS/MLSMyPageFeature/.swiftpm/xcode/xcshareddata/xcschemes/MLSMyPageFeatureTesting.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MLS/MLSMyPageFeature/Package.swift b/MLS/MLSMyPageFeature/Package.swift new file mode 100644 index 00000000..5e0dd527 --- /dev/null +++ b/MLS/MLSMyPageFeature/Package.swift @@ -0,0 +1,83 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MLSMyPageFeature", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "MLSMyPageFeatureInterface", + targets: ["MLSMyPageFeatureInterface"] + ), + .library( + name: "MLSMyPageFeature", + targets: ["MLSMyPageFeature"] + ), + .library( + name: "MLSMyPageFeatureTesting", + targets: ["MLSMyPageFeatureTesting"] + ) + ], + dependencies: [ + .package(path: "../MLSAuthFeature"), + .package(path: "../MLSCore"), + .package(path: "../MLSDesignSystem"), + .package(url: "https://github.com/ReactorKit/ReactorKit.git", from: "3.2.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 + .target( + name: "MLSMyPageFeatureInterface", + dependencies: [ + .product(name: "MLSAuthFeatureInterface", package: "MLSAuthFeature"), + .product(name: "MLSCore", package: "MLSCore"), + .product(name: "MLSDesignSystem", package: "MLSDesignSystem"), + .product(name: "RxSwift", package: "RxSwift") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + // Feature + .target( + name: "MLSMyPageFeature", + dependencies: [ + "MLSMyPageFeatureInterface", + .product(name: "MLSAuthFeatureInterface", package: "MLSAuthFeature"), + .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: "SnapKit", package: "SnapKit") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + // Mock + .target( + name: "MLSMyPageFeatureTesting", + dependencies: [ + "MLSMyPageFeatureInterface", + .product(name: "RxSwift", package: "RxSwift") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + // Tests + .testTarget( + name: "MLSMyPageFeatureTests", + dependencies: [ + "MLSMyPageFeature", + "MLSMyPageFeatureInterface", + "MLSMyPageFeatureTesting", + .product(name: "MLSAuthFeatureInterface", package: "MLSAuthFeature"), + .product(name: "MLSAuthFeatureTesting", package: "MLSAuthFeature"), + .product(name: "RxBlocking", package: "RxSwift") + ], + ) + ] +) diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/AlarmResponseDTO.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/AlarmResponseDTO.swift new file mode 100644 index 00000000..7598e0e8 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/AlarmResponseDTO.swift @@ -0,0 +1,91 @@ +import MLSMyPageFeatureInterface + +public struct AlarmResponseDTO: Decodable { + public let contents: [Content] + public let hasMore: Bool + + public enum Content: Decodable { + case normal(NormalContent) + case all(AllContent) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let normal = try? container.decode(NormalContent.self) { + self = .normal(normal) + return + } + + if let all = try? container.decode(AllContent.self) { + self = .all(all) + return + } + + throw DecodingError.typeMismatch( + Content.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Content type not matched" + ) + ) + } + } + + public struct NormalContent: Decodable { + public let id: Int + public let type: String + public let title: String + public let link: String + public let date: String + } + + public struct AllContent: Decodable { + public let alrim: NormalContent + public let alreadyRead: Bool + } +} + +public extension AlarmResponseDTO { + func toAlarmDomain() -> PagedEntity { + let alarms = contents.compactMap { content -> AlarmResponse? in + switch content { + case .normal(let normal): + return AlarmResponse( + id: normal.id, + type: normal.type, + title: normal.title, + link: normal.link, + date: normal.date + ) + case .all(let all): + return AlarmResponse( + id: all.alrim.id, + type: all.alrim.type, + title: all.alrim.title, + link: all.alrim.link, + date: all.alrim.date + ) + } + } + return PagedEntity(items: alarms, hasMore: hasMore) + } + + func toAllAlarmDomain() -> PagedEntity { + let allAlarms = contents.compactMap { content -> AllAlarmResponse? in + switch content { + case .all(let all): + return AllAlarmResponse( + id: all.alrim.id, + type: all.alrim.type, + title: all.alrim.title, + link: all.alrim.link, + date: all.alrim.date, + alreadyRead: all.alreadyRead + ) + case .normal: + return nil + } + } + return PagedEntity(items: allAlarms, hasMore: hasMore) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/JobsDTO.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/JobsDTO.swift new file mode 100644 index 00000000..42847b79 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/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/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/MemberDTO.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/MemberDTO.swift new file mode 100644 index 00000000..09320180 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/MemberDTO.swift @@ -0,0 +1,29 @@ +import MLSMyPageFeatureInterface + +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 + + func toDomain() -> MyPageResponse { + return .init( + nickname: nickname, + jobId: jobId, + jobName: "", + level: level, + profileUrl: profileImageUrl, + platform: provider == "APPLE" ? .apple : .kakao, + noticeAgreement: noticeAgreement, + patchNoteAgreement: patchNoteAgreement, + eventAgreement: eventAgreement + ) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Endpoints/AlarmEndPoint.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Endpoints/AlarmEndPoint.swift new file mode 100644 index 00000000..3baa4c8c --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Endpoints/AlarmEndPoint.swift @@ -0,0 +1,60 @@ +import MLSCore +import MLSMyPageFeatureInterface + +public enum AlarmEndPoint { + static let base = "https://mapleland.2megabytes.me" + + public static func fetchPatchNotes(query: Encodable) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v2/alrim/list/patch-notes", + method: .GET, + query: query + ) + } + + public static func fetchNotices(query: Encodable) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v2/alrim/list/notices", + method: .GET, + query: query + ) + } + + public static func fetchOutdatedEvents(query: Encodable) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v2/alrim/list/events/outdated", + method: .GET, + query: query + ) + } + + public static func fetchOngoingEvents(query: Encodable) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v2/alrim/list/events/ongoing", + method: .GET, + query: query + ) + } + + public static func fetchAll(query: Encodable) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v2/alrim/all", + method: .GET, + query: query + ) + } + + public static func setRead(query: Encodable) -> EndPoint { + .init( + baseURL: base, + path: "/api/v1/alrim/set-read", + method: .POST, + query: query + ) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Endpoints/MyPageEndpoint.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Endpoints/MyPageEndpoint.swift new file mode 100644 index 00000000..416a4722 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Endpoints/MyPageEndpoint.swift @@ -0,0 +1,40 @@ +import MLSCore +import MLSMyPageFeatureInterface + +public enum MyPageEndpoint { + 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 fetchJob(jobId: String) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/jobs/\(jobId)", + method: .GET + ) + } + + 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/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/AlarmAPIRepositoryImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/AlarmAPIRepositoryImpl.swift new file mode 100644 index 00000000..7a8f799e --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/AlarmAPIRepositoryImpl.swift @@ -0,0 +1,63 @@ +import Foundation + +import MLSCore +import MLSMyPageFeatureInterface + +import RxSwift + +public class AlarmAPIRepositoryImpl: AlarmRepository { + private let provider: NetworkProvider + private let tokenInterceptor: Interceptor + + public init(provider: NetworkProvider, interceptor: Interceptor) { + self.provider = provider + self.tokenInterceptor = interceptor + } + + public func fetchPatchNotes(cursor: Int?, pageSize: Int) -> Observable> { + let endpoint = AlarmEndPoint.fetchPatchNotes(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) + return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) + .map { $0.toAlarmDomain() } + } + + public func fetchNotices(cursor: Int?, pageSize: Int) -> Observable> { + let endpoint = AlarmEndPoint.fetchNotices(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) + return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) + .map { $0.toAlarmDomain() } + } + + public func fetchOutdatedEvents(cursor: Int?, pageSize: Int) -> Observable> { + let endpoint = AlarmEndPoint.fetchOutdatedEvents(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) + return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) + .map { $0.toAlarmDomain() } + } + + public func fetchOngoingEvents(cursor: Int?, pageSize: Int) -> Observable> { + let endpoint = AlarmEndPoint.fetchOngoingEvents(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) + return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) + .map { $0.toAlarmDomain() } + } + + public func fetchAll(cursor: Int?, pageSize: Int) -> Observable> { + let endpoint = AlarmEndPoint.fetchAll(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) + return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) + .map { $0.toAllAlarmDomain() } + } + + public func setRead(alarmLink: String) -> Completable { + let endpoint = AlarmEndPoint.setRead(query: SetReadQuery(alrimLink: alarmLink)) + return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) + } + +} + +private extension AlarmAPIRepositoryImpl { + struct AlarmQuery: Encodable { + let cursor: Int? + let pageSize: Int + } + + struct SetReadQuery: Encodable { + let alrimLink: String + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/MyPageRepositoryImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/MyPageRepositoryImpl.swift new file mode 100644 index 00000000..59012969 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/MyPageRepositoryImpl.swift @@ -0,0 +1,45 @@ +import MLSAuthFeatureInterface +import MLSCore +import MLSMyPageFeatureInterface + +import RxSwift + +public class MyPageRepositoryImpl: MyPageRepository { + private let provider: NetworkProvider + private let tokenInterceptor: Interceptor + + public init(provider: NetworkProvider, interceptor: Interceptor) { + self.provider = provider + self.tokenInterceptor = interceptor + } + + public func fetchProfile() -> Observable { + let endpoint = MyPageEndpoint.fetchProfile() + return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) + .map { $0.toDomain() } + } + + public func fetchJob(jobId: String) -> Observable { + let endPoint = MyPageEndpoint.fetchJob(jobId: jobId) + return provider.requestData(endPoint: endPoint, interceptor: nil).map { $0.toDomain() } + } + + public func updateNickName(nickName: String) -> Observable { + let endPoint = MyPageEndpoint.updateNickName(body: NickNameBody(nickname: nickName)) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) + .map { $0.toDomain() } + } + + public func updateProfileImage(url: String) -> Completable { + let endPoint = MyPageEndpoint.updateProfileImage(body: UpdateProfileImageBody(profileImageUrl: url)) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) + } +} + +struct NickNameBody: Encodable { + let nickname: String +} + +struct UpdateProfileImageBody: Encodable { + let profileImageUrl: String +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/NotificationPermissionRepositoryImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/NotificationPermissionRepositoryImpl.swift new file mode 100644 index 00000000..16cab586 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/NotificationPermissionRepositoryImpl.swift @@ -0,0 +1,19 @@ +import UserNotifications + +import MLSMyPageFeatureInterface + +import RxSwift + +public final class NotificationPermissionRepositoryImpl: NotificationPermissionRepository { + public init() {} + + public func fetchAuthorizationStatus() -> Single { + Single.create { single in + UNUserNotificationCenter.current().getNotificationSettings { settings in + single(.success(settings.authorizationStatus == .authorized)) + } + + return Disposables.create() + } + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/CheckEmptyLevelAndRoleUseCaseImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/CheckEmptyLevelAndRoleUseCaseImpl.swift new file mode 100644 index 00000000..55ffeaee --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/CheckEmptyLevelAndRoleUseCaseImpl.swift @@ -0,0 +1,13 @@ +import MLSAuthFeatureInterface + +import RxSwift + +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/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/CheckNickNameUseCaseImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/CheckNickNameUseCaseImpl.swift new file mode 100644 index 00000000..d1284c7b --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/CheckNickNameUseCaseImpl.swift @@ -0,0 +1,19 @@ +import Foundation + +import MLSMyPageFeatureInterface + +import RxSwift + +public class CheckNickNameUseCaseImpl: CheckNickNameUseCase { + public init() {} + + public func execute(nickName: String) -> Observable { + let pattern = "^[가-힣ㄱ-ㅎㅏ-ㅣ]{2,15}$" + + let trimmed = nickName.trimmingCharacters(in: .whitespacesAndNewlines) + let isValid = NSPredicate(format: "SELF MATCHES %@", pattern) + .evaluate(with: trimmed) + + return .just(isValid) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/CheckValidLevelUseCaseImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/CheckValidLevelUseCaseImpl.swift new file mode 100644 index 00000000..9331638d --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/CheckValidLevelUseCaseImpl.swift @@ -0,0 +1,14 @@ +import MLSAuthFeatureInterface + +import RxSwift + +public class CheckValidLevelUseCaseImpl: CheckValidLevelUseCase { + public init() {} + + public func execute(level: Int?) -> Bool? { + guard let level = level else { + return nil + } + return (1 ... 200).contains(level) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/FetchProfileUseCaseImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/FetchProfileUseCaseImpl.swift new file mode 100644 index 00000000..282b35f4 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/FetchProfileUseCaseImpl.swift @@ -0,0 +1,27 @@ +import MLSMyPageFeatureInterface + +import RxSwift + +public class FetchProfileUseCaseImpl: FetchProfileUseCase { + private var repository: MyPageRepository + + public init(repository: MyPageRepository) { + self.repository = repository + } + + public func execute() -> Observable { + return repository.fetchProfile() + .flatMap { [weak self] profile -> Observable in + guard let self = self, let jobId = profile?.jobId else { + return .just(profile) + } + + return repository.fetchJob(jobId: String(jobId)) + .map { job in + var new = profile + new?.jobName = job.name + return new + } + } + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/LogoutUseCaseImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/LogoutUseCaseImpl.swift new file mode 100644 index 00000000..76c73141 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/LogoutUseCaseImpl.swift @@ -0,0 +1,43 @@ +import Foundation + +import MLSAuthFeatureInterface +import MLSMyPageFeatureInterface + +import RxSwift + +public class LogoutUseCaseImpl: LogoutUseCase { + private var repository: TokenRepository + + public init(repository: TokenRepository) { + self.repository = repository + } + + public func execute() -> Completable { + return Completable.create { [weak self] completable in + guard let self = self else { + completable(.completed) + return Disposables.create() + } + + let deleteAccess = self.repository.deleteToken(type: .accessToken) + let deleteRefresh = self.repository.deleteToken(type: .refreshToken) + + guard case .success = deleteAccess, case .success = deleteRefresh else { + completable(.error(NSError(domain: "LogoutError", code: -1, userInfo: nil))) + return Disposables.create() + } + + var fcmToken: String? + if case .success(let token) = self.repository.fetchToken(type: .fcmToken) { + fcmToken = token + } + + if fcmToken != nil { + _ = self.repository.deleteToken(type: .fcmToken) + } + + completable(.completed) + return Disposables.create() + } + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/WithdrawUseCaseImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/WithdrawUseCaseImpl.swift new file mode 100644 index 00000000..6b01a127 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/WithdrawUseCaseImpl.swift @@ -0,0 +1,35 @@ +import Foundation + +import MLSAuthFeatureInterface +import MLSMyPageFeatureInterface + +import RxSwift + +public class WithdrawUseCaseImpl: WithdrawUseCase { + private let authRepository: AuthAPIRepository + private let tokenRepository: TokenRepository + + public init(authRepository: AuthAPIRepository, tokenRepository: TokenRepository) { + self.authRepository = authRepository + self.tokenRepository = tokenRepository + } + + public func execute() -> Completable { + return authRepository.withdraw() + .andThen(Completable.deferred { [weak self] in + guard let self = self else { return .empty() } + + let results: [Result] = [ + self.tokenRepository.deleteToken(type: .accessToken), + self.tokenRepository.deleteToken(type: .refreshToken) + ] + + for result in results { + if case .failure(let error) = result { + return .error(error) + } + } + return .empty() + }) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Announcement/AnnouncementReactor.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Announcement/AnnouncementReactor.swift new file mode 100644 index 00000000..550d3c17 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Announcement/AnnouncementReactor.swift @@ -0,0 +1,84 @@ +import MLSMyPageFeatureInterface + +import ReactorKit + +public final class AnnouncementReactor: Reactor { + // MARK: - Reactor + public enum Route { + case none + } + + public enum Action { + case viewWillAppear + case loadMore + case itemTapped(Int) + } + + public enum Mutation { + case setAlarms([AlarmResponse], hasMore: Bool, reset: Bool) + case setLoading(Bool) + } + + public struct State { + var alarms = [AlarmResponse]() + var hasMore = false + var isLoading = false + } + + // MARK: - Properties + public var initialState: State + private let disposeBag = DisposeBag() + private let alarmRepository: AlarmRepository + + public init(alarmRepository: AlarmRepository) { + self.initialState = .init() + self.alarmRepository = alarmRepository + } + + public func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear: + return .concat([ + .just(.setLoading(true)), + alarmRepository.fetchNotices(cursor: nil, pageSize: 20) + .map { paged in + .setAlarms(paged.items, hasMore: paged.hasMore, reset: true) + }, + .just(.setLoading(false)) + ]) + case .loadMore: + guard currentState.hasMore, !currentState.isLoading else { return .empty() } + let lastCursor = currentState.alarms.last?.id + return .concat([ + .just(.setLoading(true)), + alarmRepository.fetchNotices(cursor: lastCursor, pageSize: 20) + .map { paged in + .setAlarms(paged.items, hasMore: paged.hasMore, reset: false) + }, + .just(.setLoading(false)) + ]) + case .itemTapped(let index): + return alarmRepository.setRead(alarmLink: currentState.alarms[index].link) + .andThen(.empty()) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case let .setAlarms(newAlarms, hasMore, reset): + if reset { + newState.alarms = newAlarms + } else { + newState.alarms.append(contentsOf: newAlarms) + } + newState.hasMore = hasMore + + case let .setLoading(isLoading): + newState.isLoading = isLoading + } + + return newState + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Announcement/AnnouncementViewController.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Announcement/AnnouncementViewController.swift new file mode 100644 index 00000000..dff14538 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Announcement/AnnouncementViewController.swift @@ -0,0 +1,62 @@ +import UIKit + +import MLSMyPageFeatureInterface + +import ReactorKit + +final class AnnouncementViewController: CustomerSupportBaseViewController, View { + typealias Reactor = AnnouncementReactor + + // MARK: - Init + override init(type: CustomerSupportType) { + super.init(type: type) + } + + override func viewDidLoad() { + super.viewDidLoad() + + // 타입을 나눠서 베이스에서 다 처리하는게 나을려나?? + mainView.setMenuHidden(true) + mainView.changeSetupConstraints() + + onItemTapped = { [weak self] itemIndex in + self?.reactor?.action.onNext(.itemTapped(itemIndex)) + } + + onLoadMore = { [weak self] in + self?.reactor?.action.onNext(.loadMore) + } + } +} + +// MARK: - Bind +extension AnnouncementViewController { + func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) { + rx.viewWillAppear + .take(1) + .map { _ in Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + func bindViewState(reactor: Reactor) { + reactor.state + .map(\.alarms) + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind(onNext: { owner, items in + owner.mainView.detailItemStackView.arrangedSubviews.forEach { + owner.mainView.detailItemStackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + owner.createDetailItem(items: items) + }) + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/CustomerSupportBaseView.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/CustomerSupportBaseView.swift new file mode 100644 index 00000000..910cdef1 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/CustomerSupportBaseView.swift @@ -0,0 +1,286 @@ +import UIKit + +import MLSDesignSystem +/* + **고객지원 공통뷰가 될 것 같음** + */ +final class CustomerSupportBaseView: UIView { + // MARK: - Type + public enum Constant { + static let iconInset: CGFloat = 10 + static let buttonSize: CGFloat = 44 + static let horizontalInset: CGFloat = 16 + static let topMargin: CGFloat = 20 + static let menuStackViewHeight: CGFloat = 40 + static let underlineHeight: CGFloat = 2 + static let spacerHeight: CGFloat = 1 + static let imageSize: CGFloat = 20 + static let textWidth: CGFloat = 300 + static let viewHeight: CGFloat = 86 + static let dateTopMargin: CGFloat = 4 + static let emptyLabelHeight: CGFloat = 86 + static let emptyLabelInset: CGFloat = 16 + + static let menuTabBarButtonInset: NSDirectionalEdgeInsets = .init(top: 9, leading: 4, bottom: 9, trailing: 4) + static let menuTabBarHorizontalInset: UIEdgeInsets = .init(top: 0, left: 16, bottom: 0, right: 16) + } + + // MARK: - Components + // 헤더 뷰 + public let headerView = UIView() + + public let backButton: UIButton = { + let button = UIButton() + button + .setImage( + DesignSystemAsset + .image(named: "arrowBack") + .withRenderingMode(.alwaysTemplate) + .resizableImage( + withCapInsets: UIEdgeInsets( + top: Constant.iconInset, + left: Constant.iconInset, + bottom: Constant.iconInset, + right: Constant.iconInset + ) + ), + for: .normal + ) + button.tintColor = .textColor + return button + }() + + public let titleLabel = UILabel() + + let menuContainerView = UIView() + + // 이벤트 메뉴(진행중, 종료) 스택 뷰 + let menuStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .fill + stackView.spacing = 20 + stackView.alignment = .leading + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = Constant.menuTabBarHorizontalInset + return stackView + }() + + let bottomLineView: UIView = { + let view = UIView() + view.backgroundColor = .neutral300 + return view + }() + + // 메뉴 스택뷰 왼쪽 정렬을 위해서 + let emptyView = UIView() + + /// 아이템 스 택뷰 담을 스크롤 뷰 + public let scrollView: UIScrollView = { + let scrollView = UIScrollView() + return scrollView + }() + + // 아이템 담을 스택 뷰 + let detailItemStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.distribution = .fill + stackView.alignment = .fill + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = Constant.menuTabBarHorizontalInset + return stackView + }() + + // MARK: - Init + init() { + super.init(frame: .zero) + addViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension CustomerSupportBaseView { + func addViews() { + [backButton, titleLabel].forEach { headerView.addSubview($0) } + [headerView, menuContainerView, scrollView].forEach { addSubview($0) } + scrollView.addSubview(detailItemStackView) + menuContainerView.addSubview(menuStackView) + menuContainerView.addSubview(bottomLineView) + } + + func setupConstraints() { + headerView.snp.makeConstraints { make in + make.top.equalTo(self.safeAreaLayoutGuide.snp.top) + make.horizontalEdges.equalToSuperview() + make.height.equalTo(Constant.buttonSize) + } + + backButton.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview() + make.size.equalTo(Constant.buttonSize) + make.centerY.equalToSuperview() + } + + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(backButton.snp.trailing) + make.centerY.equalToSuperview() + } + + menuContainerView.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom).offset(Constant.topMargin) + make.width.equalToSuperview() + make.height.equalTo(Constant.menuStackViewHeight) + } + menuStackView.snp.makeConstraints { make in + make.width.equalToSuperview() + make.height.equalTo(Constant.menuStackViewHeight) + make.edges.equalToSuperview() + } + + bottomLineView.snp.makeConstraints { make in + make.bottom.equalToSuperview() + make.width.equalToSuperview() + make.height.equalTo(Constant.spacerHeight) + } + + scrollView.snp.makeConstraints { make in + make.top.equalTo(menuContainerView.snp.bottom) + make.width.equalToSuperview() + make.horizontalEdges.equalToSuperview() + make.bottom.equalToSuperview() + } + + detailItemStackView.snp.makeConstraints { make in + make.top.equalToSuperview() + make.width.equalToSuperview() + make.bottom.equalToSuperview() + } + } +} + +extension CustomerSupportBaseView { + func createMenuButton(title: String, tag: Int) -> UIButton { + let config = setupConfig() + let button = UIButton(configuration: config) + button.setAttributedTitle(.makeStyledString(font: .b_m_r, text: title), for: .normal) + button.setTitleColor(.neutral600, for: .normal) + button.titleLabel?.font = UIFont.b_m_r + button.tag = tag + + let underline = UIView() + underline.backgroundColor = .textColor + underline.isHidden = true + underline.tag = 999999 + + button.addSubview(underline) + underline.snp.makeConstraints { make in + make.height.equalTo(Constant.underlineHeight) + make.leading.trailing.bottom.equalToSuperview() + } + return button + } + + func setupConfig() -> UIButton.Configuration { + var config = UIButton.Configuration.plain() + config.contentInsets = Constant.menuTabBarButtonInset + return config + } + + func createDetailItem(titleText: String, dateText: String?) -> UIView { // 제목, 날짜 데이터 필요 + let view = UIView() + let title = UILabel() + let date = UILabel() + let spacer = UIView() + let imageView = UIImageView() + imageView.image = DesignSystemAsset.image(named: "arrowForwardSmall") + + title.attributedText = .makeStyledString(font: .sub_m_sb, text: titleText) + title.textAlignment = .left + + date.attributedText = .makeStyledString(font: .b_s_r, text: dateText, color: .neutral700) + + view.addSubview(title) + view.addSubview(date) + view.addSubview(imageView) + + spacer.backgroundColor = UIColor.neutral200 + + view.snp.makeConstraints { make in + make.height.equalTo(Constant.viewHeight) + } + if dateText != nil { + title.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.topMargin) + // 임의 너비 설정 + make.width.equalTo(Constant.textWidth) + } + date.snp.makeConstraints { make in + make.top.equalTo(title.snp.bottom).offset(Constant.dateTopMargin) + } + } else { + title.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.width.equalTo(Constant.textWidth) + } + } + imageView.snp.makeConstraints { make in + make.size.equalTo(Constant.imageSize) + make.centerY.equalToSuperview() + make.trailing.equalToSuperview() + } + spacer.snp.makeConstraints { make in + make.height.equalTo(Constant.spacerHeight) + } + + detailItemStackView.addArrangedSubview(view) + detailItemStackView.addArrangedSubview(spacer) + + return view + } + + func setupSpacerView() { + let spacerView = UIView() + spacerView.setContentHuggingPriority(.defaultLow, for: .horizontal) + menuStackView.addArrangedSubview(spacerView) + } + + // 이벤트 뷰가 아닐 경우 메뉴 태그 필요없음 -> 제약사항 변경 되어야 함 + func changeSetupConstraints() { + scrollView.snp.remakeConstraints { make in + make.top.equalTo(headerView.snp.bottom).offset(Constant.topMargin) + make.width.equalToSuperview() + make.horizontalEdges.equalToSuperview() + make.bottom.equalToSuperview() + } + setMenuHidden(true) // menuContainer뷰 숨기기 + } + + // menuContainerView Encapsulation + func setMenuHidden(_ hidden: Bool) { + menuContainerView.isHidden = hidden + } + + func setEmpty(text: String) { + let label = UILabel() + label.attributedText = .makeStyledString(font: .b_m_r, text: text, color: .neutral700, alignment: .left) + let view = UIView() + + view.addSubview(label) + + view.snp.makeConstraints { make in + make.height.equalTo(Constant.emptyLabelHeight) + } + label.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.emptyLabelInset) + make.centerY.equalToSuperview() + } + detailItemStackView.addArrangedSubview(view) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/CustomerSupportBaseViewController.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/CustomerSupportBaseViewController.swift new file mode 100644 index 00000000..383b7d78 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/CustomerSupportBaseViewController.swift @@ -0,0 +1,159 @@ +import UIKit + +import MLSCore +import MLSMyPageFeatureInterface + +import RxCocoa +import RxGesture +import RxSwift +/* + **부모 뷰컨이 될 것 같음** + */ +class CustomerSupportBaseViewController: BaseViewController { + // MARK: - Properties + public var disposeBag = DisposeBag() + + /// 현재 보여지고 있는 뷰의 인덱스 + public var currentTabIndex: Int? + public var urlStrings: [String] = [] + var onItemTapped: ((Int) -> Void)? + var onLoadMore: (() -> Void)? + + private let policyFactory: PolicyFactory? + + // MARK: - Components + public var mainView = CustomerSupportBaseView() + public var type: CustomerSupportType + + public init(type: CustomerSupportType) { + self.type = type + self.policyFactory = nil + super.init() + mainView.titleLabel.attributedText = .makeStyledString(font: .sub_m_b, text: type.detailTitle) + } + + public init(type: CustomerSupportType, policyFactory: PolicyFactory) { + self.type = type + mainView.titleLabel.attributedText = .makeStyledString(font: .sub_m_b, text: type.detailTitle) + self.policyFactory = policyFactory + super.init() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + isBottomTabbarHidden = true + addViews() + setupConstaraints() + bindBackButton() + bindScrollPagination() + } + + func createDetailItem(items: [AlarmResponse]) { + for (index, item) in items.enumerated() { + let view = mainView.createDetailItem(titleText: item.title, dateText: item.date.toDisplayDateString()) + view.tag = index + urlStrings.append(item.link) + + view.isUserInteractionEnabled = true // 꼭 필요! + + view.rx.tapGesture() + .when(.recognized) + .subscribe(onNext: { [weak self] _ in + self?.handleItemTap(index: index) + }) + .disposed(by: disposeBag) + } + } + + func createTermsDetailItem(items: [String]) { + for (index, item) in items.enumerated() { + let view = mainView.createDetailItem(titleText: item, dateText: nil) + view.tag = index // 뷰에 index 태그 부여 (URL 매핑용) + + view.isUserInteractionEnabled = true + + view.rx.tapGesture() + .when(.recognized) + .subscribe(onNext: { [weak self] _ in + self?.handleItemTap(index: index) + }) + .disposed(by: disposeBag) + } + } +} + +// MARK: - SetUp +extension CustomerSupportBaseViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstaraints() { + mainView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.horizontalEdges.bottom.equalToSuperview() + } + } +} + +extension CustomerSupportBaseViewController { + func handleItemTap(index: Int) { + // 원하는 URL 열기 또는 네비게이션 처리 + switch type { + case .announcement, .event, .patchNote: + onItemTapped?(index) + guard index < urlStrings.count else { return } + let url = urlStrings[index] + if let webViewController = WebViewController.make(urlString: url) { + present(webViewController, animated: true) + } + case .terms: + switch index { + case 0: + guard let viewController = policyFactory?.make(type: .service) else { return } + navigationController?.pushViewController(viewController, animated: true) + case 1: + guard let viewController = policyFactory?.make(type: .service) else { return } + navigationController?.pushViewController(viewController, animated: true) + case 2: + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + default: + break + } + } + } + + func bindBackButton() { + mainView.backButton.rx.tap + .bind { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + .disposed(by: disposeBag) + } + + func bindScrollPagination() { + mainView.scrollView.rx.contentOffset + .throttle(.milliseconds(300), scheduler: MainScheduler.instance) + .map { [weak self] offset -> Bool in + guard let self = self else { return false } + let contentHeight = self.mainView.scrollView.contentSize.height + let height = self.mainView.scrollView.frame.size.height + let offsetY = offset.y + return offsetY > contentHeight - height - 100 + } + .distinctUntilChanged() + .filter { $0 } + .bind { [weak self] _ in + self?.onLoadMore?() + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/CustomerSupportBaseViewFactoryImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/CustomerSupportBaseViewFactoryImpl.swift new file mode 100644 index 00000000..f513616c --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/CustomerSupportBaseViewFactoryImpl.swift @@ -0,0 +1,43 @@ +import MLSCore +import MLSMyPageFeatureInterface + +public final class CustomerSupportBaseViewFactoryImpl: CustomerSupportFactory { + private let policyFactory: PolicyFactory + + private let alarmRepository: AlarmRepository + + public init( + policyFactory: PolicyFactory, + alarmRepository: AlarmRepository + ) { + self.policyFactory = policyFactory + self.alarmRepository = alarmRepository + } + + public func make(type: CustomerSupportType) -> BaseViewController { + var viewController = BaseViewController() + + switch type { + case .event: + viewController = EventViewController(type: .event) + if let viewController = viewController as? EventViewController { + viewController.reactor = EventReactor(alarmRepository: alarmRepository) + } + case .announcement: + viewController = AnnouncementViewController(type: .announcement) + if let viewController = viewController as? AnnouncementViewController { + viewController.reactor = AnnouncementReactor(alarmRepository: alarmRepository) + } + case .patchNote: + viewController = PatchNoteViewController(type: .patchNote) + if let viewController = viewController as? PatchNoteViewController { + viewController.reactor = PatchNoteReactor(alarmRepository: alarmRepository) + } + case .terms: + viewController = TermsViewController(type: .terms, policyFactory: policyFactory) + } + + viewController.isBottomTabbarHidden = true + return viewController + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Event/EventReactor.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Event/EventReactor.swift new file mode 100644 index 00000000..18b8ad39 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Event/EventReactor.swift @@ -0,0 +1,99 @@ +import ReactorKit + +import MLSMyPageFeatureInterface + +public final class EventReactor: Reactor { + // MARK: - Reactor + public enum Route { + case none + } + + public enum Action { + case loadMore + case selectTab(Int) + case itemTapped(Int) + } + + public enum Mutation { + case setAlarms([AlarmResponse], hasMore: Bool, reset: Bool) + case setLoading(Bool) + case setIndex(Int) + } + + public struct State { + var alarms = [AlarmResponse]() + var selectedIndex = 0 + var hasMore = false + var isLoading = false + } + + // MARK: - Properties + public var initialState: State + private let disposeBag = DisposeBag() + private let alarmRepository: AlarmRepository + + public init(alarmRepository: AlarmRepository) { + self.initialState = .init() + self.alarmRepository = alarmRepository + } + + public func mutate(action: Action) -> Observable { + switch action { + case let .selectTab(index): + let fetchObservable = (index == 0 + ? alarmRepository.fetchOngoingEvents(cursor: nil, pageSize: 20) + : alarmRepository.fetchOutdatedEvents(cursor: nil, pageSize: 20)) + .map { paged -> Mutation in + .setAlarms(paged.items, hasMore: paged.hasMore, reset: true) + } + .catch { error -> Observable in + print("Fetch error: \(error)") + return .just(.setLoading(false)) + } + + return .concat([ + .just(.setIndex(index)), + .just(.setLoading(true)), + fetchObservable, + .just(.setLoading(false)) + ]) + + case .loadMore: + guard currentState.hasMore, !currentState.isLoading else { return .empty() } + let lastCursor = currentState.alarms.last?.id + + return .concat([ + .just(.setLoading(true)), + (currentState.selectedIndex == 0 ? alarmRepository.fetchOngoingEvents(cursor: lastCursor, pageSize: 20) : alarmRepository.fetchOutdatedEvents(cursor: lastCursor, pageSize: 20)) + .map { paged in + .setAlarms(paged.items, hasMore: paged.hasMore, reset: false) + }, + .just(.setLoading(false)) + ]) + case let .itemTapped(index): + return alarmRepository.setRead(alarmLink: currentState.alarms[index].link) + .andThen(.empty()) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case let .setIndex(index): + newState.selectedIndex = index + case let .setAlarms(newAlarms, hasMore, reset): + if reset { + newState.alarms = newAlarms + } else { + newState.alarms.append(contentsOf: newAlarms) + } + newState.hasMore = hasMore + + case let .setLoading(isLoading): + newState.isLoading = isLoading + } + + return newState + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Event/EventViewController.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Event/EventViewController.swift new file mode 100644 index 00000000..e4c970d2 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Event/EventViewController.swift @@ -0,0 +1,102 @@ +import UIKit + +import MLSMyPageFeatureInterface + +import ReactorKit + +final class EventViewController: CustomerSupportBaseViewController, View { + typealias Reactor = EventReactor + + // MARK: - Init + override init(type: CustomerSupportType) { + super.init(type: type) + } + + override func viewDidLoad() { + super.viewDidLoad() + setupMenu() + + onItemTapped = { [weak self] itemIndex in + self?.reactor?.action.onNext(.itemTapped(itemIndex)) + } + + onLoadMore = { [weak self] in + self?.reactor?.action.onNext(.loadMore) + } + } + + // MARK: - Setup + private func setupMenu() { + let ongoingButton = mainView.createMenuButton(title: "진행중인 이벤트", tag: 0) + let endedButton = mainView.createMenuButton(title: "종료된 이벤트", tag: 1) + + mainView.menuStackView.addArrangedSubview(ongoingButton) + mainView.menuStackView.addArrangedSubview(endedButton) + mainView.setupSpacerView() + + guard let reactor = reactor else { return } + mainView.menuStackView.arrangedSubviews + .compactMap { $0 as? UIButton } + .forEach { button in + button.rx.tap + .map { Reactor.Action.selectTab(button.tag) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + } +} + +// MARK: - Bind +extension EventViewController { + func bind(reactor: Reactor) { + reactor.action.onNext(.selectTab(0)) + bindViewState(reactor: reactor) + } + + private func bindViewState(reactor: Reactor) { + reactor.state.map(\.selectedIndex) + .observe(on: MainScheduler.instance) + .bind { [weak self] selectedIndex in + guard let self else { return } + self.updateButtonStates(in: self.mainView.menuStackView, selectedTag: selectedIndex) + } + .disposed(by: disposeBag) + + reactor.state.map(\.alarms) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .bind { [weak self] items in + guard let self else { return } + self.mainView.detailItemStackView.arrangedSubviews.forEach { subview in + self.mainView.detailItemStackView.removeArrangedSubview(subview) + subview.removeFromSuperview() + } + let eventType = reactor.currentState.selectedIndex == 0 ? "진행중인" : "종료된" + if items.isEmpty { + self.mainView.setEmpty(text: "\(eventType) 이벤트가 없습니다.") + } else { + self.createDetailItem(items: items) + } + } + .disposed(by: disposeBag) + } +} + +// MARK: - Methods +private extension EventViewController { + private func updateButtonStates(in stackView: UIStackView, selectedTag: Int) { + for (index, subview) in stackView.arrangedSubviews.enumerated() { + guard let button = subview as? UIButton else { continue } + let title = button.titleLabel?.text ?? "" + let underline = button.subviews.first { $0.tag == 999999 } + + if index == selectedTag { + button.setAttributedTitle(.makeStyledString(font: .sub_m_b, text: title, color: .black), for: .normal) + underline?.isHidden = false + } else { + button.setAttributedTitle(.makeStyledString(font: .b_m_r, text: title, color: .neutral600), for: .normal) + underline?.isHidden = true + } + } + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/PatchNote/PatchNoteReactor.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/PatchNote/PatchNoteReactor.swift new file mode 100644 index 00000000..6957307a --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/PatchNote/PatchNoteReactor.swift @@ -0,0 +1,84 @@ +import ReactorKit + +import MLSMyPageFeatureInterface + +public final class PatchNoteReactor: Reactor { + // MARK: - Reactor + public enum Route { + case none + } + + public enum Action { + case viewWillAppear + case loadMore + case itemTapped(Int) + } + + public enum Mutation { + case setAlarms([AlarmResponse], hasMore: Bool, reset: Bool) + case setLoading(Bool) + } + + public struct State { + var alarms = [AlarmResponse]() + var hasMore = false + var isLoading = false + } + + // MARK: - Properties + public var initialState: State + private let disposeBag = DisposeBag() + private let alarmRepository: AlarmRepository + + public init(alarmRepository: AlarmRepository) { + self.initialState = .init() + self.alarmRepository = alarmRepository + } + + public func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear: + return .concat([ + .just(.setLoading(true)), + alarmRepository.fetchPatchNotes(cursor: nil, pageSize: 20) + .map { paged in + .setAlarms(paged.items, hasMore: paged.hasMore, reset: true) + }, + .just(.setLoading(false)) + ]) + case .loadMore: + guard currentState.hasMore, !currentState.isLoading else { return .empty() } + let lastCursor = currentState.alarms.last?.id + return .concat([ + .just(.setLoading(true)), + alarmRepository.fetchPatchNotes(cursor: lastCursor, pageSize: 20) + .map { paged in + .setAlarms(paged.items, hasMore: paged.hasMore, reset: false) + }, + .just(.setLoading(false)) + ]) + case let .itemTapped(index): + return alarmRepository.setRead(alarmLink: currentState.alarms[index].link) + .andThen(.empty()) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case let .setAlarms(newAlarms, hasMore, reset): + if reset { + newState.alarms = newAlarms + } else { + newState.alarms.append(contentsOf: newAlarms) + } + newState.hasMore = hasMore + + case let .setLoading(isLoading): + newState.isLoading = isLoading + } + + return newState + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/PatchNote/PatchNoteViewController.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/PatchNote/PatchNoteViewController.swift new file mode 100644 index 00000000..308e3dd6 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/PatchNote/PatchNoteViewController.swift @@ -0,0 +1,62 @@ +import UIKit + +import MLSMyPageFeatureInterface + +import ReactorKit + +final class PatchNoteViewController: CustomerSupportBaseViewController, View { + typealias Reactor = PatchNoteReactor + + // MARK: - Init + override init(type: CustomerSupportType) { + super.init(type: type) + } + + override func viewDidLoad() { + super.viewDidLoad() + + // 타입을 나눠서 베이스에서 다 처리하는게 나을려나?? + mainView.setMenuHidden(true) + mainView.changeSetupConstraints() + + onItemTapped = { [weak self] itemIndex in + self?.reactor?.action.onNext(.itemTapped(itemIndex)) + } + + onLoadMore = { [weak self] in + self?.reactor?.action.onNext(.loadMore) + } + } +} + +// MARK: - Bind +extension PatchNoteViewController { + func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) {} + + func bindViewState(reactor: Reactor) { + rx.viewWillAppear + .take(1) + .map { _ in Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + reactor.state + .map { $0.alarms } + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind(onNext: { owner, alarms in + owner.mainView.detailItemStackView.arrangedSubviews.forEach { + owner.mainView.detailItemStackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + owner.createDetailItem(items: alarms) + }) + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/PolicyFactoryImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/PolicyFactoryImpl.swift new file mode 100644 index 00000000..1ddd45d9 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/PolicyFactoryImpl.swift @@ -0,0 +1,12 @@ +import MLSCore +import MLSMyPageFeatureInterface + +public final class PolicyFactoryImpl: PolicyFactory { + public init() {} + + public func make(type: PolicyType) -> BaseViewController { + let viewController = PolicyViewController(type: type) + viewController.isBottomTabbarHidden = true + return viewController + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/PolicyView.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/PolicyView.swift new file mode 100644 index 00000000..3cc03b04 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/PolicyView.swift @@ -0,0 +1,86 @@ +import UIKit + +import MLSDesignSystem +import MLSMyPageFeatureInterface + +final class PolicyView: UIView { + // MARK: - Type + public enum Constant { + static let verticalMargin: CGFloat = 20 + static let horizontalMargin: CGFloat = 16 + } + + // MARK: - Components + public let headerView = NavigationBar(type: .collection("약관 및 정책")) + + private let titleLabel = UILabel() + + private let contentTextView: UITextView = { + let view = UITextView() + view.isScrollEnabled = true + view.isEditable = false + view.isSelectable = false + return view + }() + + // MARK: - Init + init(type: PolicyType) { + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI(type: type) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension PolicyView { + func addViews() { + addSubview(headerView) + addSubview(titleLabel) + addSubview(contentTextView) + } + + func setupConstraints() { + headerView.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + } + + titleLabel.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom).offset(Constant.verticalMargin) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalMargin) + } + + contentTextView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Constant.verticalMargin) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalMargin) + make.bottom.equalToSuperview().inset(Constant.verticalMargin) + } + } + + func configureUI(type: PolicyType) { + headerView.editButton.isHidden = true + headerView.addButton.isHidden = true + headerView.setTitle(title: type.title) + titleLabel.attributedText = .makeStyledString(font: .h_xxxl_sb, text: "메랜사 \(type.title)", alignment: .left) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byWordWrapping + paragraphStyle.alignment = .left + + let attrString = NSAttributedString( + string: type.content, + attributes: [ + .font: UIFont.b_s_r ?? .systemFont(ofSize: 12), + .foregroundColor: UIColor.textColor, + .paragraphStyle: paragraphStyle + ] + ) + + contentTextView.attributedText = attrString + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/PolicyViewController.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/PolicyViewController.swift new file mode 100644 index 00000000..15ab98b1 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/PolicyViewController.swift @@ -0,0 +1,60 @@ +import UIKit + +import MLSCore +import MLSDesignSystem +import MLSMyPageFeatureInterface + +import RxCocoa +import RxGesture +import RxSwift +/* +**부모 뷰컨이 될 것 같음** + */ +class PolicyViewController: BaseViewController { + // MARK: - Properties + public var disposeBag = DisposeBag() + + // MARK: - Components + public var mainView: PolicyView + + public init(type: PolicyType) { + self.mainView = PolicyView(type: type) + super.init() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstaraints() + bind() + } +} + +// MARK: - SetUp +extension PolicyViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstaraints() { + mainView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.horizontalEdges.bottom.equalToSuperview() + } + } + + func bind() { + mainView.headerView.leftButton.rx.tap + .withUnretained(self) + .observe(on: MainScheduler.instance) + .subscribe { owner, _ in + owner.navigationController?.popViewController(animated: true) + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/TermsViewController.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/TermsViewController.swift new file mode 100644 index 00000000..251a90cf --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/TermsViewController.swift @@ -0,0 +1,16 @@ +import UIKit + +import MLSMyPageFeatureInterface + +final class TermsViewController: CustomerSupportBaseViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + let items = PolicyType.allCases + + mainView.setMenuHidden(true) + mainView.changeSetupConstraints() + createTermsDetailItem(items: items.map { $0.title }) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageListCell.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageListCell.swift new file mode 100644 index 00000000..f0a55cc1 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageListCell.swift @@ -0,0 +1,88 @@ +import UIKit + +import MLSDesignSystem + +import SnapKit + +public final class MyPageListCell: UICollectionViewCell { + // MARK: - Type + enum Constant { + static let inset: CGFloat = 10 + static let badgeMargin: CGFloat = 12 + } + + // MARK: - Components + private let titleLabel = UILabel() + + private let iconView: UIImageView = { + let view = UIImageView() + view.image = DesignSystemAsset.image(named: "arrowForwardSmall") + return view + }() + + var levelBadge: Badge? + + // MARK: - init + override init(frame: CGRect) { + super.init(frame: frame) + + addViews() + setupContstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension MyPageListCell { + func addViews() { + addSubview(titleLabel) + addSubview(iconView) + } + + func setupContstraints() { + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(Constant.inset) + make.centerY.equalToSuperview() + } + + iconView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(Constant.inset) + make.centerY.equalToSuperview() + } + } +} + +public extension MyPageListCell { + struct Input { + let title: String + var isHeader: Bool + var addLevel: Int? + + init(title: String, isHeader: Bool = false, addLevel: Int? = nil) { + self.title = title + self.isHeader = isHeader + self.addLevel = addLevel + } + } + + func inject(input: Input) { + titleLabel.attributedText = .makeStyledString(font: input.isHeader ? .sub_m_b : .b_m_r, text: input.title, alignment: .left) + iconView.isHidden = input.isHeader + levelBadge?.removeFromSuperview() + if let level = input.addLevel { + let levelBadge = Badge(style: .element("Lv.\(level)")) + + addSubview(levelBadge) + + levelBadge.snp.makeConstraints { make in + make.leading.equalTo(titleLabel.snp.trailing).offset(Constant.badgeMargin) + make.centerY.equalToSuperview() + } + self.levelBadge = levelBadge + } + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainCell.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainCell.swift new file mode 100644 index 00000000..2ac5b907 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainCell.swift @@ -0,0 +1,131 @@ +import UIKit + +import MLSCore +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +public final class MyPageMainCell: UICollectionViewCell { + // MARK: - Type + enum Constant { + static let imageSize: CGFloat = 104 + static let spacingBetweenImageAndLabel: CGFloat = 12 + static let spacingBetweenLabelAndButton: CGFloat = 16 + static let buttonHeight: CGFloat = 44 + static let horizontalInset: CGFloat = 16 + static let verticalInset: CGFloat = 20 + static let radius: CGFloat = 42 + } + + // MARK: - Properties + private let disposeBag = DisposeBag() + public var onSetProfileTap: (() -> Void)? + + // MARK: - Components + private let imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = Constant.radius + return imageView + }() + + private let nameLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 2 + label.textAlignment = .center + return label + }() + + public let setProfileButton = CommonButton(style: .normal, title: "프로필 설정", disabledTitle: nil) + + private lazy var contentStackView: UIStackView = { + let stack = UIStackView(arrangedSubviews: [imageView, nameLabel, setProfileButton]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = Constant.spacingBetweenImageAndLabel + return stack + }() + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + addViews() + setupConstraints() + configureUI() + bindButton() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - Setup +private extension MyPageMainCell { + func addViews() { + addSubview(contentStackView) + } + + func setupConstraints() { + contentStackView.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(Constant.verticalInset) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + } + + imageView.snp.makeConstraints { make in + make.size.equalTo(Constant.imageSize) + } + + setProfileButton.snp.remakeConstraints { make in + make.height.equalTo(Constant.buttonHeight) + make.horizontalEdges.equalToSuperview() + } + } + + func configureUI() { + backgroundColor = .whiteMLS + } + + func bindButton() { + setProfileButton.rx.tap + .withUnretained(self) + .subscribe { owner, _ in + owner.onSetProfileTap?() + } + .disposed(by: disposeBag) + } +} + +// MARK: - Public +public extension MyPageMainCell { + struct Input { + let imageUrl: String + let name: String + let isLogin: Bool + } + + func inject(input: Input) { + if input.isLogin { + imageView.isHidden = false + ImageLoader.shared.loadImage(stringURL: input.imageUrl) { [weak self] image in + self?.imageView.image = image + } + nameLabel.attributedText = .makeStyledString(font: .sub_l_b, text: input.name) + contentStackView.spacing = Constant.spacingBetweenImageAndLabel + setProfileButton.updateTitle(title: "프로필 설정") + } else { + imageView.isHidden = true + nameLabel.attributedText = .makeStyledString( + font: .b_s_m, + text: "로그인 후 이벤트 실시간 알림과 북마크\n그리고 최적화된 검색 서비스를 이용해보세요", + color: .neutral500 + ) + contentStackView.spacing = Constant.spacingBetweenLabelAndButton + setProfileButton.updateTitle(title: "로그인 하기") + } + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainFactoryImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainFactoryImpl.swift new file mode 100644 index 00000000..1afaf09c --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainFactoryImpl.swift @@ -0,0 +1,40 @@ +import MLSAuthFeatureInterface +import MLSCore +import MLSMyPageFeatureInterface + +public final class MyPageMainFactoryImpl: MyPageMainFactory { + private let loginFactory: LoginFactory + private let setProfileFactory: SetProfileFactory + private let customerSupportFactory: CustomerSupportFactory + private let notificationSettingFactory: NotificationSettingFactory + private let setCharacterFactory: SetCharacterFactory + private let fetchProfileUseCase: FetchProfileUseCase + + public init( + loginFactory: LoginFactory, + setProfileFactory: SetProfileFactory, + customerSupportFactory: CustomerSupportFactory, + notificationSettingFactory: NotificationSettingFactory, + setCharacterFactory: SetCharacterFactory, + fetchProfileUseCase: FetchProfileUseCase + ) { + self.loginFactory = loginFactory + self.setProfileFactory = setProfileFactory + self.customerSupportFactory = customerSupportFactory + self.notificationSettingFactory = notificationSettingFactory + self.setCharacterFactory = setCharacterFactory + self.fetchProfileUseCase = fetchProfileUseCase + } + + public func make() -> BaseViewController { + let viewController = MyPageMainViewController( + setProfileFactory: setProfileFactory, + customerSupportFactory: customerSupportFactory, + notificationSettingFactory: notificationSettingFactory, + setCharacterFactory: setCharacterFactory, + loginFactory: loginFactory + ) + viewController.reactor = MyPageMainReactor(fetchProfileUseCase: fetchProfileUseCase) + return viewController + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainReactor.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainReactor.swift new file mode 100644 index 00000000..f157f83a --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainReactor.swift @@ -0,0 +1,150 @@ +import MLSMyPageFeatureInterface + +import ReactorKit + +public final class MyPageMainReactor: Reactor { + // MARK: - Type + public enum MyPageMenu { + case setAlarm + case setCharacterInfo(MyPageResponse?) + case showEvent + case showNotice + case showPatchNode + case showPolicy + + var route: Route { + switch self { + case .setAlarm: + .notificationSetting + case .showEvent: + .event + case .showNotice: + .notice + case .showPatchNode: + .patchNode + case .showPolicy: + .policy + case .setCharacterInfo: + .characterInfoSetting + } + } + + var description: String { + switch self { + case .setAlarm: + "알림 설정" + case .setCharacterInfo: + "캐릭터 정보 설정" + case .showEvent: + "메이플랜드 이벤트" + case .showNotice: + "메이플랜드 공지사항" + case .showPatchNode: + "메이플랜드 패치노트" + case .showPolicy: + "약관 및 정책" + } + } + + var requiresLogin: Bool { + switch self { + case .setAlarm, .setCharacterInfo: + return true + default: + return false + } + } + } + + // MARK: - Route + public enum Route { + case edit + case notificationSetting + case characterInfoSetting + case event + case notice + case patchNode + case policy + case login + } + + // MARK: - Action + public enum Action { + case viewWillAppear + case profileButtonTapped + case menuItemTapped(MyPageMenu) + } + + // MARK: - Mutation + public enum Mutation { + case toNavigate(Route) + case setProfile(MyPageResponse?) + } + + // MARK: - State + public struct State { + @Pulse var route: Route? + var menus: [[MyPageMenu]] = [ + [ + .setAlarm, + .setCharacterInfo(nil) + ], [ + .showEvent, + .showNotice, + .showPatchNode, + .showPolicy + ] + ] + var profile: MyPageResponse? + } + + // MARK: - Properties + public var initialState = State() + + private let fetchProfileUseCase: FetchProfileUseCase + + // MARK: - Init + public init(fetchProfileUseCase: FetchProfileUseCase) { + self.fetchProfileUseCase = fetchProfileUseCase + } + + // MARK: - Mutate + public func mutate(action: Action) -> Observable { + switch action { + case .profileButtonTapped: + if currentState.profile != nil { + return .just(.toNavigate(.edit)) + } else { + return .just(.toNavigate(.login)) + } + case .menuItemTapped(let menu): + if currentState.profile == nil, menu.requiresLogin { + return .just(.toNavigate(.login)) + } else { + return .just(.toNavigate(menu.route)) + } + case .viewWillAppear: + return fetchProfileUseCase.execute() + .map { .setProfile($0) } + .catch { error in + print(error) + return .just(.setProfile(nil)) + } + } + } + + // MARK: - Reduce + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .toNavigate(let route): + newState.route = route + case .setProfile(let profile): + newState.profile = profile + newState.menus[0][1] = .setCharacterInfo(profile) + } + + return newState + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainView.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainView.swift new file mode 100644 index 00000000..8b16ccea --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainView.swift @@ -0,0 +1,68 @@ +import UIKit + +import MLSDesignSystem + +import SnapKit + +public final class MyPageMainView: UIView { + // MARK: - Type + enum Constant { + static let titleLabelHeight: CGFloat = 44 + static let colletionViewTopMargin: CGFloat = 20 + static let horizontalInset: CGFloat = 16 + } + + // MARK: - Properties + + // MARK: - Components + private let titleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .h_xxxl_b, text: "마이페이지", alignment: .left) + return label + }() + + public let mainCollectionView: UICollectionView = { + let layout = UICollectionViewLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .neutral100 + return collectionView + }() + + // MARK: - init + public init() { + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension MyPageMainView { + func addViews() { + addSubview(titleLabel) + addSubview(mainCollectionView) + } + + func setupConstraints() { + titleLabel.snp.makeConstraints { make in + make.top.equalTo(self.layoutMarginsGuide) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.height.equalTo(Constant.titleLabelHeight) + } + + mainCollectionView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Constant.colletionViewTopMargin) + make.horizontalEdges.bottom.equalToSuperview() + } + } + + func configureUI() { + backgroundColor = .whiteMLS + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainViewController.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainViewController.swift new file mode 100644 index 00000000..55804c0b --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainViewController.swift @@ -0,0 +1,266 @@ +import UIKit + +import MLSAuthFeatureInterface +import MLSCore +import MLSDesignSystem +import MLSMyPageFeatureInterface + +import ReactorKit +import RxSwift +import SnapKit + +public final class MyPageMainViewController: BaseViewController, View { + // MARK: - Type + enum Constant { + static let bottomHeight: CGFloat = 64 + } + + public typealias Reactor = MyPageMainReactor + + // MARK: - Properties + public var disposeBag = DisposeBag() + + private let setProfileFactory: SetProfileFactory + private let customerSupportFactory: CustomerSupportFactory + private let notificationSettingFactory: NotificationSettingFactory + private let setCharacterFactory: SetCharacterFactory + private let loginFactory: LoginFactory + + // MARK: - Components + private let mainView = MyPageMainView() + + // MARK: - Init + public init(setProfileFactory: SetProfileFactory, customerSupportFactory: CustomerSupportFactory, notificationSettingFactory: NotificationSettingFactory, setCharacterFactory: SetCharacterFactory, loginFactory: LoginFactory) { + self.setProfileFactory = setProfileFactory + self.customerSupportFactory = customerSupportFactory + self.notificationSettingFactory = notificationSettingFactory + self.setCharacterFactory = setCharacterFactory + self.loginFactory = loginFactory + super.init() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override public func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - Setup +private extension MyPageMainViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(Constant.bottomHeight) + } + } + + func configureUI() { + mainView.mainCollectionView.collectionViewLayout = createLayout() + mainView.mainCollectionView.delegate = self + mainView.mainCollectionView.dataSource = self + mainView.mainCollectionView.register(MyPageMainCell.self, forCellWithReuseIdentifier: MyPageMainCell.identifier) + mainView.mainCollectionView.register(MyPageListCell.self, forCellWithReuseIdentifier: MyPageListCell.identifier) + } + + func createLayout() -> UICollectionViewLayout { + let layoutFactory = LayoutFactory() + + let layout = UICollectionViewCompositionalLayout { sectionIndex, _ in + switch sectionIndex { + case 0: + return layoutFactory.getMyPageMainLayout().build() + case 1: + return layoutFactory.getMyPageSettingLayout().build() + default: + return layoutFactory.getMyPageSupportLayout().build() + } + } + layout.register(SettingBackgroundView.self, forDecorationViewOfKind: SettingBackgroundView.identifier) + layout.register(SupportBackgroundView.self, forDecorationViewOfKind: SupportBackgroundView.identifier) + return layout + } +} + +// MARK: - Bind +extension MyPageMainViewController { + public func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindState(reactor: reactor) + } + + private func bindUserActions(reactor: Reactor) { + rx.viewWillAppear + .map { _ in Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + private func bindState(reactor: Reactor) { + reactor.state + .map { $0.profile } + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind { owner, _ in + owner.mainView.mainCollectionView.reloadData() + } + .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 .edit: + let viewController = owner.setProfileFactory.make() + if let viewController = viewController as? SetProfileViewController { + viewController.didReturn + .withUnretained(self) + .subscribe(onNext: { _, isUpdate in + if isUpdate { + ToastFactory.createToast(message: "프로필이 업데이트 되었어요.") + } + }) + .disposed(by: owner.disposeBag) + } + owner.navigationController?.pushViewController(viewController, animated: true) + case .characterInfoSetting: + let viewController = owner.setCharacterFactory.make() + owner.navigationController?.pushViewController(viewController, animated: true) + case .event: + let viewController = owner.customerSupportFactory.make(type: .event) + owner.navigationController?.pushViewController(viewController, animated: true) + case .notice: + let viewController = owner.customerSupportFactory.make(type: .announcement) + owner.navigationController?.pushViewController(viewController, animated: true) + case .patchNode: + let viewController = owner.customerSupportFactory.make(type: .patchNote) + owner.navigationController?.pushViewController(viewController, animated: true) + case .policy: + let viewController = owner.customerSupportFactory.make(type: .terms) + owner.navigationController?.pushViewController(viewController, animated: true) + case .notificationSetting: + guard let reactor = owner.reactor, + let profile = reactor.currentState.profile else { return } + let viewController = owner.notificationSettingFactory.make(isAgreeEventNotification: profile.eventAgreement, isAgreeNoticeNotification: profile.noticeAgreement, isAgreePatchNoteNotification: profile.patchNoteAgreement) + owner.navigationController?.pushViewController(viewController, animated: true) + case .login: + let viewController = owner.loginFactory.make(exitRoute: .pop) + owner.navigationController?.pushViewController(viewController, animated: true) + case .none: + break + } + } + .disposed(by: disposeBag) + } +} + +// MARK: - Delegate +extension MyPageMainViewController: UICollectionViewDelegate, UICollectionViewDataSource { + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard let reactor = reactor else { return 0 } + switch section { + case 0: + return 1 + case 1: + return reactor.currentState.menus[0].count + 1 + default: + return reactor.currentState.menus[1].count + 1 + } + } + + public func numberOfSections(in collectionView: UICollectionView) -> Int { + return 3 + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + switch indexPath.section { + case 0: + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: MyPageMainCell.identifier, + for: indexPath + ) as? MyPageMainCell, + let reactor = reactor else { return UICollectionViewCell() } + + let profile = reactor.currentState.profile + cell.inject( + input: MyPageMainCell.Input( + imageUrl: profile?.profileUrl ?? "", + name: profile?.nickname ?? "", + isLogin: profile != nil + ) + ) + cell.onSetProfileTap = { [weak self] in + self?.reactor?.action.onNext(.profileButtonTapped) + } + return cell + + case 1, 2: + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: MyPageListCell.identifier, + for: indexPath + ) as? MyPageListCell, + let reactor = reactor else { return UICollectionViewCell() } + + let headerTitle: String + switch indexPath.section { + case 1: headerTitle = "설정" + default: headerTitle = "고객 지원" + } + + if indexPath.row == 0 { + cell.inject(input: MyPageListCell.Input(title: headerTitle, isHeader: true)) + } else { + // index.row == 0은 제목 + let item = reactor.currentState.menus[indexPath.section - 1][indexPath.row - 1] + switch item { + case .setCharacterInfo(let .some(profile)): + if let level = profile.level { + cell.inject(input: MyPageListCell.Input(title: profile.jobName, isHeader: false, addLevel: profile.level)) + } else { + cell.inject(input: MyPageListCell.Input(title: item.description, isHeader: false)) + } + default: + cell.inject(input: MyPageListCell.Input(title: item.description, isHeader: false)) + } + } + + return cell + + default: + return UICollectionViewCell() + } + } + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard indexPath.section > 0 else { return } // section 0은 프로필 셀 + + let row = indexPath.row + guard row > 0 else { return } // row 0은 header + + // 메뉴 항목 가져오기 + guard let menu = reactor?.currentState.menus[indexPath.section - 1][row - 1] else { return } + // 액션 발생 + reactor?.action.onNext(.menuItemTapped(menu)) + } + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + // 디자이너 문의 필요 + if scrollView.contentOffset.y < 0 { + scrollView.contentOffset.y = 0 + } + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingFactoryImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingFactoryImpl.swift new file mode 100644 index 00000000..913381b0 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingFactoryImpl.swift @@ -0,0 +1,26 @@ +import MLSAuthFeatureInterface +import MLSCore +import MLSMyPageFeatureInterface + +public final class NotificationSettingFactoryImpl: NotificationSettingFactory { + private let notificationRepository: NotificationPermissionRepository + private let authRepository: AuthAPIRepository + + public init(notificationRepository: NotificationPermissionRepository, authRepository: AuthAPIRepository) { + self.notificationRepository = notificationRepository + self.authRepository = authRepository + } + + public func make(isAgreeEventNotification: Bool, isAgreeNoticeNotification: Bool, isAgreePatchNoteNotification: Bool) -> BaseViewController { + let viewController = NotificationSettingViewController( + reactor: NotificationSettingReactor( + notificationRepository: notificationRepository, + authRepository: authRepository, + isAgreeEventNotification: isAgreeEventNotification, + isAgreeNoticeNotification: isAgreeNoticeNotification, + isAgreePatchNoteNotification: isAgreePatchNoteNotification + ) + ) + return viewController + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingItemView.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingItemView.swift new file mode 100644 index 00000000..4df4623b --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingItemView.swift @@ -0,0 +1,107 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class NotificationItemView: UIView { + // MARK: - Constants + enum Constant { + static let iconInset: CGFloat = 10 + static let buttonSize: CGFloat = 44 + static let topMargin: CGFloat = 20 + static let viewHeight: CGFloat = 100 + static let subTextViewWidth: CGFloat = 220 + static let horizontalMargin: CGFloat = 16 + static let subTextTopMargin: CGFloat = 8 + static let spacerHeight: CGFloat = 10 + } + + // MARK: - Components + private let titleLabel = UILabel() + private let subTextLabel = UILabel() + public let switchButton = UISwitch() + public let changeButton = UIButton() + private let spacer = UIView() + + // MARK: - Properties + private let disposeBag = DisposeBag() + + // MARK: - Init + init(title: String, subtitle: String, isAuth: Bool) { + super.init(frame: .zero) + setupUI(title: title, subtitle: subtitle, isAuth: isAuth) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - UI Setup + private func setupUI(title: String, subtitle: String, isAuth: Bool) { + // 기본 속성 설정 + titleLabel.attributedText = .makeStyledString(font: .sub_m_sb, text: title, color: .textColor) + titleLabel.textAlignment = .left + + subTextLabel.attributedText = .makeStyledString(font: .cp_s_r, text: subtitle, color: .neutral500) + subTextLabel.numberOfLines = 0 + subTextLabel.textAlignment = .left + + switchButton.onTintColor = .primary700 + + changeButton.setAttributedTitle( + .makeStyledString(font: .cp_xs_r, text: "변경하기", color: .primary700), + for: .normal + ) + changeButton.semanticContentAttribute = .forceRightToLeft + changeButton.setImage(DesignSystemAsset.image(named: "arrowForwardSmall").withRenderingMode(.alwaysTemplate), for: .normal) + changeButton.tintColor = .primary700 + + // addSubviews + addSubview(titleLabel) + addSubview(subTextLabel) + addSubview(spacer) + spacer.backgroundColor = .neutral100 + + if isAuth { + addSubview(switchButton) + } else { + addSubview(changeButton) + } + + // Layout + self.snp.makeConstraints { make in + make.height.equalTo(Constant.viewHeight) + } + + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview() + make.leading.equalToSuperview().inset(Constant.horizontalMargin) + } + + subTextLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Constant.subTextTopMargin) + make.leading.equalToSuperview().inset(Constant.horizontalMargin) + make.width.equalTo(Constant.subTextViewWidth) + } + + if isAuth { + switchButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(Constant.horizontalMargin) + make.top.equalToSuperview().offset(Constant.topMargin) + } + } else { + changeButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(Constant.horizontalMargin) + make.top.equalToSuperview().offset(Constant.topMargin) + } + } + spacer.snp.makeConstraints { make in + make.height.equalTo(Constant.spacerHeight) + make.bottom.equalToSuperview() + make.width.equalToSuperview() + } + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingReactor.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingReactor.swift new file mode 100644 index 00000000..a78afd05 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingReactor.swift @@ -0,0 +1,101 @@ +import MLSAuthFeatureInterface +import MLSMyPageFeatureInterface + +import ReactorKit + +public final class NotificationSettingReactor: Reactor { + // MARK: - Reactor + public enum Route { + case none + case dismiss + case setting + } + + public enum Action { + case viewWillAppear + case appWillEnterForeground + case backButtonTapped + case eventViewSwitch(Bool) + case noticeViewSwitch(Bool) + case patchNoteViewSwitch(Bool) + case pushGuideViewTapped + case updateAuthorization(Bool) + } + + public enum Mutation { + case setAuthorized(Bool) + case toNavigate(Route) + case setEventNotification(Bool) + case setNoticeNotification(Bool) + case setPatchNoteNotification(Bool) + } + + public struct State { + @Pulse var route = Route.none + var authorized = false + var isAgreeEventNotification: Bool + var isAgreeNoticeNotification: Bool + var isAgreePatchNoteNotification: Bool + } + + public var initialState: State + private let disposeBag = DisposeBag() + + private let notificationRepository: NotificationPermissionRepository + private let authRepository: AuthAPIRepository + + init( + notificationRepository: NotificationPermissionRepository, + authRepository: AuthAPIRepository, + isAgreeEventNotification: Bool, + isAgreeNoticeNotification: Bool, + isAgreePatchNoteNotification: Bool + ) { + self.initialState = .init(isAgreeEventNotification: isAgreeEventNotification, isAgreeNoticeNotification: isAgreeNoticeNotification, isAgreePatchNoteNotification: isAgreePatchNoteNotification) + self.notificationRepository = notificationRepository + self.authRepository = authRepository + } + + public func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear, .appWillEnterForeground: + return notificationRepository.fetchAuthorizationStatus() + .asObservable() + .map { Mutation.setAuthorized($0) } + case .backButtonTapped: + return .just(.toNavigate(.dismiss)) + case .eventViewSwitch(let isAgree): + return authRepository.updateNotificationAgreement(noticeAgreement: currentState.isAgreeNoticeNotification, patchNoteAgreement: currentState.isAgreePatchNoteNotification, eventAgreement: isAgree) + .andThen(.just(.setEventNotification(isAgree))) + case .noticeViewSwitch(let isAgree): + return authRepository.updateNotificationAgreement(noticeAgreement: isAgree, patchNoteAgreement: currentState.isAgreePatchNoteNotification, eventAgreement: currentState.isAgreeEventNotification) + .andThen(.just(.setNoticeNotification(isAgree))) + case .patchNoteViewSwitch(let isAgree): + return authRepository.updateNotificationAgreement(noticeAgreement: currentState.isAgreeNoticeNotification, patchNoteAgreement: isAgree, eventAgreement: currentState.isAgreeEventNotification) + .andThen(.just(.setPatchNoteNotification(isAgree))) + case .pushGuideViewTapped: + return .just(.toNavigate(.setting)) + case .updateAuthorization(let authorized): + return .just(.setAuthorized(authorized)) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .setAuthorized(let authorized): + newState.authorized = authorized + case .toNavigate(let route): + newState.route = route + case .setEventNotification(let isAgree): + newState.isAgreeEventNotification = isAgree + case .setNoticeNotification(let isAgree): + newState.isAgreeNoticeNotification = isAgree + case .setPatchNoteNotification(let isAgree): + newState.isAgreePatchNoteNotification = isAgree + } + + return newState + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingView.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingView.swift new file mode 100644 index 00000000..106fce30 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingView.swift @@ -0,0 +1,121 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift + +final class NotificationSettingView: UIView { + // MARK: - Properties + public var disposeBag = DisposeBag() + + // MARK: - Constants + public enum Constant { + static let iconInset: CGFloat = 10 + static let buttonSize: CGFloat = 44 + static let topMargin: CGFloat = 20 + } + + // MARK: - Components + // 헤더 뷰 + public let headerView = UIView() + public let backButton: UIButton = { + let button = UIButton() + button + .setImage( + DesignSystemAsset + .image(named: "arrowBack") + .withRenderingMode(.alwaysTemplate) + .resizableImage( + withCapInsets: UIEdgeInsets( + top: Constant.iconInset, + left: Constant.iconInset, + bottom: Constant.iconInset, + right: Constant.iconInset + ) + ), + for: .normal + ) + button.tintColor = .textColor + return button + }() + public let titleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .sub_m_b, text: "알림 설정") + return label + }() + + // 알림 설정 셀들을 담는 스택뷰 + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [eventView, noticeView, patchNoteView, pushGuideView]) + stackView.axis = .vertical + stackView.distribution = .fill + stackView.spacing = 36 + return stackView + }() + + public let eventView = NotificationItemView(title: "신규 이벤트 알림 설정", subtitle: "메이플랜드 신규 이벤트를 푸시 알림으로 빠르게 받을 수 있어요.", isAuth: true) + + public let noticeView = NotificationItemView(title: "공지사항 알림 설정", subtitle: "메이플랜드 공지사항을 푸시 알림으로 빠르게 받을 수 있어요.", isAuth: true) + + public let patchNoteView = NotificationItemView(title: "패치노트 알림 설정", subtitle: "메이플랜드 패치노트를 푸시 알림으로 빠르게 받을 수 있어요.", isAuth: true) + + public let pushGuideView = NotificationItemView(title: "푸시 알림 설정", subtitle: "기기 설정을 변경해야 알림을 받을 수 있어요.", isAuth: false) + + // MARK: - Init + init() { + super.init(frame: .zero) + addViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Setup +private extension NotificationSettingView { + func addViews() { + addSubview(headerView) + addSubview(stackView) + + headerView.addSubview(backButton) + headerView.addSubview(titleLabel) + } + + func setupConstraints() { + headerView.snp.makeConstraints { make in + make.top.equalTo(safeAreaLayoutGuide.snp.top) + make.horizontalEdges.equalToSuperview() + make.height.equalTo(Constant.buttonSize) + } + + backButton.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview() + make.size.equalTo(Constant.buttonSize) + } + + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(backButton.snp.trailing) + make.centerY.equalToSuperview() + } + + stackView.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom).offset(Constant.topMargin) + make.horizontalEdges.equalToSuperview() + make.bottom.lessThanOrEqualToSuperview() + } + } +} + +// MARK: - Update +extension NotificationSettingView { + func setViews(authorized: Bool) { + eventView.isHidden = !authorized + noticeView.isHidden = !authorized + patchNoteView.isHidden = !authorized + pushGuideView.isHidden = authorized + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingViewController.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingViewController.swift new file mode 100644 index 00000000..b088fe46 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingViewController.swift @@ -0,0 +1,166 @@ +import UIKit + +import MLSCore +import MLSMyPageFeatureInterface + +import ReactorKit +import RxSwift + +final class NotificationSettingViewController: BaseViewController, View, UNUserNotificationCenterDelegate { + typealias Reactor = NotificationSettingReactor + + public var disposeBag = DisposeBag() + + // MARK: - Properties + + // MARK: - UI Components + private let mainView = NotificationSettingView() + + // MARK: - Init + init(reactor: NotificationSettingReactor) { + super.init() + self.reactor = reactor + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - LifeCycle + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + } +} + +// MARK: - Setup +private extension NotificationSettingViewController { + func setupUI() { + isBottomTabbarHidden = true + view.addSubview(mainView) + mainView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.horizontalEdges.bottom.equalToSuperview() + } + } +} + +// MARK: - Notification Authorization +private extension NotificationSettingViewController { + 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)) + } + } + } +} + +// MARK: - Reactor Binding +extension NotificationSettingViewController { + func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + private 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.backButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.eventView.switchButton.rx.isOn + .skip(1) + .map { Reactor.Action.eventViewSwitch($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.noticeView.switchButton.rx.isOn + .skip(1) + .map { Reactor.Action.noticeViewSwitch($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.patchNoteView.switchButton.rx.isOn + .skip(1) + .map { Reactor.Action.patchNoteViewSwitch($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.pushGuideView.changeButton.rx.tap + .map { Reactor.Action.pushGuideViewTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + private func bindViewState(reactor: Reactor) { + reactor.state + .observe(on: MainScheduler.instance) + .map { $0.authorized } + .withUnretained(self) + .subscribe { owner, authorized in + owner.mainView.setViews(authorized: authorized) + } + .disposed(by: disposeBag) + + reactor.state.map { $0.isAgreeEventNotification } + .distinctUntilChanged() + .bind(to: mainView.eventView.switchButton.rx.isOn) + .disposed(by: disposeBag) + + reactor.state.map { $0.isAgreeNoticeNotification } + .distinctUntilChanged() + .bind(to: mainView.noticeView.switchButton.rx.isOn) + .disposed(by: disposeBag) + + reactor.state.map { $0.isAgreePatchNoteNotification } + .distinctUntilChanged() + .bind(to: mainView.patchNoteView.switchButton.rx.isOn) + .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .subscribe(onNext: { owner, route in + switch route { + case .dismiss: + owner.navigationController?.popViewController(animated: true) + case .setting: + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + default: + break + } + }) + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageCell.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageCell.swift new file mode 100644 index 00000000..3e0ba38b --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageCell.swift @@ -0,0 +1,118 @@ +import UIKit + +import MLSCore +import MLSDesignSystem + +import SnapKit + +@MainActor +public final class SelectImageCell: UICollectionViewCell { + // MARK: - Type + enum Constant { + static let inset: CGFloat = 28 + } + + // MARK: - Properties + private var type: MapleIllustration? + + override public var isSelected: Bool { + didSet { + updateImage() + } + } + + // MARK: - Components + private let imageView: UIImageView = { + let view = UIImageView() + view.clipsToBounds = true + return view + }() + + private lazy var checkMarkContainerView: UIView = { + let view = UIView() + view.backgroundColor = .primary100.withAlphaComponent(0.5) + + view.addSubview(checkMarkView) + + checkMarkView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(3) + } + + return view + }() + + private lazy var checkMarkView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexCode: "72412C", alpha: 0.5) + view.clipsToBounds = true + + view.addSubview(checkIcon) + + checkIcon.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(Constant.inset) + } + return view + }() + + private let checkIcon: UIImageView = { + let view = UIImageView(image: DesignSystemAsset.image(named: "checkMark").withRenderingMode(.alwaysTemplate)) + view.tintColor = .whiteMLS + return view + }() + + // MARK: - init + override init(frame: CGRect) { + super.init(frame: frame) + + addViews() + setupContstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } + + public override func layoutSubviews() { + super.layoutSubviews() + imageView.layer.cornerRadius = imageView.bounds.width * 0.4 + checkMarkView.layer.cornerRadius = checkMarkView.bounds.width * 0.4 + } +} + +// MARK: - SetUp +private extension SelectImageCell { + func addViews() { + addSubview(imageView) + imageView.addSubview(checkMarkContainerView) + } + + func setupContstraints() { + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + checkMarkContainerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + func updateImage() { + guard let type = type else { return } + ImageLoader.shared.loadImage(stringURL: type.url) { [weak self] image in + self?.imageView.image = image + } + checkMarkContainerView.isHidden = !isSelected + } +} + +public extension SelectImageCell { + struct Input { + let type: MapleIllustration + } + + func inject(input: Input) { + type = input.type + updateImage() + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageFactoryImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageFactoryImpl.swift new file mode 100644 index 00000000..6cda50e2 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageFactoryImpl.swift @@ -0,0 +1,18 @@ +import MLSCore +import MLSDesignSystem +import MLSMyPageFeatureInterface + +public struct SelectImageFactoryImpl: SelectImageFactory { + private let myPageRepository: MyPageRepository + + public init(myPageRepository: MyPageRepository) { + self.myPageRepository = myPageRepository + } + + public func make() -> BaseViewController & ModalPresentable { + let viewController = SelectImageViewContoller() + viewController.reactor = SelectImageReactor(myPageRepository: myPageRepository) + viewController.isBottomTabbarHidden = true + return viewController + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageReactor.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageReactor.swift new file mode 100644 index 00000000..18add4dd --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageReactor.swift @@ -0,0 +1,82 @@ +import MLSDesignSystem +import MLSMyPageFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift + +public final class SelectImageReactor: Reactor { + public enum Route { + case none + case dismiss + case dismissWithSave + } + + // MARK: - Reactor + public enum Action { + case cancelButtonTapped + case applyButtonTapped + case imageTapped(Int) + } + + public enum Mutation { + case navigateTo(route: Route) + case selectImage(MapleIllustration) + } + + public struct State { + @Pulse var route: Route = .none + var images: [MapleIllustration] = [ + .mushroom, + .slime, + .blueSnail, + .juniorYeti, + .yeti, + .pepe, + .wraith, + .starPixie, + .rash + ] + var selectedImage: MapleIllustration? + } + + // MARK: - properties + public var initialState: State + var disposeBag = DisposeBag() + + private let myPageRepository: MyPageRepository + + // MARK: - init + public init(myPageRepository: MyPageRepository) { + self.initialState = State() + self.myPageRepository = myPageRepository + } + + // MARK: - Reactor Methods + public func mutate(action: Action) -> Observable { + switch action { + case .cancelButtonTapped: + return .just(.navigateTo(route: .dismiss)) + case .applyButtonTapped: + guard let url = currentState.selectedImage?.url else { return .empty() } + return myPageRepository.updateProfileImage(url: url) + .andThen(.just(.navigateTo(route: .dismissWithSave))) + case .imageTapped(let index): + let image = currentState.images[index] + return .just(.selectImage(image)) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .navigateTo(let route): + newState.route = route + case .selectImage(let image): + newState.selectedImage = image + } + + return newState + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageView.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageView.swift new file mode 100644 index 00000000..615dc726 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageView.swift @@ -0,0 +1,68 @@ +import UIKit + +import MLSDesignSystem + +import SnapKit + +final class SelectImageView: UIView { + private enum Constant { + static let imageSize: CGFloat = 104 + static let topMargin: CGFloat = 16 + static let headerHeight: CGFloat = 32 + static let bottomMargin: CGFloat = 24 + static let buttonBottomMargin: CGFloat = 4 + static let horizontalInset: CGFloat = 16 + } + + // MARK: - Components + public let header = Header(style: .filter, title: "프로필 이미지를 선택해주세요.") + + public let imageCollectionView: UICollectionView = { + let layout = UICollectionViewLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + return collectionView + }() + + public let applyButton = CommonButton(style: .normal, title: "적용", disabledTitle: nil) + + // MARK: - init + init() { + super.init(frame: .zero) + + addViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension SelectImageView { + func addViews() { + addSubview(header) + addSubview(imageCollectionView) + addSubview(applyButton) + } + + func setupConstraints() { + header.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.topMargin) + make.horizontalEdges.equalToSuperview() + make.height.equalTo(Constant.headerHeight) + } + + imageCollectionView.snp.makeConstraints { make in + make.top.equalTo(header.snp.bottom) + make.horizontalEdges.equalToSuperview() + } + + applyButton.snp.makeConstraints { make in + make.top.equalTo(imageCollectionView.snp.bottom).offset(Constant.bottomMargin) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.bottom.equalToSuperview().inset(Constant.buttonBottomMargin) + } + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageViewContoller.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageViewContoller.swift new file mode 100644 index 00000000..f73ee509 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageViewContoller.swift @@ -0,0 +1,120 @@ +import UIKit + +import MLSCore +import MLSDesignSystem +import MLSMyPageFeatureInterface + +import ReactorKit +import RxCocoa +import RxKeyboard +import RxSwift +import SnapKit + +public final class SelectImageViewContoller: BaseViewController, ModalPresentable, View { + // 수정필요 + public var modalHeight: CGFloat? = 16 + 32 + UIScreen.main.bounds.size.width + 4 + 24 + 54 + 4 + + public typealias Reactor = SelectImageReactor + + public var disposeBag = DisposeBag() + + // MARK: - Components + + private var mainView = SelectImageView() +} + +// MARK: - Life Cycle +public extension SelectImageViewContoller { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension SelectImageViewContoller { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + func configureUI() { + mainView.imageCollectionView.collectionViewLayout = createLayout() + mainView.imageCollectionView.delegate = self + mainView.imageCollectionView.dataSource = self + mainView.imageCollectionView.register(SelectImageCell.self, forCellWithReuseIdentifier: SelectImageCell.identifier) + } + + func createLayout() -> UICollectionViewLayout { + let layoutFactory = LayoutFactory() + let layout = CompositionalLayoutBuilder() + .section { _ in layoutFactory.getSelectImageLayout() } + .build() + return layout + } +} + +extension SelectImageViewContoller { + public func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) { + mainView.header.firstIconButton.rx.tap + .map { Reactor.Action.cancelButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.applyButton.rx.tap + .map { Reactor.Action.applyButtonTapped } + .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 .dismiss: + owner.dismissCurrentModal() + case .dismissWithSave: + owner.dismissCurrentModal() + default: + break + } + } + .disposed(by: disposeBag) + } +} + +extension SelectImageViewContoller: UICollectionViewDelegate, UICollectionViewDataSource { + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard let reactor = reactor else { return 0 } + return reactor.currentState.images.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: SelectImageCell.identifier, for: indexPath) as? SelectImageCell, + let reactor = reactor else { return UICollectionViewCell() } + cell.inject(input: SelectImageCell.Input(type: reactor.currentState.images[indexPath.row])) + return cell + } + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let reactor = reactor else { return } + reactor.action.onNext(.imageTapped(indexPath.row)) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterFactoryImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterFactoryImpl.swift new file mode 100644 index 00000000..b7157662 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterFactoryImpl.swift @@ -0,0 +1,30 @@ +import MLSAuthFeatureInterface +import MLSCore +import MLSMyPageFeatureInterface + +public struct SetCharacterFactoryImpl: SetCharacterFactory { + private let checkEmptyUseCase: CheckEmptyLevelAndRoleUseCase + private let checkValidLevelUseCase: CheckValidLevelUseCase + private let authRepository: AuthAPIRepository + + public init( + checkEmptyUseCase: CheckEmptyLevelAndRoleUseCase, + checkValidLevelUseCase: CheckValidLevelUseCase, + authRepository: AuthAPIRepository + ) { + self.checkEmptyUseCase = checkEmptyUseCase + self.checkValidLevelUseCase = checkValidLevelUseCase + self.authRepository = authRepository + } + + public func make() -> BaseViewController { + let viewController = SetCharacterViewController() + viewController.reactor = SetCharacterReactor( + checkEmptyUseCase: checkEmptyUseCase, + checkValidLevelUseCase: checkValidLevelUseCase, + authRepository: authRepository + ) + viewController.isBottomTabbarHidden = true + return viewController + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterReactor.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterReactor.swift new file mode 100644 index 00000000..e409653b --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterReactor.swift @@ -0,0 +1,113 @@ +import MLSAuthFeatureInterface +import MLSMyPageFeatureInterface + +import ReactorKit +import RxSwift + +public final class SetCharacterReactor: Reactor { + // MARK: - Reactor + public enum Route { + case none + case dismiss + case dismissWithSave + case error + } + + public enum Action { + case viewWillAppear + case backButtonTapped + case applyButtonTapped + 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 + private let checkEmptyUseCase: CheckEmptyLevelAndRoleUseCase + private let checkValidLevelUseCase: CheckValidLevelUseCase + private let authRepository: AuthAPIRepository + var disposeBag = DisposeBag() + + // 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 .applyButtonTapped: + guard let level = currentState.level, + let job = currentState.job else { return Observable.just(.navigateTo(route: .error)) } + return authRepository.updateUserInfo(level: level, selectedJobID: job.id) + .andThen(Observable.just(.navigateTo(route: .dismissWithSave))) + .catchAndReturn(.navigateTo(route: .error)) + 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 job): + newState.job = job + case .navigateTo(let route): + newState.route = route + } + + return newState + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterView.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterView.swift new file mode 100644 index 00000000..bde23089 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterView.swift @@ -0,0 +1,56 @@ +import UIKit + +import MLSCore +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +public final class SetCharacterView: CharacterInputView { + // MARK: - Type + + // MARK: - Properties + + // MARK: - Components + public let headerView: NavigationBar = { + let view = NavigationBar(type: .arrowLeft) + return view + }() + + // MARK: - init + public init() { + super.init() + addViews() + setupConstraints() + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension SetCharacterView { + 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 + nextButton.updateTitle(title: "완료", disabledTitle: "완료") + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterViewController.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterViewController.swift new file mode 100644 index 00000000..3aa22ea7 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterViewController.swift @@ -0,0 +1,140 @@ +import UIKit + +import MLSCore +import MLSDesignSystem + +import ReactorKit +import RxCocoa +import RxKeyboard +import RxSwift +import SnapKit + +public class SetCharacterViewController: BaseViewController, View { + // MARK: - Properties + public typealias Reactor = SetCharacterReactor + + public var disposeBag = DisposeBag() + + // MARK: - Components + + private var mainView = SetCharacterView() +} + +// MARK: - Life Cycle +public extension SetCharacterViewController { + override func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension SetCharacterViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } + + func configureUI() { + setupKeyboard() + } + + func setupKeyboard() { + setupKeyboard(inset: CharacterInputView.Constant.bottomInset) { [weak self] height in + self?.mainView.nextButtonBottomConstraint?.update(inset: height) + } + } +} + +// MARK: - Bind +public extension SetCharacterViewController { + 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.applyButtonTapped } + .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) + } + + 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 .dismissWithSave: + owner.navigationController?.popViewController(animated: true) + case .error: + let errorViewController = BaseErrorViewController() + owner.present(errorViewController, animated: true) + default: + break + } + }) + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileFactoryImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileFactoryImpl.swift new file mode 100644 index 00000000..9395626a --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileFactoryImpl.swift @@ -0,0 +1,34 @@ +import MLSCore +import MLSMyPageFeatureInterface + +public final class SetProfileFactoryImpl: SetProfileFactory { + private let selectImageFactory: SelectImageFactory + private let checkNickNameUseCase: CheckNickNameUseCase + private let logoutUseCase: LogoutUseCase + private let withdrawUseCase: WithdrawUseCase + private let fetchProfileUseCase: FetchProfileUseCase + private let myPageRepository: MyPageRepository + + public init( + selectImageFactory: SelectImageFactory, + checkNickNameUseCase: CheckNickNameUseCase, + logoutUseCase: LogoutUseCase, + withdrawUseCase: WithdrawUseCase, + fetchProfileUseCase: FetchProfileUseCase, + myPageRepository: MyPageRepository + ) { + self.selectImageFactory = selectImageFactory + self.checkNickNameUseCase = checkNickNameUseCase + self.myPageRepository = myPageRepository + self.logoutUseCase = logoutUseCase + self.withdrawUseCase = withdrawUseCase + self.fetchProfileUseCase = fetchProfileUseCase + } + + public func make() -> BaseViewController { + let viewController = SetProfileViewController(selectImageFactory: selectImageFactory) + viewController.reactor = SetProfileReactor(checkNickNameUseCase: checkNickNameUseCase, logoutUseCase: logoutUseCase, withdrawUseCase: withdrawUseCase, fetchProfileUseCase: fetchProfileUseCase, myPageRepository: myPageRepository) + viewController.isBottomTabbarHidden = true + return viewController + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileReactor.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileReactor.swift new file mode 100644 index 00000000..d9d6058f --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileReactor.swift @@ -0,0 +1,149 @@ +import MLSMyPageFeatureInterface + +import ReactorKit + +public final class SetProfileReactor: Reactor { + // MARK: - Route + public enum Route: Equatable { + case none + case dismiss + case dismissWithUpdate + case imageBottomSheet + case logoutAlert + case withdrawAlert + } + + // MARK: - Action + public enum Action { + case viewWillAppear + case backButtonTapped + case editButtonTapped + case logoutButtonTapped + case withdrawButtonTapped + case showBottomSheet + case inputNickName(String) + case beginEditingNickName + case logout + case withdraw + } + + // MARK: - Mutation + public enum Mutation: Equatable { + case toNavigate(Route) + case setNickName(String) + case setProfile(MyPageResponse?) + case showError(Bool) + case beginSetText(Bool) + case beginEditting + case cancelEditting + case completeEditting + } + + // MARK: - State + public struct State { + @Pulse var route: Route = .none + var setProfileState: SetProfileView.SetProfileState + var isShowError = false + var isEditingNickName = false + var profile: MyPageResponse? + var nickName = "" + } + + // MARK: - Properties + public var initialState = State(setProfileState: .normal) + + private let checkNickNameUseCase: CheckNickNameUseCase + private let logoutUseCase: LogoutUseCase + private let withdrawUseCase: WithdrawUseCase + private let fetchProfileUseCase: FetchProfileUseCase + private let myPageRepository: MyPageRepository + + // MARK: - Init + public init(checkNickNameUseCase: CheckNickNameUseCase, logoutUseCase: LogoutUseCase, withdrawUseCase: WithdrawUseCase, fetchProfileUseCase: FetchProfileUseCase, myPageRepository: MyPageRepository) { + self.checkNickNameUseCase = checkNickNameUseCase + self.logoutUseCase = logoutUseCase + self.withdrawUseCase = withdrawUseCase + self.fetchProfileUseCase = fetchProfileUseCase + self.myPageRepository = myPageRepository + } + + // MARK: - Mutate + public func mutate(action: Action) -> Observable { + switch action { + case .showBottomSheet: + return .just(.toNavigate(.imageBottomSheet)) + case .inputNickName(let nickName): + return checkNickNameUseCase.execute(nickName: nickName) + .map { isValid in + [.setNickName(nickName), .showError(!isValid)] + } + .flatMap { Observable.from($0) } + case .beginEditingNickName: + return .just(.beginSetText(true)) + case .backButtonTapped: + switch currentState.setProfileState { + case .edit: + return .just(.cancelEditting) + case .normal: + return .just(.toNavigate(.dismiss)) + } + case .editButtonTapped: + switch currentState.setProfileState { + case .edit: + if currentState.isShowError { + return .empty() + } else { + return myPageRepository.updateNickName(nickName: currentState.nickName) + .flatMap { profile in + Observable.concat([ + .just(.setProfile(profile)), + .just(.completeEditting) + ]) + } + } + case .normal: + return .just(.beginEditting) + } + case .logoutButtonTapped: + return Observable.just(.toNavigate(.logoutAlert)) + case .withdrawButtonTapped: + return Observable.just(.toNavigate(.withdrawAlert)) + case .logout: + return logoutUseCase.execute() + .andThen(.just(.toNavigate(.dismiss))) + case .withdraw: + return withdrawUseCase.execute() + .andThen(.just(.toNavigate(.dismiss))) + case .viewWillAppear: + return fetchProfileUseCase.execute() + .map { Mutation.setProfile($0)} + } + } + + // MARK: - Reduce + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .toNavigate(let route): + newState.route = route + case .showError(let error): + newState.isShowError = error + case .beginSetText(let isEditing): + newState.isEditingNickName = isEditing + case .cancelEditting: + newState.setProfileState = .normal + case .beginEditting: + newState.setProfileState = .edit + case .completeEditting: + newState.route = .dismissWithUpdate + case .setProfile(let profile): + newState.profile = profile + newState.nickName = profile?.nickname ?? "" + case .setNickName(let nickname): + newState.nickName = nickname + } + + return newState + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileView.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileView.swift new file mode 100644 index 00000000..f6a32f91 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileView.swift @@ -0,0 +1,437 @@ +import UIKit + +import MLSAuthFeatureInterface +import MLSCore +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +public final class SetProfileView: UIView { + // MARK: - Type + enum Constant { + static let buttonSize: CGFloat = 44 + static let headerViewHeight: CGFloat = 44 + static let imageViewTopMargin: CGFloat = 20 + static let imageSize: CGFloat = 104 + static let setImageIconSize: CGFloat = 28 + static let nameTopMargin: CGFloat = 12 + static let nameBottomMargin: CGFloat = 64 + static let backgroudViewTopInset: CGFloat = 20 + static let backgroudViewHorizontalInset: CGFloat = 16 + static let cancelTextViewBottomMargin: CGFloat = 10 + static let contentViewInset: CGFloat = 20 + static let textSpacing: CGFloat = 28 + static let platformSpacing: CGFloat = 5 + static let platformIconSize: CGFloat = 26 + static let logoutBottomMargin: CGFloat = 11 + static let nickNameTopMargin: CGFloat = 40 + static let nickNameHorizontalMargin: CGFloat = 33 + static let errorMessageBottomMargin: CGFloat = 12 + static let countLabelTopMargin: CGFloat = 3 + static let radius: CGFloat = 16 + } + + // MARK: - Properties + private let disposeBag = DisposeBag() + + var onCancelTap = PublishRelay() + public let imageTap = PublishRelay() + + // MARK: - Components + // shared + private lazy var headerView: UIView = { + let view = UIView() + + view.addSubview(backButton) + view.addSubview(titleLabel) + view.addSubview(editButton) + + backButton.snp.makeConstraints { make in + make.leading.equalToSuperview() + make.centerY.equalToSuperview() + make.size.equalTo(Constant.buttonSize) + } + + titleLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + editButton.snp.makeConstraints { make in + make.trailing.equalToSuperview() + make.centerY.equalToSuperview() + make.size.equalTo(Constant.buttonSize) + } + + return view + }() + + public let backButton: UIButton = { + let button = UIButton() + button.setImage(DesignSystemAsset.image(named: "arrowBack"), for: .normal) + return button + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .sub_m_b, text: "프로필 설정") + return label + }() + + public let editButton = UIButton() + + private lazy var imageView: UIImageView = { + let view = UIImageView() + view.clipsToBounds = true + + view.addSubview(setImageButton) + + setImageButton.snp.makeConstraints { make in + make.trailing.bottom.equalToSuperview() + make.size.equalTo(Constant.setImageIconSize) + } + + return view + }() + + private let setImageButton: UIButton = { + let button = UIButton() + var config = UIButton.Configuration.plain() + config.baseBackgroundColor = .clear + config.background.backgroundColor = .clear + config.image = DesignSystemAsset.image(named: "plusIcon") + button.configuration = config + return button + }() + + private let nameLabel = UILabel() + + public let logoutButton: UIButton = { + let button = UIButton() + button.setAttributedTitle(.makeStyledUnderlinedString(font: .b_s_r, text: "로그아웃", color: .neutral600), for: .normal) + return button + }() + + private let cancelTextView: UITextView = { + let textView = UITextView() + textView.isEditable = false + textView.isScrollEnabled = false + textView.isUserInteractionEnabled = true + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + + let text = "메이플랜드사전을 탈퇴하려면 여기를 눌러주세요" + let attributed = NSMutableAttributedString(string: text) + + let fullRange = NSRange(text.startIndex ..< text.endIndex, in: text) + attributed.addAttribute(.font, value: UIFont.korFont(style: .regular, size: 12)!, range: fullRange) + attributed.addAttribute(.foregroundColor, value: UIColor.neutral500, range: fullRange) + + if let range = text.range(of: "여기") { + let nsRange = NSRange(range, in: text) + attributed.addAttribute(.link, value: "cancel", range: nsRange) + attributed.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: nsRange) + } + + textView.attributedText = attributed + + textView.linkTextAttributes = [ + .foregroundColor: UIColor.neutral500, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + + return textView + }() + + // normal + private lazy var backgroudView: UIView = { + let view = UIView() + view.backgroundColor = .neutral100 + + view.addSubview(contentView) + + contentView.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.backgroudViewTopInset) + make.horizontalEdges.equalToSuperview().inset(Constant.backgroudViewHorizontalInset) + } + + return view + }() + + private lazy var contentView: UIView = { + let view = UIView() + view.backgroundColor = .whiteMLS + view.layer.cornerRadius = Constant.radius + + view.addSubview(infoLabel) + view.addSubview(accountLabel) + view.addSubview(platformIconView) + view.addSubview(platformLabel) + + infoLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().inset(Constant.contentViewInset) + } + + accountLabel.snp.makeConstraints { make in + make.top.equalTo(infoLabel.snp.bottom).offset(Constant.textSpacing) + make.leading.bottom.equalToSuperview().inset(Constant.contentViewInset) + } + + platformIconView.snp.makeConstraints { make in + make.leading.equalTo(accountLabel.snp.trailing) + make.centerY.equalTo(accountLabel) + make.size.equalTo(Constant.platformIconSize) + } + + platformLabel.snp.makeConstraints { make in + make.leading.equalTo(platformIconView.snp.trailing).offset(Constant.platformSpacing) + make.trailing.equalToSuperview().inset(Constant.contentViewInset) + make.centerY.equalTo(accountLabel) + } + + return view + }() + + private let infoLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .sub_m_b, text: "가입 정보") + return label + }() + + private let accountLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .b_m_r, text: "가입 계정", alignment: .left) + return label + }() + + private let platformIconView = UIImageView() + private let platformLabel = UILabel() + + // setEdit + public let nickNameInputBox: InputBox = { + let box = InputBox(label: "닉네임", placeHodler: "한글 2~15자 입력해주세요.") + box.label.attributedText = .makeStyledString(font: .cp_s_r, text: "닉네임", color: .textColor) + return box + }() + + private lazy var errorMessageContentView: UIView = { + let view = UIView() + + view.addSubview(errorMessage) + + errorMessage.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalToSuperview().inset(Constant.errorMessageBottomMargin) + } + + return view + }() + + private let errorMessage = ErrorMessage(message: "닉네임은 15자 이하로 입력해주세요.") + + private let countLabel = UILabel() + + // MARK: - init + public init() { + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI() + bindImageGesture() + bindTextFieldGesture() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +// public override var inputAccessoryView: UIView? { +// return errorMessageContentView +// } + + public override var canBecomeFirstResponder: Bool { + return true + } +} + +// MARK: - SetUp +private extension SetProfileView { + func addViews() { + addSubview(headerView) + addSubview(imageView) + addSubview(nameLabel) + addSubview(backgroudView) + addSubview(nickNameInputBox) + addSubview(countLabel) + addSubview(logoutButton) + addSubview(cancelTextView) + } + + func setupConstraints() { + headerView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(Constant.headerViewHeight) + } + + imageView.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom).offset(Constant.imageViewTopMargin) + make.centerX.equalToSuperview() + make.size.equalTo(Constant.imageSize) + } + + nameLabel.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom).offset(Constant.nameTopMargin) + make.centerX.equalToSuperview() + } + + backgroudView.snp.makeConstraints { make in + make.top.equalTo(nameLabel.snp.bottom).offset(Constant.nameBottomMargin) + make.horizontalEdges.bottom.equalToSuperview() + } + + nickNameInputBox.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom).offset(Constant.nickNameTopMargin) + make.horizontalEdges.equalToSuperview().inset(Constant.nickNameHorizontalMargin) + } + + countLabel.snp.makeConstraints { make in + make.top.equalTo(nickNameInputBox.snp.bottom).offset(Constant.countLabelTopMargin) + make.trailing.equalTo(nickNameInputBox) + } + + logoutButton.snp.makeConstraints { make in + make.centerX.equalToSuperview() + } + + cancelTextView.snp.makeConstraints { make in + make.top.equalTo(logoutButton.snp.bottom).offset(Constant.logoutBottomMargin) + make.centerX.equalToSuperview() + make.bottom.equalToSuperview().inset(Constant.cancelTextViewBottomMargin) + } + } + + func configureUI() { + backgroundColor = .whiteMLS + cancelTextView.delegate = self + nickNameInputBox.textField.inputAccessoryView = errorMessageContentView + } + + func bindImageGesture() { + imageView.isUserInteractionEnabled = true + + let tapGesture = UITapGestureRecognizer() + imageView.addGestureRecognizer(tapGesture) + + tapGesture.rx.event + .map { _ in } + .bind(to: imageTap) + .disposed(by: disposeBag) + + setImageButton.rx.tap + .bind(to: imageTap) + .disposed(by: disposeBag) + } + + func bindTextFieldGesture() { + let tapGesture = UITapGestureRecognizer() + tapGesture.cancelsTouchesInView = false + addGestureRecognizer(tapGesture) + + tapGesture.rx.event + .subscribe(onNext: { [weak self] _ in + self?.endEditing(true) + }) + .disposed(by: disposeBag) + } +} + +extension SetProfileView: UITextViewDelegate { + public func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + if URL.absoluteString == "cancel" { + onCancelTap.accept(()) + return false + } + return true + } +} + +public extension SetProfileView { + enum SetProfileState { + case normal + case edit + } + + func setState(state: SetProfileState) { + switch state { + case .normal: + editButton.setAttributedTitle(.makeStyledString(font: .btn_m_r, text: "수정"), for: .normal) + nameLabel.isHidden = false + backgroudView.isHidden = false + nickNameInputBox.isHidden = true + setImageButton.isHidden = true + imageView.isUserInteractionEnabled = false + case .edit: + editButton.setAttributedTitle(.makeStyledString(font: .btn_m_r, text: "완료"), for: .normal) + nameLabel.isHidden = true + backgroudView.isHidden = true + nickNameInputBox.isHidden = false + setImageButton.isHidden = false + imageView.isUserInteractionEnabled = true + } + } + + func setImage(imageUrl: String) { + ImageLoader.shared.loadImage(stringURL: imageUrl) { [weak self] image in + self?.imageView.image = image + } + } + + func setName(name: String) { + nameLabel.attributedText = .makeStyledString(font: .sub_l_b, text: name) + } + + func setPlatform(platform: LoginPlatform) { + switch platform { + case .kakao: + platformLabel.attributedText = .makeStyledString(font: .b_m_r, text: "카카오") + platformIconView.image = DesignSystemAsset.image(named: "kakaoImage") + case .apple: + platformLabel.attributedText = .makeStyledString(font: .b_m_r, text: "애플") + platformIconView.image = DesignSystemAsset.image(named: "appleImage") + } + } + + func setCount(count: Int) { + countLabel.isHidden = count < 0 + let text = "\(count)/15" + + let attributed = NSMutableAttributedString(string: text) + + if count == 0 { + let fullRange = NSRange(location: 0, length: attributed.length) + attributed.addAttribute(.font, value: UIFont.korFont(style: .regular, size: 12)!, range: fullRange) + attributed.addAttribute(.foregroundColor, value: UIColor.neutral600, range: fullRange) + } else { + let countRange = (text as NSString).range(of: "\(count)") + attributed.addAttribute(.font, value: UIFont.korFont(style: .semiBold, size: 12)!, range: countRange) + attributed.addAttribute(.foregroundColor, value: UIColor.textColor, range: countRange) + + let suffixRange = (text as NSString).range(of: "/15") + attributed.addAttribute(.font, value: UIFont.korFont(style: .regular, size: 12)!, range: suffixRange) + attributed.addAttribute(.foregroundColor, value: UIColor.neutral600, range: suffixRange) + } + + countLabel.attributedText = attributed + } + + func setError(isError: Bool) { + errorMessage.isHidden = !isError + } + + func setCountHidden(state: SetProfileState) { + countLabel.isHidden = state != .edit + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileViewController.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileViewController.swift new file mode 100644 index 00000000..aaa81020 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileViewController.swift @@ -0,0 +1,217 @@ +import UIKit + +import MLSCore +import MLSDesignSystem +import MLSMyPageFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift +import SnapKit + +public final class SetProfileViewController: BaseViewController, View { + // MARK: - Type + enum Constant { + static let bottomHeight: CGFloat = 64 + } + + public typealias Reactor = SetProfileReactor + + // MARK: - Properties + public var disposeBag = DisposeBag() + + var didReturn = PublishRelay() + private var selectImageFactory: SelectImageFactory + + // MARK: - Components + private let mainView = SetProfileView() + private let topBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = .whiteMLS + return view + }() + + // MARK: - Init + public init(selectImageFactory: SelectImageFactory) { + self.selectImageFactory = selectImageFactory + super.init() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override public func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstraints() + } +} + +// MARK: - Setup +private extension SetProfileViewController { + func addViews() { + view.addSubview(topBackgroundView) + view.addSubview(mainView) + } + + func setupConstraints() { + topBackgroundView.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top) + } + + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } +} + +// MARK: - Bind +extension SetProfileViewController { + public func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindState(reactor: reactor) + } + + private func bindUserActions(reactor: Reactor) { + rx.viewWillAppear + .map { Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.backButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.editButton.rx.tap + .map { Reactor.Action.editButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.imageTap + .map { Reactor.Action.showBottomSheet } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.nickNameInputBox.textField.rx.text.orEmpty + .map { Reactor.Action.inputNickName($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.nickNameInputBox.textField.rx.controlEvent(.editingDidBegin) + .map { Reactor.Action.beginEditingNickName } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.logoutButton.rx.tap + .map { Reactor.Action.logoutButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.onCancelTap + .map { Reactor.Action.withdrawButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + private func bindState(reactor: Reactor) { + reactor.state + .map(\.setProfileState) + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind(onNext: { owner, state in + owner.view.backgroundColor = state == .edit ? .whiteMLS : .neutral100 + owner.mainView.setCountHidden(state: state) + owner.mainView.setState(state: state) + }) + .disposed(by: disposeBag) + + reactor.state + .compactMap(\.profile) + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind(onNext: { owner, profile in + owner.mainView.setImage(imageUrl: profile.profileUrl) + owner.mainView.setPlatform(platform: profile.platform) + owner.mainView.nickNameInputBox.textField.text = profile.nickname + }) + .disposed(by: disposeBag) + + reactor.state + .compactMap(\.nickName) + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind(onNext: { owner, nickname in + owner.mainView.setName(name: nickname) + }) + .disposed(by: disposeBag) + + reactor.state + .filter(\.isEditingNickName) + .compactMap(\.nickName) + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind(onNext: { owner, nickname in + owner.mainView.setCount(count: nickname.count) + }) + .disposed(by: disposeBag) + + reactor.state + .map(\.isShowError) + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind(onNext: { owner, isShowError in + owner.mainView.setError(isError: isShowError) + }) + .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 .imageBottomSheet: + let viewController = owner.selectImageFactory.make() + + if let viewController = viewController as? UIViewController { + viewController.rx + .methodInvoked(#selector(UIViewController.viewDidDisappear)) + .take(1) + .map { _ in Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: owner.disposeBag) + } + + owner.presentModal(viewController) + case .dismiss: + owner.didReturn.accept(false) + owner.navigationController?.popViewController(animated: true) + case .dismissWithUpdate: + owner.didReturn.accept(true) + owner.navigationController?.popViewController(animated: true) + case .logoutAlert: + GuideAlertFactory.showAuthAlert(type: .logout, ctaAction: { + reactor.action.onNext(.logout) + }) + case .withdrawAlert: + GuideAlertFactory.showAuthAlert(type: .withdraw, ctaAction: { + reactor.action.onNext(.withdraw) + }) + default: + break + } + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/AlarmResponse.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/AlarmResponse.swift new file mode 100644 index 00000000..0f486c35 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/AlarmResponse.swift @@ -0,0 +1,45 @@ +import Foundation + +public struct AlarmResponse: Equatable { + public let id: Int + public let type: String + public let title: String + public let link: String + public let date: String + + public init(id: Int, type: String, title: String, link: String, date: String) { + self.id = id + self.type = type + self.title = title + self.link = link + self.date = date + } +} + +public struct AllAlarmResponse: Equatable { + public let id: Int + public let type: String + public let title: String + public let link: String + public let date: String + public var alreadyRead: Bool + + public init(id: Int, type: String, title: String, link: String, date: String, alreadyRead: Bool) { + self.id = id + self.type = type + self.title = title + self.link = link + self.date = date + self.alreadyRead = alreadyRead + } +} + +public struct PagedEntity { + public let items: [T] + public let hasMore: Bool + + public init(items: [T], hasMore: Bool) { + self.items = items + self.hasMore = hasMore + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/CustomerSupportType.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/CustomerSupportType.swift new file mode 100644 index 00000000..449977de --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/CustomerSupportType.swift @@ -0,0 +1,19 @@ +public enum CustomerSupportType { + case event + case announcement + case patchNote + case terms + + public var detailTitle: String { + switch self { + case .event: + "이벤트" + case .announcement: + "공지사항" + case .patchNote: + "패치노트" + case .terms: + "약관 및 정책" + } + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/MyPageResponse.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/MyPageResponse.swift new file mode 100644 index 00000000..46b49d44 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/MyPageResponse.swift @@ -0,0 +1,41 @@ +import MLSAuthFeatureInterface + +public struct MyPageResponse: Equatable { + public var nickname: String + public let jobId: Int? + public var jobName: String + public let level: Int? + public let profileUrl: String + public let platform: LoginPlatform + public let noticeAgreement: Bool + public let patchNoteAgreement: Bool + public let eventAgreement: Bool + + public init(nickname: String, jobId: Int?, jobName: String, level: Int?, profileUrl: String, platform: LoginPlatform, noticeAgreement: Bool?, patchNoteAgreement: Bool?, eventAgreement: Bool?) { + self.nickname = nickname + self.jobId = jobId + self.jobName = jobName + self.level = level + self.profileUrl = profileUrl + self.platform = platform + self.noticeAgreement = noticeAgreement ?? false + self.patchNoteAgreement = patchNoteAgreement ?? false + self.eventAgreement = eventAgreement ?? false + } +} + +public extension MyPageResponse { + static func mock() -> MyPageResponse { + return MyPageResponse( + nickname: "테스트", + jobId: 1, + jobName: "전사", + level: 200, + profileUrl: "", + platform: .apple, + noticeAgreement: false, + patchNoteAgreement: false, + eventAgreement: false + ) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/PolicyType.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/PolicyType.swift new file mode 100644 index 00000000..aa334654 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/PolicyType.swift @@ -0,0 +1,40 @@ +import Foundation + +public enum PolicyType: CaseIterable { + case service + case privacy + case openSource + + public var title: String { + switch self { + case .service: + "서비스 이용약관" + case .privacy: + "개인정보 처리방침" + case .openSource: + "오픈소스 라이선스" + } + } + + public var fileName: String { + switch self { + case .service: + "TermsOfService.txt" + case .privacy: + "PrivacyPolicy.txt" + case .openSource: + "" + } + } + + public var content: String { + var result = "" + guard let pahts = Bundle.main.path(forResource: fileName, ofType: nil) else { return "" } + do { + result = try String(contentsOfFile: pahts, encoding: .utf8) + return result + } catch { + return "Error: file read failed - \(error.localizedDescription)" + } + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/CustomerSupportFactory.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/CustomerSupportFactory.swift new file mode 100644 index 00000000..52a5f18c --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/CustomerSupportFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol CustomerSupportFactory { + func make(type: CustomerSupportType) -> BaseViewController +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/MyPageMainFactory.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/MyPageMainFactory.swift new file mode 100644 index 00000000..7a92981c --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/MyPageMainFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol MyPageMainFactory { + func make() -> BaseViewController +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/NotificationSettingFactory.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/NotificationSettingFactory.swift new file mode 100644 index 00000000..748996ac --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/NotificationSettingFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol NotificationSettingFactory { + func make(isAgreeEventNotification: Bool, isAgreeNoticeNotification: Bool, isAgreePatchNoteNotification: Bool) -> BaseViewController +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/PolicyFactory.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/PolicyFactory.swift new file mode 100644 index 00000000..ed7bdd83 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/PolicyFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol PolicyFactory { + func make(type: PolicyType) -> BaseViewController +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/SelectImageFactory.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/SelectImageFactory.swift new file mode 100644 index 00000000..b6265c23 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/SelectImageFactory.swift @@ -0,0 +1,6 @@ +import MLSCore +import MLSDesignSystem + +public protocol SelectImageFactory { + func make() -> BaseViewController & ModalPresentable +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/SetCharacterFactory.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/SetCharacterFactory.swift new file mode 100644 index 00000000..56820a42 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/SetCharacterFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol SetCharacterFactory { + func make() -> BaseViewController +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/SetProfileFactory.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/SetProfileFactory.swift new file mode 100644 index 00000000..fa33e846 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/SetProfileFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol SetProfileFactory { + func make() -> BaseViewController +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Repositories/AlarmRepository.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Repositories/AlarmRepository.swift new file mode 100644 index 00000000..fbf1bde8 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Repositories/AlarmRepository.swift @@ -0,0 +1,15 @@ +import RxSwift + +public protocol AlarmRepository { + func fetchPatchNotes(cursor: Int?, pageSize: Int) -> Observable> + + func fetchNotices(cursor: Int?, pageSize: Int) -> Observable> + + func fetchOutdatedEvents(cursor: Int?, pageSize: Int) -> Observable> + + func fetchOngoingEvents(cursor: Int?, pageSize: Int) -> Observable> + + func fetchAll(cursor: Int?, pageSize: Int) -> Observable> + + func setRead(alarmLink: String) -> Completable +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Repositories/MyPageRepository.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Repositories/MyPageRepository.swift new file mode 100644 index 00000000..11965d0d --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Repositories/MyPageRepository.swift @@ -0,0 +1,10 @@ +import MLSAuthFeatureInterface + +import RxSwift + +public protocol MyPageRepository { + func fetchProfile() -> Observable + func fetchJob(jobId: String) -> Observable + func updateNickName(nickName: String) -> Observable + func updateProfileImage(url: String) -> Completable +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Repositories/NotificationPermissionRepository.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Repositories/NotificationPermissionRepository.swift new file mode 100644 index 00000000..0e5e3ed9 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Repositories/NotificationPermissionRepository.swift @@ -0,0 +1,5 @@ +import RxSwift + +public protocol NotificationPermissionRepository { + func fetchAuthorizationStatus() -> Single +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/CheckNickNameUseCase.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/CheckNickNameUseCase.swift new file mode 100644 index 00000000..f57fb71d --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/CheckNickNameUseCase.swift @@ -0,0 +1,9 @@ +import RxSwift + +public protocol CheckNickNameUseCase { + /// 닉네임에 비속어가 포함되어 있는지 판별 + /// - Parameters: + /// - nickName: 현재 입력된 닉네임 + /// - Returns: true / false + func execute(nickName: String) -> Observable +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/FetchAllAlarmUseCase.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/FetchAllAlarmUseCase.swift new file mode 100644 index 00000000..b4dcccab --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/FetchAllAlarmUseCase.swift @@ -0,0 +1,5 @@ +import RxSwift + +public protocol FetchAllAlarmUseCase { + func execute(id: Int?, pageSize: Int) -> Observable> +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/FetchProfileUseCase.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/FetchProfileUseCase.swift new file mode 100644 index 00000000..f8176cb4 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/FetchProfileUseCase.swift @@ -0,0 +1,5 @@ +import RxSwift + +public protocol FetchProfileUseCase { + func execute() -> Observable +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/LogoutUseCase.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/LogoutUseCase.swift new file mode 100644 index 00000000..10cf0abc --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/LogoutUseCase.swift @@ -0,0 +1,5 @@ +import RxSwift + +public protocol LogoutUseCase { + func execute() -> Completable +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/WithdrawUseCase.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/WithdrawUseCase.swift new file mode 100644 index 00000000..0f8869db --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/WithdrawUseCase.swift @@ -0,0 +1,5 @@ +import RxSwift + +public protocol WithdrawUseCase { + func execute() -> Completable +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockAlarmRepository.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockAlarmRepository.swift new file mode 100644 index 00000000..4612512c --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockAlarmRepository.swift @@ -0,0 +1,82 @@ +import MLSMyPageFeatureInterface + +import RxSwift + +public final class MockAlarmRepository: AlarmRepository { + + public init() {} + + public func fetchPatchNotes(cursor: Int?, pageSize: Int) -> Observable> { + .just(makePagedAlarmResponse(prefix: "패치노트", cursor: cursor, pageSize: pageSize)) + } + + public func fetchNotices(cursor: Int?, pageSize: Int) -> Observable> { + .just(makePagedAlarmResponse(prefix: "공지사항", cursor: cursor, pageSize: pageSize)) + } + + public func fetchOutdatedEvents(cursor: Int?, pageSize: Int) -> Observable> { + .just(makePagedAlarmResponse(prefix: "종료 이벤트", cursor: cursor, pageSize: pageSize)) + } + + public func fetchOngoingEvents(cursor: Int?, pageSize: Int) -> Observable> { + .just(makePagedAlarmResponse(prefix: "진행중 이벤트", cursor: cursor, pageSize: pageSize)) + } + + public func fetchAll(cursor: Int?, pageSize: Int) -> Observable> { + let startID = cursor ?? 200 + + let items = (0.. pageSize + ) + ) + } + + public func setRead(alarmLink: String) -> Completable { + .empty() + } +} + +// MARK: - Private +private extension MockAlarmRepository { + + func makePagedAlarmResponse( + prefix: String, + cursor: Int?, + pageSize: Int + ) -> PagedEntity { + + let startID = cursor ?? 1000 + + let items = (0.. pageSize + ) + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockInterceptor.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockInterceptor.swift new file mode 100644 index 00000000..7b9c929b --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockInterceptor.swift @@ -0,0 +1,39 @@ +import Foundation + +import MLSCore + +public final class MockInterceptor: Interceptor { + + public init() {} + + public var adaptCalled = false + public var retryCalled = false + + public var receivedRequest: URLRequest? + public var receivedData: Data? + public var receivedResponse: URLResponse? + public var receivedError: Error? + + public var adaptedRequest: URLRequest? + public var retryResult: Bool = false + + public func adapt(_ request: URLRequest) -> URLRequest { + adaptCalled = true + receivedRequest = request + + return adaptedRequest ?? request + } + + public func retry( + data: Data?, + response: URLResponse?, + error: Error? + ) -> Bool { + retryCalled = true + receivedData = data + receivedResponse = response + receivedError = error + + return retryResult + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockLoginFactory.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockLoginFactory.swift new file mode 100644 index 00000000..cf87876b --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockLoginFactory.swift @@ -0,0 +1,12 @@ +import MLSAuthFeatureInterface +import MLSCore + +public final class MockLoginFactory: LoginFactory { + public func make(exitRoute: LoginExitRoute, onLoginCompleted: (() -> Void)?) -> BaseViewController { + let viewcontroller = BaseViewController() + viewcontroller.view.backgroundColor = .redMLS + return viewcontroller + } + + public init() {} +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockMyPageRepository.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockMyPageRepository.swift new file mode 100644 index 00000000..62f748f5 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockMyPageRepository.swift @@ -0,0 +1,36 @@ +import MLSAuthFeatureInterface +import MLSMyPageFeatureInterface + +import RxSwift + +public final class MockMyPageRepository: MyPageRepository { + + public init() {} + + public var fetchProfileResult: Observable = .just(nil) + public var fetchJobResult: Observable = .just(.init(name: "", id: 0)) + public var updateNickNameResult: Observable = + .just(.init(nickname: "", jobId: nil, jobName: "", level: nil, profileUrl: "", platform: .apple, noticeAgreement: nil, patchNoteAgreement: nil, eventAgreement: nil)) + public var updateProfileImageResult: Completable = .empty() + + public private(set) var fetchJobCalled = false + public private(set) var receivedJobId: String? + + public func fetchProfile() -> Observable { + fetchProfileResult + } + + public func fetchJob(jobId: String) -> Observable { + fetchJobCalled = true + receivedJobId = jobId + return fetchJobResult + } + + public func updateNickName(nickName: String) -> Observable { + updateNickNameResult + } + + public func updateProfileImage(url: String) -> Completable { + updateProfileImageResult + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNetworkProvider.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNetworkProvider.swift new file mode 100644 index 00000000..bc6c6c1d --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNetworkProvider.swift @@ -0,0 +1,51 @@ +import MLSCore + +import RxSwift + +public final class MockNetworkProvider: NetworkProvider { + + public init() {} + + public var requestWithResponseCalled = false + public var requestCompletableCalled = false + + public var receivedInterceptor: Interceptor? + public var receivedEndPoint: Any? + + public var responseResult: Any? + public var responseError: Error? + + public var completableResult: Completable = .empty() + + public func requestData( + endPoint: T, + interceptor: Interceptor? + ) -> Observable { + + requestWithResponseCalled = true + receivedEndPoint = endPoint + receivedInterceptor = interceptor + + if let error = responseError { + return .error(error) + } + + guard let result = responseResult as? T.Response else { + fatalError("MockNetworkProvider.responseResult 타입이 \(T.Response.self) 와 일치하지 않습니다.") + } + + return .just(result) + } + + public func requestData( + endPoint: Requestable, + interceptor: Interceptor? + ) -> Completable { + + requestCompletableCalled = true + receivedEndPoint = endPoint + receivedInterceptor = interceptor + + return completableResult + } +} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNotificationPermissionRepository.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNotificationPermissionRepository.swift new file mode 100644 index 00000000..c9723f57 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNotificationPermissionRepository.swift @@ -0,0 +1,18 @@ +import UserNotifications + +import MLSMyPageFeatureInterface + +import RxSwift + +public final class MockNotificationPermissionRepository: NotificationPermissionRepository { + public var fetchAuthorizationStatusResult = false + + public init() {} + + public func fetchAuthorizationStatus() -> Single { + Single.create { single in + single(.success(self.fetchAuthorizationStatusResult)) + return Disposables.create() + } + } +} diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/CustomerSupportReactorTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/CustomerSupportReactorTests.swift new file mode 100644 index 00000000..9258794f --- /dev/null +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/CustomerSupportReactorTests.swift @@ -0,0 +1,113 @@ +@testable import MLSMyPageFeature + +import Testing + +import MLSMyPageFeatureInterface +import MLSMyPageFeatureTesting + +import ReactorKit +import RxBlocking +import RxSwift + +@Suite("CustomerSupportReactorTests") +struct CustomerSupportReactorTests { + + // MARK: - 이벤트 페이지 + @Test("첫번째 탭 선택하면 ongoing 이벤트 fetch") + func selectTab_ongoing_emitsMutations() throws { + let repo = MockAlarmRepository() + let reactor = EventReactor(alarmRepository: repo) + + let mutations = try reactor + .mutate(action: .selectTab(0)) + .toBlocking() + .toArray() + + switch mutations[0] { + case .setIndex(let index): + #expect(index == 0) + default: + #expect(Bool(false), "Expected setIndex") + } + } + + @Test("두번째 탭 선택하면 outdated 이벤트 fetch") + func selectTab_outdated_emitsMutations() throws { + let repo = MockAlarmRepository() + let reactor = EventReactor(alarmRepository: repo) + + let mutations = try reactor + .mutate(action: .selectTab(1)) + .toBlocking() + .toArray() + + switch mutations[0] { + case .setIndex(let index): + #expect(index == 1) + default: + #expect(Bool(false), "Expected setIndex") + } + } + + // MARK: - 공통 + @Test("reset = true로 업데이트면 alarm 교체") + func setAlarms_replacesItems() { + let reactor = EventReactor(alarmRepository: MockAlarmRepository()) + + let alarms = [ + AlarmResponse( + id: 1, + type: "notice", + title: "공지", + link: "https://example.com/1", + date: "2026-04-26" + ) + ] + + let state = reactor.reduce( + state: reactor.initialState, + mutation: .setAlarms(alarms, hasMore: true, reset: true) + ) + + #expect(state.alarms.count == 1) + #expect(state.hasMore == true) + } + + @Test("reset = false로 업데이트면 alarm 추가") + func setAlarms_addsItems() { + let reactor = EventReactor(alarmRepository: MockAlarmRepository()) + + let base = EventReactor.State( + alarms: [ + AlarmResponse( + id: 1, + type: "notice", + title: "기존", + link: "https://example.com/1", + date: "2026-04-26" + ) + ], + selectedIndex: 0, + hasMore: true, + isLoading: false + ) + + let newItems = [ + AlarmResponse( + id: 2, + type: "notice", + title: "추가", + link: "https://example.com/2", + date: "2026-04-26" + ) + ] + + let state = reactor.reduce( + state: base, + mutation: .setAlarms(newItems, hasMore: false, reset: false) + ) + + #expect(state.alarms.count == 2) + #expect(state.hasMore == false) + } +} diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/MyPageMainReactorTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/MyPageMainReactorTests.swift new file mode 100644 index 00000000..df75510e --- /dev/null +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/MyPageMainReactorTests.swift @@ -0,0 +1,166 @@ +@testable import MLSMyPageFeature + +import Testing + +import MLSMyPageFeatureInterface +import MLSMyPageFeatureTesting + +import RxBlocking +import RxSwift + +@Suite("MyPageMainReactorTests") +struct MyPageMainReactorTests { + @Test("fetch profile 성공하면 profile != nil") + func viewWillAppear_user_setsProfile() throws { + let repo = MockMyPageRepository() + + repo.fetchProfileResult = .just( + MyPageResponse.mock() + ) + + let reactor = MyPageMainReactor( + fetchProfileUseCase: FetchProfileUseCaseImpl( + repository: repo + ) + ) + + let mutation = try reactor + .mutate(action: .viewWillAppear) + .toBlocking() + .first()! + + switch mutation { + case .setProfile(let profile): + #expect(profile != nil) + default: + #expect(Bool(false), "Expected setProfile") + } + } + + // MARK: - 비로그인 + @Test("profileButtonTapped + 비로그인이면 login으로 이동") + func profileButtonTapped_guest_routesLogin() throws { + let reactor = makeSUT() + let mutation = try reactor + .mutate(action: .profileButtonTapped) + .toBlocking() + .first()! + + switch mutation { + case .toNavigate(let route): + #expect(route == .login) + default: + #expect(Bool(false), "Expected toNavigate") + } + } + + @Test("setAlarm 메뉴 + 비로그인이면 login으로 이동") + func setAlarm_guest_routesLogin() throws { + let reactor = makeSUT() + let mutation = try reactor + .mutate(action: .menuItemTapped(.setAlarm)) + .toBlocking() + .first()! + + switch mutation { + case .toNavigate(let route): + #expect(route == .login) + default: + #expect(Bool(false), "Expected toNavigate") + } + } + + @Test("setCharacterInfo 메뉴 + 비로그인이면 login으로 이동") + func setCharacter_guest_routesLogin() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .menuItemTapped(.setCharacterInfo(nil))) + .toBlocking() + .first()! + + switch mutation { + case .toNavigate(let route): + #expect(route == .login) + default: + #expect(Bool(false), "Expected toNavigate") + } + } + + // MARK: - 로그인 + @Test("프로필 수정 버튼 + 로그인이면 edit으로 이동") + func profileButtonTapped_user_routesEdit() throws { + let reactor = makeSUT() + + let profile = MyPageResponse.mock() + + let loggedInState = reactor.reduce( + state: reactor.initialState, + mutation: .setProfile(profile) + ) + + let mutation: MyPageMainReactor.Mutation + + if loggedInState.profile != nil { + mutation = .toNavigate(.edit) + } else { + mutation = try reactor + .mutate(action: .profileButtonTapped) + .toBlocking() + .first()! + } + + switch mutation { + case .toNavigate(let route): + #expect(route == .edit) + default: + #expect(Bool(false), "Expected toNavigate") + } + } + + @Test("notice 메뉴 선택하면 notice로 이동") + func showNotice_routesNotice() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .menuItemTapped(.showNotice)) + .toBlocking() + .first()! + + switch mutation { + case .toNavigate(let route): + #expect(route == .notice) + default: + #expect(Bool(false), "Expected toNavigate") + } + } + + @Test("profile을 업데이트하면 캐릭터 정보 반영") + func reduce_setProfile_updatesMenu() { + let reactor = makeSUT() + + let profile = MyPageResponse.mock() + + let state = reactor.reduce( + state: reactor.initialState, + mutation: .setProfile(profile) + ) + + switch state.menus[0][1] { + case .setCharacterInfo(let menuProfile): + #expect(menuProfile?.nickname == "테스트") + default: + #expect(Bool(false), "Expected setCharacterInfo") + } + } +} + +private extension MyPageMainReactorTests { + func makeSUT() -> MyPageMainReactor { + return MyPageMainReactor( + fetchProfileUseCase: FetchProfileUseCaseImpl( + repository: MockMyPageRepository() + ) + ) + } +} diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/NotificationSettingReactorTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/NotificationSettingReactorTests.swift new file mode 100644 index 00000000..e9effad4 --- /dev/null +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/NotificationSettingReactorTests.swift @@ -0,0 +1,173 @@ +@testable import MLSMyPageFeature + +import Testing + +import MLSAuthFeatureInterface +import MLSAuthFeatureTesting +import MLSMyPageFeatureInterface +import MLSMyPageFeatureTesting + +import ReactorKit +import RxBlocking +import RxSwift + +@Suite("NotificationSettingReactorTests") +struct NotificationSettingReactorTests { + @Test("viewWillAppear에서 authorization 불러오기") + func viewWillAppear_setsAuthorized() throws { + let reactor = makeSUT(authorized: true) + + let mutation = try reactor + .mutate(action: .viewWillAppear) + .toBlocking() + .first()! + + switch mutation { + case .setAuthorized(let value): + #expect(value == true) + default: + #expect(Bool(false), "Expected setAuthorized") + } + } + + @Test("appWillEnterForeground에서 authorization 불러오기") + func appWillEnterForeground_setsAuthorized() throws { + let reactor = makeSUT(authorized: false) + + let mutation = try reactor + .mutate(action: .appWillEnterForeground) + .toBlocking() + .first()! + + switch mutation { + case .setAuthorized(let value): + #expect(value == false) + default: + #expect(Bool(false), "Expected setAuthorized") + } + } + + @Test("backButton 클릭하면 dismiss") + func backButtonTapped_routesDismiss() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .backButtonTapped) + .toBlocking() + .first()! + + switch mutation { + case .toNavigate(let route): + #expect(route == .dismiss) + default: + #expect(Bool(false), "Expected toNavigate") + } + } + + @Test("pushGuideView 클릭하면 setting") + func pushGuideViewTapped_routesSetting() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .pushGuideViewTapped) + .toBlocking() + .first()! + + switch mutation { + case .toNavigate(let route): + #expect(route == .setting) + default: + #expect(Bool(false), "Expected toNavigate") + } + } + + @Test("Authorization을 true로 수정하면 setAuthorized") + func updateAuthorization_setsAuthorized() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .updateAuthorization(true)) + .toBlocking() + .first()! + + switch mutation { + case .setAuthorized(let value): + #expect(value == true) + default: + #expect(Bool(false), "Expected setAuthorized") + } + } + + @Test("eventViewSwitch 변경하면 eventNotification 허용") + func eventViewSwitch_updatesState() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .eventViewSwitch(true)) + .toBlocking() + .first()! + + switch mutation { + case .setEventNotification(let value): + #expect(value == true) + default: + #expect(Bool(false), "Expected setEventNotification") + } + } + + @Test("noticeViewSwitch 변경하면 -> noticeNotification 허용") + func noticeViewSwitch_updatesState() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .noticeViewSwitch(true)) + .toBlocking() + .first()! + + switch mutation { + case .setNoticeNotification(let value): + #expect(value == true) + default: + #expect(Bool(false), "Expected setNoticeNotification") + } + } + + @Test("patchNoteViewSwitch 변경하면 patchNoteNotification 허용") + func patchNoteViewSwitch_updatesState() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .patchNoteViewSwitch(true)) + .toBlocking() + .first()! + + switch mutation { + case .setPatchNoteNotification(let value): + #expect(value == true) + default: + #expect(Bool(false), "Expected setPatchNoteNotification") + } + } +} + +extension NotificationSettingReactorTests { + private func makeSUT( + authorized: Bool = false, + event: Bool = false, + notice: Bool = false, + patch: Bool = false + ) -> NotificationSettingReactor { + let notificationRepo = MockNotificationPermissionRepository() + notificationRepo.fetchAuthorizationStatusResult = authorized + + let authRepo = MockAuthAPIRepository() + + return NotificationSettingReactor( + notificationRepository: notificationRepo, + authRepository: authRepo, + isAgreeEventNotification: event, + isAgreeNoticeNotification: notice, + isAgreePatchNoteNotification: patch + ) + } +} diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetCharacterReactorTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetCharacterReactorTests.swift new file mode 100644 index 00000000..11fbc63c --- /dev/null +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetCharacterReactorTests.swift @@ -0,0 +1,167 @@ +@testable import MLSMyPageFeature + +import Testing + +import MLSAuthFeatureInterface +import MLSAuthFeatureTesting +import MLSMyPageFeatureInterface +import MLSMyPageFeatureTesting + +import ReactorKit +import RxBlocking +import RxSwift + +@Suite("SetCharacterReactorTests") +struct SetCharacterReactorTests { + private let testJob = Job(name: "전사", id: 1) + + // MARK: - Mutate + + @Test("viewWillAppear에서 jobList 불러오기") + func viewWillAppear_setsJobList() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .viewWillAppear) + .toBlocking() + .first()! + + switch mutation { + case .setJobList(let jobList): + #expect(jobList.count == 5) + #expect(jobList.first?.name == "전사") + default: + #expect(Bool(false), "Expected setJobList") + } + } + + @Test("backButton 클릭하면 dismiss") + func backButtonTapped_routesDismiss() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .backButtonTapped) + .toBlocking() + .first()! + + switch mutation { + case .navigateTo(let route): + #expect(route == .dismiss) + default: + #expect(Bool(false), "Expected navigateTo") + } + } + + @Test("레벨 200 입력하면 level/buttonEnabled 활성화") + func inputLevel_emitsThreeMutations() throws { + let reactor = makeSUT() + + reactor.isStubEnabled = true + reactor.stub.state.value.job = testJob + + let mutations = try reactor + .mutate(action: .inputLevel(200)) + .toBlocking() + .toArray() + + #expect(mutations.count == 3) + + switch mutations[0] { + case .setLevel(let level): + #expect(level == 200) + default: + #expect(Bool(false), "Expected setLevel") + } + + switch mutations[1] { + case .setButtonEnabled(let enabled): + #expect(enabled == true) + default: + #expect(Bool(false), "Expected setButtonEnabled") + } + + switch mutations[2] { + case .setLevelValid(let valid): + #expect(valid == true) + default: + #expect(Bool(false), "Expected setLevelValid") + } + } + + @Test("job 입력하면 role/buttonEnabled 활성화") + func inputRole_emitsTwoMutations() throws { + let reactor = makeSUT() + + reactor.isStubEnabled = true + reactor.stub.state.value.level = 200 + + let mutations = try reactor + .mutate(action: .inputRole(testJob)) + .toBlocking() + .toArray() + + #expect(mutations.count == 2) + + switch mutations[0] { + case .setRole(let job): + #expect(job?.name == "전사") + default: + #expect(Bool(false), "Expected setRole") + } + + switch mutations[1] { + case .setButtonEnabled(let enabled): + #expect(enabled == true) + default: + #expect(Bool(false), "Expected setButtonEnabled") + } + } + + @Test("입력값 없이 applyButton 클릭하면 error 방출") + func applyButtonTapped_empty_routesError() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .applyButtonTapped) + .toBlocking() + .first()! + + switch mutation { + case .navigateTo(let route): + #expect(route == .error) + default: + #expect(Bool(false), "Expected navigateTo") + } + } + + @Test("유효한 값 입력 후 applyButton 클릭하면 dismissWithSave") + func applyButtonTapped_valid_routesDismissWithSave() throws { + let reactor = makeSUT() + + reactor.isStubEnabled = true + reactor.stub.state.value.level = 200 + reactor.stub.state.value.job = testJob + + let mutation = try reactor + .mutate(action: .applyButtonTapped) + .toBlocking() + .first()! + + switch mutation { + case .navigateTo(let route): + #expect(route == .dismissWithSave) + default: + #expect(Bool(false), "Expected navigateTo") + } + } +} + +private extension SetCharacterReactorTests { + func makeSUT() -> SetCharacterReactor { + return SetCharacterReactor( + checkEmptyUseCase: CheckEmptyLevelAndRoleUseCaseImpl(), + checkValidLevelUseCase: CheckValidLevelUseCaseImpl(), + authRepository: MockAuthAPIRepository() + ) + } +} diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetProfileReactorTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetProfileReactorTests.swift new file mode 100644 index 00000000..f5aa8909 --- /dev/null +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetProfileReactorTests.swift @@ -0,0 +1,87 @@ +@testable import MLSMyPageFeature + +import Testing + +import MLSAuthFeatureTesting +import MLSMyPageFeatureInterface +import MLSMyPageFeatureTesting + +import ReactorKit +import RxBlocking +import RxSwift + +@Suite("SetProfileReactorTests") +struct SetProfileReactorTests { + @Test("bottomSheet 버튼 클릭하면 imageBottomSheet 이동") + func showBottomSheet_routesBottomSheet() throws { + let reactor = makeSUT() + + let mutation = try reactor.mutate(action: .showBottomSheet) + .toBlocking() + .first()! + + #expect(mutation == .toNavigate(.imageBottomSheet)) + } + + @Test("backButton 클릭하면 dismiss") + func back_normal_routesDismiss() throws { + let reactor = makeSUT() + + let mutation = try reactor.mutate(action: .backButtonTapped) + .toBlocking() + .first()! + + #expect(mutation == .toNavigate(.dismiss)) + } + + @Test("editButton클릭하면 beginEditing") + func edit_normal_beginEditing() throws { + let reactor = makeSUT() + + let mutation = try reactor.mutate(action: .editButtonTapped) + .toBlocking() + .first()! + + #expect(mutation == .beginEditting) + } + + @Test("logoutButton 클릭하면 logoutAlert") + func logoutButtonTapped_routesAlert() throws { + let reactor = makeSUT() + + let mutation = try reactor.mutate(action: .logoutButtonTapped) + .toBlocking() + .first()! + + #expect(mutation == .toNavigate(.logoutAlert)) + } + + @Test("withdrawButton 클릭 하면 withdrawAlert") + func withdrawButtonTapped_routesAlert() throws { + let reactor = makeSUT() + + let mutation = try reactor.mutate(action: .withdrawButtonTapped) + .toBlocking() + .first()! + + #expect(mutation == .toNavigate(.withdrawAlert)) + } +} + +private extension SetProfileReactorTests { + func makeSUT() -> SetProfileReactor { + let authRepo = MockAuthAPIRepository() + let tokenRepo = MockTokenRepository() + return SetProfileReactor( + checkNickNameUseCase: CheckNickNameUseCaseImpl(), + logoutUseCase: LogoutUseCaseImpl( + repository: tokenRepo + ), + withdrawUseCase: WithdrawUseCaseImpl(authRepository: authRepo, tokenRepository: tokenRepo), + fetchProfileUseCase: FetchProfileUseCaseImpl( + repository: MockMyPageRepository() + ), + myPageRepository: MockMyPageRepository() + ) + } +} diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/AlarmRepositoryTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/AlarmRepositoryTests.swift new file mode 100644 index 00000000..d32f16a1 --- /dev/null +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/AlarmRepositoryTests.swift @@ -0,0 +1,206 @@ +@testable import MLSMyPageFeature + +import Testing + +import MLSCore +import MLSMyPageFeatureInterface +import MLSMyPageFeatureTesting + +import RxBlocking +import RxSwift + +@Suite("AlarmAPIRepositoryImplTests") +struct AlarmAPIRepositoryImplTests { + private let normalDTO = AlarmResponseDTO( + contents: [ + .normal( + .init( + id: 1, + type: "notice", + title: "공지사항", + link: "https://example.com/1", + date: "2026-04-01" + ) + ), + .normal( + .init( + id: 2, + type: "patch", + title: "패치노트", + link: "https://example.com/2", + date: "2026-04-02" + ) + ) + ], + hasMore: true + ) + + private let allDTO = AlarmResponseDTO( + contents: [ + .all( + .init( + alrim: .init( + id: 1, + type: "notice", + title: "공지사항", + link: "https://example.com/1", + date: "2026-04-01" + ), + alreadyRead: true + ) + ), + .all( + .init( + alrim: .init( + id: 2, + type: "event", + title: "이벤트", + link: "https://example.com/2", + date: "2026-04-02" + ), + alreadyRead: false + ) + ) + ], + hasMore: false + ) + + @Test("ongoingEvents 가져오기위해 provider 호출 및 domain 반환") + func fetchOngoingEvents_returnsPagedEntity() throws { + let provider = MockNetworkProvider() + provider.responseResult = normalDTO + + let sut = makeSUT(provider: provider) + + let result = + try sut + .fetchOngoingEvents(cursor: nil, pageSize: 20) + .toBlocking() + .first() + + #expect(provider.requestWithResponseCalled) + #expect(result?.items.count == 2) + #expect(result?.hasMore == true) + #expect(result?.items.first?.title == "공지사항") + } + + @Test("outdatedEvents 가져오기위해 provider 호출") + func fetchOutdatedEvents_returnsPagedEntity() throws { + let provider = MockNetworkProvider() + provider.responseResult = normalDTO + + let sut = makeSUT(provider: provider) + + let result = + try sut + .fetchOutdatedEvents(cursor: 10, pageSize: 20) + .toBlocking() + .first() + + #expect(provider.requestWithResponseCalled) + #expect(result?.items.count == 2) + } + + @Test("notices 가져오기위해 provider 호출") + func fetchNotices_returnsPagedEntity() throws { + let provider = MockNetworkProvider() + provider.responseResult = normalDTO + + let sut = makeSUT(provider: provider) + + let result = + try sut + .fetchNotices(cursor: nil, pageSize: 20) + .toBlocking() + .first() + + #expect(provider.requestWithResponseCalled) + #expect(result?.items.first?.type == "notice") + } + + @Test("patchNotes 가져오기위해 provider 호출") + func fetchPatchNotes_returnsPagedEntity() throws { + let provider = MockNetworkProvider() + provider.responseResult = normalDTO + + let sut = makeSUT(provider: provider) + + let result = + try sut + .fetchPatchNotes(cursor: nil, pageSize: 20) + .toBlocking() + .first() + + #expect(provider.requestWithResponseCalled) + #expect(result?.items.last?.type == "patch") + } + + // MARK: - fetchAll + + @Test("모든 Alarm 가져오면 domain 반환") + func fetchAll_returnsPagedAllAlarmEntity() throws { + let provider = MockNetworkProvider() + provider.responseResult = allDTO + + let sut = makeSUT(provider: provider) + + let result = + try sut + .fetchAll(cursor: nil, pageSize: 20) + .toBlocking() + .first() + + #expect(provider.requestWithResponseCalled) + #expect(result?.items.count == 2) + #expect(result?.hasMore == false) + #expect(result?.items.first?.alreadyRead == true) + } + + // MARK: - setRead + + @Test("알람을 읽으면 setRead 호출 후 complete") + func setRead_returnsComplete() throws { + let provider = MockNetworkProvider() + provider.completableResult = .empty() + + let sut = makeSUT(provider: provider) + + _ = + try sut + .setRead(alarmLink: "https://example.com/1") + .toBlocking() + .first() + + #expect(provider.requestCompletableCalled) + } + + @Test("setRead에 실패하면 error 방출") + func setRead_throwsError() { + let provider = MockNetworkProvider() + provider.completableResult = .error(NetworkError.httpError) + + let sut = makeSUT(provider: provider) + + #expect(throws: Error.self) { + _ = + try sut + .setRead(alarmLink: "https://example.com/1") + .toBlocking() + .first() + } + + #expect(provider.requestCompletableCalled) + } +} + +private extension AlarmAPIRepositoryImplTests { + func makeSUT( + provider: MockNetworkProvider = MockNetworkProvider(), + interceptor: MockInterceptor = MockInterceptor() + ) -> AlarmAPIRepositoryImpl { + AlarmAPIRepositoryImpl( + provider: provider, + interceptor: interceptor + ) + } +} diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/MyPageRepositoryTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/MyPageRepositoryTests.swift new file mode 100644 index 00000000..593e8180 --- /dev/null +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/MyPageRepositoryTests.swift @@ -0,0 +1,155 @@ +@testable import MLSMyPageFeature + +import Testing + +import MLSAuthFeatureInterface +import MLSCore +import MLSMyPageFeatureInterface +import MLSMyPageFeatureTesting + +import RxBlocking +import RxSwift + +@Suite("MyPageRepositoryImplTests") +struct MyPageRepositoryImplTests { + private let profileDTO = MemberDTO( + id: "1", + provider: "APPLE", + nickname: "테스터", + fcmToken: nil, + marketingAgreement: true, + noticeAgreement: true, + patchNoteAgreement: false, + eventAgreement: true, + jobId: 1, + level: 200, + profileImageUrl: "https://example.com/profile.png" + ) + + private let updatedProfileDTO = MemberDTO( + id: "2", + provider: "KAKAO", + nickname: "수정닉네임", + fcmToken: nil, + marketingAgreement: false, + noticeAgreement: false, + patchNoteAgreement: true, + eventAgreement: false, + jobId: 2, + level: 210, + profileImageUrl: "https://example.com/new.png" + ) + + private let jobDTO = JobsDTO( + jobId: 3, + jobName: "궁수", + jobLevel: 10, + parentJobId: nil, + ) + + @Test("profile 가져오기위해 provider 호출 및 domain 반환") + func fetchProfile_returnsProfile() throws { + let provider = MockNetworkProvider() + provider.responseResult = profileDTO + + let sut = makeSUT(provider: provider) + + let result = try sut + .fetchProfile() + .toBlocking() + .first()! + + #expect(provider.requestWithResponseCalled) + #expect(result?.nickname == "테스터") + #expect(result?.jobId == 1) + #expect(result?.level == 200) + #expect(result?.profileUrl == "https://example.com/profile.png") + #expect(result?.platform == .apple) + } + + // MARK: - fetchJob + + @Test("jobs 가져오기위해 interceptor 없이 호출 및 Job 반환") + func fetchJob_returnsJob() throws { + let provider = MockNetworkProvider() + provider.responseResult = jobDTO + + let sut = makeSUT(provider: provider) + + let result = try sut + .fetchJob(jobId: "3") + .toBlocking() + .first() + + #expect(provider.requestWithResponseCalled) + #expect(result?.id == 3) + #expect(result?.name == "궁수") + } + + // MARK: - updateNickName + + @Test("nickName 업데이트하면 수정된 profile 반환") + func updateNickName_returnsUpdatedProfile() throws { + let provider = MockNetworkProvider() + provider.responseResult = updatedProfileDTO + + let sut = makeSUT(provider: provider) + + let result = try sut + .updateNickName(nickName: "수정닉네임") + .toBlocking() + .first() + + #expect(provider.requestWithResponseCalled) + #expect(result?.nickname == "수정닉네임") + #expect(result?.jobId == 2) + #expect(result?.level == 210) + #expect(result?.platform == .kakao) + } + + // MARK: - updateProfileImage + + @Test("profileImage 업데이트 성공하면 complete") + func updateProfileImage_returnsComplete() throws { + let provider = MockNetworkProvider() + provider.completableResult = .empty() + + let sut = makeSUT(provider: provider) + + _ = try sut + .updateProfileImage(url: "https://example.com/image.png") + .toBlocking() + .first() + + #expect(provider.requestCompletableCalled) + } + + @Test("profileImage 업데이트 실패하면 error 방출") + func updateProfileImage_throwsError() { + let provider = MockNetworkProvider() + provider.completableResult = .error(NetworkError.httpError) + + let sut = makeSUT(provider: provider) + + #expect(throws: Error.self) { + _ = try sut + .updateProfileImage(url: "https://example.com/image.png") + .toBlocking() + .first() + } + + #expect(provider.requestCompletableCalled) + } +} + +private extension MyPageRepositoryImplTests { + func makeSUT( + provider: MockNetworkProvider = MockNetworkProvider(), + interceptor: MockInterceptor = MockInterceptor() + ) -> MyPageRepositoryImpl { + MyPageRepositoryImpl( + provider: provider, + interceptor: interceptor + ) + } +} diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/AuthUseCaseTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/AuthUseCaseTests.swift new file mode 100644 index 00000000..3d5e4140 --- /dev/null +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/AuthUseCaseTests.swift @@ -0,0 +1,171 @@ +@testable import MLSMyPageFeature + +import Testing + +import MLSAuthFeatureInterface +import MLSAuthFeatureTesting +import MLSMyPageFeatureInterface +import MLSMyPageFeatureTesting + +import RxBlocking +import RxSwift + +@Suite("FetchProfileUseCaseTests") +struct FetchProfileUseCaseTests { + @Test("job + jobName 모두 가져오기 성공") + func profileWithJobId_returnsTrue() throws { + let repo = MockMyPageRepository() + + repo.fetchProfileResult = .just( + MyPageResponse.mock() + ) + + repo.fetchJobResult = .just(Job(name: "iOS 개발자", id: 1)) + + let sut = FetchProfileUseCaseImpl(repository: repo) + + let result = try sut.execute().toBlocking().first() + + #expect(repo.fetchJobCalled == true) + #expect(repo.receivedJobId == "1") + #expect(result??.jobName == "iOS 개발자") + } + + @Test("nil인 profile 가져오면 nil 반환") + func nilProfile_returnsNil() throws { + let repo = MockMyPageRepository() + repo.fetchProfileResult = .just(nil) + + let sut = FetchProfileUseCaseImpl(repository: repo) + + let result = try sut.execute().toBlocking().first() + + #expect(result == .some(nil)) + #expect(repo.fetchJobCalled == false) + } + + @Test("잘못된 jobId이면 jobName이 빈문자열") + func noJobId_returnsFalse() throws { + let repo = MockMyPageRepository() + + repo.fetchProfileResult = .just( + MyPageResponse.mock() + ) + + let sut = FetchProfileUseCaseImpl(repository: repo) + + let result = try sut.execute().toBlocking().first() + + #expect(result??.jobName == "") + #expect(repo.fetchJobCalled == true) + } + + @Test("profile 가져오기 실패하면 error 전파") + func profileError_returnsError() { + let repo = MockMyPageRepository() + + enum DummyError: Error { case test } + + repo.fetchProfileResult = Observable.error(DummyError.test) + + let sut = FetchProfileUseCaseImpl(repository: repo) + + #expect(throws: DummyError.self) { + _ = try sut.execute().toBlocking().first() + } + } +} + +@Suite("LogoutUseCaseTests") +struct LogoutUseCaseTests { + @Test("로그아웃 성공 시 모든 토큰 삭제") + func logout_deletesAllTokens_returnsComplete() throws { + let repo = MockTokenRepository() + + _ = repo.saveToken(type: .accessToken, value: "mock_access") + _ = repo.saveToken(type: .refreshToken, value: "mock_refresh") + _ = repo.saveToken(type: .fcmToken, value: "mock_fcm") + + let sut = LogoutUseCaseImpl(repository: repo) + + _ = try sut.execute().toBlocking().first() + + switch repo.fetchToken(type: .accessToken) { + case .failure: + #expect(true) + case .success: + #expect(Bool(false), "Expected failure") + } + + switch repo.fetchToken(type: .refreshToken) { + case .failure: + #expect(true) + case .success: + #expect(Bool(false), "Expected failure") + } + + switch repo.fetchToken(type: .fcmToken) { + case .failure: + #expect(true) + case .success: + #expect(Bool(false), "Expected failure") + } + } + + @Test("delete 실패 시 에러 전파") + func deleteFailure_returnsError() { + let repo = FailingMockTokenRepository() + + let sut = LogoutUseCaseImpl(repository: repo) + + #expect(throws: Error.self) { + _ = try sut.execute().toBlocking().first() + } + } +} + +@Suite("WithdrawUseCaseTests") +struct WithdrawUseCaseTests { + @Test("회원탈퇴 성공") + func withdraw_success() throws { + let authRepo = MockAuthAPIRepository() + let tokenRepo = MockTokenRepository() + + let sut = WithdrawUseCaseImpl( + authRepository: authRepo, + tokenRepository: tokenRepo + ) + + _ = try sut.execute().toBlocking().first() + } + + @Test("성공 시 토큰 삭제") + func withdraw_deleteTokens() throws { + let authRepo = MockAuthAPIRepository() + let tokenRepo = MockTokenRepository() + + _ = tokenRepo.saveToken(type: .accessToken, value: "access") + _ = tokenRepo.saveToken(type: .refreshToken, value: "refresh") + + let sut = WithdrawUseCaseImpl( + authRepository: authRepo, + tokenRepository: tokenRepo + ) + + _ = try sut.execute().toBlocking().first() + + switch tokenRepo.fetchToken(type: .accessToken) { + case .failure: + #expect(true) + case .success: + #expect(Bool(false), "Expected failure") + } + + switch tokenRepo.fetchToken(type: .refreshToken) { + case .failure: + #expect(true) + case .success: + #expect(Bool(false), "Expected failure") + } + } +} diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift new file mode 100644 index 00000000..275933bd --- /dev/null +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift @@ -0,0 +1,75 @@ +@testable import MLSMyPageFeature + +import Testing + +import RxBlocking + +@Suite("CheckNickNameUseCase") +struct CheckNickNameUseCaseTests { + private let sut = CheckNickNameUseCaseImpl() + + @Test("nickName이 빈 문자열이면 false") + func emptyName_returnsFalse() throws { + let result = try sut.execute(nickName: "").toBlocking().first() + #expect(result == false) + } + + @Test("nickName이 1글자면 false") + func outOfRangeName_returnsFalse() throws { + let result = try sut.execute(nickName: "메").toBlocking().first() + #expect(result == false) + } + + @Test("nickName이 2~8글자면 true") + func validLengthName_returnsTrue() throws { + let result = try sut.execute(nickName: "메이플").toBlocking().first() + #expect(result == true) + } + + @Test("nickName에 특수문자가 포함이면 false") + func validName_returnsFalse() throws { + let result = try sut.execute(nickName: "*메이플").toBlocking().first() + #expect(result == false) + } + + @Test("nickName에 공백이 포함이면 false") + func blankName_returnsFalse() throws { + let result = try sut.execute(nickName: "메이 플").toBlocking().first() + #expect(result == false) + } +} + +@Suite("CheckValidLevelUseCaseTests") +struct CheckValidLevelUseCaseTests { + private let sut = CheckValidLevelUseCaseImpl() + + @Test("level이 1이면 true") + func minimumLevel_returnsTrue() { + let result = sut.execute(level: 1) + #expect(result == true) + } + + @Test("level이 200이면 true") + func maximumLevel_returnsTrue() { + let result = sut.execute(level: 200) + #expect(result == true) + } + + @Test("level이 0이면 false") + func outOfRangeLowerLevel_returnsFalse() { + let result = sut.execute(level: 0) + #expect(result == false) + } + + @Test("level이 201이면 false") + func outOfRangeUpperLevel_returnsFalse() { + let result = sut.execute(level: 201) + #expect(result == false) + } + + @Test("level이 nil이면 nil") + func nilLevel_returnsFalse() { + let result = sut.execute(level: nil) + #expect(result == nil) + } +} diff --git a/MLS/MLSMyPageFeatureExample/AppDelegate.swift b/MLS/MLSMyPageFeatureExample/AppDelegate.swift new file mode 100644 index 00000000..133694e5 --- /dev/null +++ b/MLS/MLSMyPageFeatureExample/AppDelegate.swift @@ -0,0 +1,44 @@ +// swiftlint:disable line_length + +import UIKit + +import MLSCore +import MLSDesignSystem + +@main +class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + ImageLoader.shared.configure.diskCacheCountLimit = 10 + FontManager.registerFonts() + + let center = UNUserNotificationCenter.current() + center.delegate = self + + center.getNotificationSettings { settings in + switch settings.authorizationStatus { + case .notDetermined: + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + if let error = error { + print("알림 권한 요청 실패: \(error)") + } else { + print("알림 권한 허용 여부: \(granted)") + } + } + case .denied: + print("사용자가 알림 권한 거부") + case .authorized, .provisional, .ephemeral: + print("알림 권한 이미 허용됨") + @unknown default: + break + } + } + + 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/MLSMyPageFeatureExample/Assets.xcassets/AccentColor.colorset/Contents.json b/MLS/MLSMyPageFeatureExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/MLS/MLSMyPageFeatureExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSMyPageFeatureExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/MLS/MLSMyPageFeatureExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/MLS/MLSMyPageFeatureExample/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/MLSMyPageFeatureExample/Assets.xcassets/Contents.json b/MLS/MLSMyPageFeatureExample/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/MLS/MLSMyPageFeatureExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSAuthFeatureExample/Base.lproj/Main.storyboard b/MLS/MLSMyPageFeatureExample/Base.lproj/LaunchScreen.storyboard similarity index 69% rename from MLS/MLSAuthFeatureExample/Base.lproj/Main.storyboard rename to MLS/MLSMyPageFeatureExample/Base.lproj/LaunchScreen.storyboard index 25a76385..865e9329 100644 --- a/MLS/MLSAuthFeatureExample/Base.lproj/Main.storyboard +++ b/MLS/MLSMyPageFeatureExample/Base.lproj/LaunchScreen.storyboard @@ -1,5 +1,5 @@ - - + + @@ -7,18 +7,19 @@ - + - - + + - + + diff --git a/MLS/MLSMyPageFeatureExample/Info.plist b/MLS/MLSMyPageFeatureExample/Info.plist new file mode 100644 index 00000000..0eb786dc --- /dev/null +++ b/MLS/MLSMyPageFeatureExample/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/MLS/MLSMyPageFeatureExample/SceneDelegate.swift b/MLS/MLSMyPageFeatureExample/SceneDelegate.swift new file mode 100644 index 00000000..dd1db16b --- /dev/null +++ b/MLS/MLSMyPageFeatureExample/SceneDelegate.swift @@ -0,0 +1,71 @@ +import UIKit + +import MLSAuthFeatureTesting +import MLSMyPageFeature +import MLSMyPageFeatureInterface +import MLSMyPageFeatureTesting + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + let window = UIWindow(windowScene: windowScene) + self.window = window + + let rootVC = makeMyPageViewController() + let nav = UINavigationController(rootViewController: rootVC) + nav.navigationBar.isHidden = true + window.rootViewController = nav + window.makeKeyAndVisible() + } + + func makeMyPageViewController() -> UIViewController { + let myPageRepository = MockMyPageRepository() + let authRepository = MockAuthAPIRepository() + let tokenRepository = MockTokenRepository() + let alarmRepository = MockAlarmRepository() + let notificationRepository = NotificationPermissionRepositoryImpl() + + let fetchProfileUseCase = FetchProfileUseCaseImpl(repository: myPageRepository) + + let setProfileFactory = SetProfileFactoryImpl( + selectImageFactory: SelectImageFactoryImpl(myPageRepository: myPageRepository), + checkNickNameUseCase: CheckNickNameUseCaseImpl(), + logoutUseCase: LogoutUseCaseImpl(repository: tokenRepository), + withdrawUseCase: WithdrawUseCaseImpl(authRepository: authRepository, tokenRepository: tokenRepository), + fetchProfileUseCase: fetchProfileUseCase, + myPageRepository: myPageRepository + ) + + let customerSupportFactory = CustomerSupportBaseViewFactoryImpl( + policyFactory: PolicyFactoryImpl(), + alarmRepository: alarmRepository + ) + + let notificationSettingFactory = NotificationSettingFactoryImpl( + notificationRepository: notificationRepository, + authRepository: authRepository + ) + + let setCharacterFactory = SetCharacterFactoryImpl( + checkEmptyUseCase: CheckEmptyLevelAndRoleUseCaseImpl(), + checkValidLevelUseCase: CheckValidLevelUseCaseImpl(), + authRepository: authRepository + ) + + let loginFactory = MockLoginFactory() + + let myPageFactory = MyPageMainFactoryImpl( + loginFactory: loginFactory, + setProfileFactory: setProfileFactory, + customerSupportFactory: customerSupportFactory, + notificationSettingFactory: notificationSettingFactory, + setCharacterFactory: setCharacterFactory, + fetchProfileUseCase: fetchProfileUseCase + ) + + return myPageFactory.make() + } +} diff --git a/MLS/MLSMyPageFeatureExample/ViewController.swift b/MLS/MLSMyPageFeatureExample/ViewController.swift new file mode 100644 index 00000000..edd52383 --- /dev/null +++ b/MLS/MLSMyPageFeatureExample/ViewController.swift @@ -0,0 +1,4 @@ +import UIKit + +// SceneDelegate에서 직접 MyPageMainViewController를 띄우기 때문에 사용하지 않습니다. +class ViewController: UIViewController {} diff --git a/MLS/Presentation/BaseFeature/BaseFeature/Utills/Extension/String+.swift b/MLS/Presentation/BaseFeature/BaseFeature/Utills/Extension/String+.swift index 59091f42..59cf62a1 100644 --- a/MLS/Presentation/BaseFeature/BaseFeature/Utills/Extension/String+.swift +++ b/MLS/Presentation/BaseFeature/BaseFeature/Utills/Extension/String+.swift @@ -4,22 +4,30 @@ extension String { public func isOnlyKorean() -> Bool { return !self.contains { char in guard let scalar = char.unicodeScalars.first else { return false } - return (0x3131...0x3163).contains(scalar.value) + return (0x3131 ... 0x3163).contains(scalar.value) } } - public func toDisplayDateString() -> String { - let inputFormatter = DateFormatter() - inputFormatter.locale = Locale(identifier: "ko_KR") - inputFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") - inputFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + private static let inputDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + return formatter - guard let date = inputFormatter.date(from: self) else { return self } + }() - let outputFormatter = DateFormatter() - outputFormatter.locale = Locale(identifier: "ko_KR") - outputFormatter.dateFormat = "yyyy.MM.dd HH:mm" + private static let outputDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy.MM.dd HH:mm" + return formatter + }() - return outputFormatter.string(from: date) + public func toDisplayDateString() -> String { + guard let date = Self.inputDateFormatter.date(from: self) else { + return self + } + return Self.outputDateFormatter.string(from: date) } } diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/SetProfile/SetProfileReactor.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/SetProfile/SetProfileReactor.swift index da27d604..4e10af60 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/SetProfile/SetProfileReactor.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/SetProfile/SetProfileReactor.swift @@ -4,7 +4,7 @@ import ReactorKit public final class SetProfileReactor: Reactor { // MARK: - Route - public enum Route { + public enum Route: Equatable { case none case dismiss case dismissWithUpdate @@ -28,7 +28,7 @@ public final class SetProfileReactor: Reactor { } // MARK: - Mutation - public enum Mutation { + public enum Mutation: Equatable { case toNavigate(Route) case setNickName(String) case setProfile(MyPageResponse?)