From 88e9179cc7ce221568e072ebef30abcdfd8ac4da Mon Sep 17 00:00:00 2001 From: p2glet Date: Thu, 23 Apr 2026 15:52:28 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat/#326:=20MLSMyPageFeature=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MLS/MLS.xcworkspace/contents.xcworkspacedata | 9 ++++++--- MLS/MLSMyPageFeature | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) create mode 160000 MLS/MLSMyPageFeature diff --git a/MLS/MLS.xcworkspace/contents.xcworkspacedata b/MLS/MLS.xcworkspace/contents.xcworkspacedata index 99fde6a0..a11cbf43 100644 --- a/MLS/MLS.xcworkspace/contents.xcworkspacedata +++ b/MLS/MLS.xcworkspace/contents.xcworkspacedata @@ -35,9 +35,6 @@ - - @@ -47,4 +44,10 @@ + + + + diff --git a/MLS/MLSMyPageFeature b/MLS/MLSMyPageFeature new file mode 160000 index 00000000..45d727ee --- /dev/null +++ b/MLS/MLSMyPageFeature @@ -0,0 +1 @@ +Subproject commit 45d727eef8acb62e5806140d23dcd3b0b6bfe4ab From 33d81f799946eb1d766b390578b4dd67653ea6ae Mon Sep 17 00:00:00 2001 From: p2glet Date: Mon, 27 Apr 2026 23:30:06 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat/#326:=20MyPageFeature=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20endpoint=20=EC=9C=84=EC=B9=98=20=EC=A0=95=EB=A6=BD?= =?UTF-8?q?=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=EC=BD=94=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=ED=95=84=EC=9A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20=ED=95=84=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MLS/MLS.xcodeproj/project.pbxproj | 192 +++++++++++++++++- .../BaseController/WebViewController.swift | 10 + .../Sources/MLSCore/Extension/String+.swift | 25 +++ .../CompositionalLayoutBuilder.swift | 0 .../CompositionalSectionBuilder.swift | 0 .../LayoutFactory.swift | 158 ++++++++++++++ .../Views/DescriptionBackgroundView.swift | 44 ++++ .../Views/Neutral100BackgroundView.swift | 34 ++++ .../Views/Neutral200DividerView.swift | 18 ++ .../Views/Neutral300DividerView.swift | 18 ++ .../Views/PopularSearchHeaderView.swift | 76 +++++++ .../Views/RecentSearchHeaderView.swift | 62 ++++++ .../Views/SearchDividerView.swift | 33 +++ .../Views/SettingBackgroundView.swift | 43 ++++ .../Views/SubTitleBoldHeaderView.swift | 40 ++++ .../Views/SupportBackgroundView.swift | 44 ++++ .../UICollectionReusableView+.swift | 0 MLS/MLSMyPageFeatureExample/AppDelegate.swift | 163 +++++++++++++++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++++ .../Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard} | 13 +- MLS/MLSMyPageFeatureExample/Info.plist | 23 +++ .../SceneDelegate.swift | 70 +++++++ .../ViewController.swift | 92 +++++++++ 25 files changed, 1198 insertions(+), 12 deletions(-) create mode 100644 MLS/MLSCore/Sources/MLSCore/BaseController/WebViewController.swift create mode 100644 MLS/MLSCore/Sources/MLSCore/Extension/String+.swift rename MLS/{MLSCore/Sources/MLSCore => MLSDesignSystem/Sources/MLSDesignSystem/Layouts}/CompositionalLayoutBuilder/CompositionalLayoutBuilder.swift (100%) rename MLS/{MLSCore/Sources/MLSCore => MLSDesignSystem/Sources/MLSDesignSystem/Layouts}/CompositionalLayoutBuilder/CompositionalSectionBuilder.swift (100%) create mode 100644 MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/LayoutFactory.swift create mode 100644 MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/DescriptionBackgroundView.swift create mode 100644 MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/Neutral100BackgroundView.swift create mode 100644 MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/Neutral200DividerView.swift create mode 100644 MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/Neutral300DividerView.swift create mode 100644 MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/PopularSearchHeaderView.swift create mode 100644 MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/RecentSearchHeaderView.swift create mode 100644 MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SearchDividerView.swift create mode 100644 MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SettingBackgroundView.swift create mode 100644 MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SubTitleBoldHeaderView.swift create mode 100644 MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/Views/SupportBackgroundView.swift rename MLS/{MLSCore/Sources/MLSCore/Extension => MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignExtensions}/UICollectionReusableView+.swift (100%) create mode 100644 MLS/MLSMyPageFeatureExample/AppDelegate.swift create mode 100644 MLS/MLSMyPageFeatureExample/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 MLS/MLSMyPageFeatureExample/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 MLS/MLSMyPageFeatureExample/Assets.xcassets/Contents.json rename MLS/{MLSAuthFeatureExample/Base.lproj/Main.storyboard => MLSMyPageFeatureExample/Base.lproj/LaunchScreen.storyboard} (69%) create mode 100644 MLS/MLSMyPageFeatureExample/Info.plist create mode 100644 MLS/MLSMyPageFeatureExample/SceneDelegate.swift create mode 100644 MLS/MLSMyPageFeatureExample/ViewController.swift diff --git a/MLS/MLS.xcodeproj/project.pbxproj b/MLS/MLS.xcodeproj/project.pbxproj index 2de28dda..63b860ca 100644 --- a/MLS/MLS.xcodeproj/project.pbxproj +++ b/MLS/MLS.xcodeproj/project.pbxproj @@ -34,11 +34,14 @@ 08F7AA032F86745C00EF5C06 /* MLSAuthFeatureInterface in Frameworks */ = {isa = PBXBuildFile; productRef = 08F7AA042F86745C00EF5C06 /* MLSAuthFeatureInterface */; }; 08F7AA052F86745C00EF5C06 /* MLSAuthFeatureTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 08F7AA062F86745C00EF5C06 /* MLSAuthFeatureTesting */; }; 770ADB1F2E433EDA00270506 /* RxKeyboard in Frameworks */ = {isa = PBXBuildFile; productRef = 770ADB1E2E433EDA00270506 /* RxKeyboard */; }; + 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 */; }; @@ -57,6 +60,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, ); }; }; @@ -139,6 +143,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; }; 772199F12E0E7EC800A7B58C /* AuthFeatureInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AuthFeatureInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7721A5032E0EE7AE00A7B58C /* BaseFeature.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = BaseFeature.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 77660AD12DD0D361007A4EF3 /* KakaoConfig.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = KakaoConfig.xcconfig; sourceTree = ""; }; @@ -170,6 +175,13 @@ ); target = 08F7A9222F86745C00EF5C06 /* MLSAuthFeatureExample */; }; + 77217FCF2F9A04D0000915EF /* Exceptions for "MLSMyPageFeatureExample" folder in "MLSMyPageFeatureExample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 77217FBD2F9A04CF000915EF /* MLSMyPageFeatureExample */; + }; 77FA688B2F72C7380064B6EB /* Exceptions for "MLSDesignSystemExample" folder in "MLSDesignSystemExample" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -196,6 +208,14 @@ path = MLSAuthFeatureExample; sourceTree = ""; }; + 77217FBF2F9A04CF000915EF /* MLSMyPageFeatureExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 77217FCF2F9A04D0000915EF /* Exceptions for "MLSMyPageFeatureExample" folder in "MLSMyPageFeatureExample" target */, + ); + path = MLSMyPageFeatureExample; + sourceTree = ""; + }; 77BEB0412DBA84B0002FFCFC /* MLSTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = MLSTests; @@ -257,6 +277,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; + }; 77BEB03D2DBA84B0002FFCFC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -360,6 +391,7 @@ 77BEB0412DBA84B0002FFCFC /* MLSTests */, 77FA687B2F72C7360064B6EB /* MLSDesignSystemExample */, 08F7A9242F86745C00EF5C06 /* MLSAuthFeatureExample */, + 77217FBF2F9A04CF000915EF /* MLSMyPageFeatureExample */, 084A25312DB93A5400C395C0 /* Frameworks */, 087D3EE92DA7972C002F924D /* Products */, ); @@ -372,6 +404,7 @@ 77BEB0402DBA84B0002FFCFC /* MLSTests.xctest */, 77FA687A2F72C7360064B6EB /* MLSDesignSystemExample.app */, 08F7A9232F86745C00EF5C06 /* MLSAuthFeatureExample.app */, + 77217FBE2F9A04CF000915EF /* MLSMyPageFeatureExample.app */, ); name = Products; sourceTree = ""; @@ -439,6 +472,32 @@ productReference = 08F7A9232F86745C00EF5C06 /* MLSAuthFeatureExample.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"; + }; 77BEB03F2DBA84B0002FFCFC /* MLSTests */ = { isa = PBXNativeTarget; buildConfigurationList = 77BEB0482DBA84B0002FFCFC /* Build configuration list for PBXNativeTarget "MLSTests" */; @@ -505,6 +564,9 @@ 08F7A9222F86745C00EF5C06 = { CreatedOnToolsVersion = 26.1.1; }; + 77217FBD2F9A04CF000915EF = { + CreatedOnToolsVersion = 26.1.1; + }; 77BEB03F2DBA84B0002FFCFC = { CreatedOnToolsVersion = 16.2; TestTargetID = 087D3EE72DA7972C002F924D; @@ -567,6 +629,7 @@ 77BEB03F2DBA84B0002FFCFC /* MLSTests */, 77FA68792F72C7360064B6EB /* MLSDesignSystemExample */, 08F7A9222F86745C00EF5C06 /* MLSAuthFeatureExample */, + 77217FBD2F9A04CF000915EF /* MLSMyPageFeatureExample */, ); }; /* End PBXProject section */ @@ -589,6 +652,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 77217FBC2F9A04CF000915EF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 77BEB03E2DBA84B0002FFCFC /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -641,6 +711,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 77217FBA2F9A04CF000915EF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 77BEB03C2DBA84B0002FFCFC /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -883,10 +960,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", @@ -895,12 +971,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; }; @@ -916,10 +996,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", @@ -928,12 +1007,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; }; @@ -1081,6 +1236,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 77217FD02F9A04D0000915EF /* Build configuration list for PBXNativeTarget "MLSMyPageFeatureExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 77217FD12F9A04D0000915EF /* Debug */, + 77217FD22F9A04D0000915EF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 77BEB0482DBA84B0002FFCFC /* Build configuration list for PBXNativeTarget "MLSTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1208,11 +1372,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" */; @@ -1228,6 +1404,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/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..59091f42 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Extension/String+.swift @@ -0,0 +1,25 @@ +import Foundation + +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) + } + } + + 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" + + 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..8d734a6f --- /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 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 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 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 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 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 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 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/MLSMyPageFeatureExample/AppDelegate.swift b/MLS/MLSMyPageFeatureExample/AppDelegate.swift new file mode 100644 index 00000000..653802e4 --- /dev/null +++ b/MLS/MLSMyPageFeatureExample/AppDelegate.swift @@ -0,0 +1,163 @@ +// swiftlint:disable line_length + +import UIKit + +import MLSCore +import MLSDesignSystem +import MLSMyPageFeature +import MLSMyPageFeatureInterface +import MLSMyPageFeatureTesting + +@main +class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + ImageLoader.shared.configure.diskCacheCountLimit = 10 + FontManager.registerFonts() +// registerDependencies() + + 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) {} +} + +//private extension AppDelegate { +// func registerDependencies() { +// registerUseCase() +// registerFactory() +// } +// +// func registerUseCase() { +// DIContainer.register(type: CheckNickNameUseCase.self) { +// CheckNickNameUseCaseImpl() +// } +// DIContainer.register(type: CheckEmptyLevelAndRoleUseCase.self) { +// CheckEmptyLevelAndRoleUseCaseImpl() +// } +// DIContainer.register(type: CheckValidLevelUseCase.self) { +// CheckValidLevelUseCaseImpl() +// } +// DIContainer.register(type: FetchJobListUseCase.self) { +// FetchJobListUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) +// } +// DIContainer.register(type: UpdateUserInfoUseCase.self) { +// UpdateUserInfoUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) +// } +// DIContainer.register(type: UpdateNickNameUseCase.self) { +// UpdateNickNameUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) +// } +// DIContainer.register(type: LogoutUseCase.self) { +// LogoutUseCaseImpl(repository: DIContainer.resolve(type: TokenRepository.self)) +// } +// DIContainer.register(type: WithdrawUseCase.self) { +// WithdrawUseCaseImpl(authRepository: DIContainer.resolve(type: AuthAPIRepository.self), tokenRepository: DIContainer.resolve(type: TokenRepository.self)) +// } +// DIContainer.register(type: FetchTokenFromLocalUseCase.self) { +// FetchTokenFromLocalUseCaseImpl(repository: DIContainer.resolve(type: TokenRepository.self)) +// } +// DIContainer.register(type: FetchNoticesUseCase.self) { +// FetchNoticesUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) +// } +// DIContainer.register(type: FetchOngoingEventsUseCase.self) { +// FetchOngoingEventsUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) +// } +// DIContainer.register(type: FetchOutdatedEventsUseCase.self) { +// FetchOutdatedEventsUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) +// } +// DIContainer.register(type: FetchPatchNotesUseCase.self) { +// FetchPatchNotesUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) +// } +// DIContainer.register(type: SetReadUseCase.self) { +// SetReadUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) +// } +// DIContainer.register(type: CheckNotificationPermissionUseCase.self) { +// CheckNotificationPermissionUseCaseImpl() +// } +// DIContainer.register(type: UpdateNotificationAgreementUseCase.self) { +// UpdateNotificationAgreementUseCaseImpl(authRepository: DIContainer.resolve(type: AuthAPIRepository.self)) +// } +// DIContainer.register(type: UpdateProfileImageUseCase.self) { +// UpdateProfileImageUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) +// } +// DIContainer.register(type: FetchJobUseCase.self) { +// FetchJobUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) +// } +// DIContainer.register(type: FetchProfileUseCase.self) { +// FetchProfileUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self), fetchJobUseCase: DIContainer.resolve(type: FetchJobUseCase.self)) +// } +// } +// +// func registerFactory() { +// DIContainer.register(type: SelectImageFactory.self) { +// SelectImageFactoryImpl(updateProfileImageUseCase: DIContainer.resolve(type: UpdateProfileImageUseCase.self)) +// } +// +// DIContainer.register(type: SetProfileFactory.self) { +// SetProfileFactoryImpl(selectImageFactory: DIContainer.resolve(type: SelectImageFactory.self), checkNickNameUseCase: DIContainer.resolve(type: CheckNickNameUseCase.self), updateNickNameUseCase: DIContainer.resolve(type: UpdateNickNameUseCase.self), logoutUseCase: DIContainer.resolve(type: LogoutUseCase.self), withdrawUseCase: DIContainer.resolve(type: WithdrawUseCase.self), fetchProfileUseCase: DIContainer.resolve(type: FetchProfileUseCase.self)) +// } +// +// DIContainer.register(type: SetCharacterFactory.self) { +// SetCharacterFactoryImpl( +// checkEmptyUseCase: DIContainer +// .resolve(type: CheckEmptyLevelAndRoleUseCase.self), +// checkValidLevelUseCase: DIContainer +// .resolve(type: CheckValidLevelUseCase.self), +// fetchJobListUseCase: DIContainer +// .resolve(type: FetchJobListUseCase.self), +// updateUserInfoUseCase: DIContainer +// .resolve(type: UpdateUserInfoUseCase.self) +// ) +// } +// +// DIContainer.register(type: MyPageMainFactory.self) { +// MyPageMainFactoryImpl( +// loginFactory: DIContainer.resolve(type: LoginFactory.self), setProfileFactory: DIContainer +// .resolve(type: SetProfileFactory.self), +// customerSupportFactory: DIContainer +// .resolve(type: CustomerSupportFactory.self), +// notificationSettingFactory: DIContainer +// .resolve(type: NotificationSettingFactory.self), +// setCharacterFactory: DIContainer +// .resolve(type: SetCharacterFactory.self), fetchProfileUseCase: DIContainer.resolve(type: FetchProfileUseCase.self) +// ) +// } +// +// DIContainer.register(type: CustomerSupportFactory.self) { +// CustomerSupportBaseViewFactoryImpl(policyFactory: DIContainer.resolve(type: PolicyFactory.self), fetchNoticesUseCase: DIContainer.resolve(type: FetchNoticesUseCase.self), fetchOngoingEventsUseCase: DIContainer.resolve(type: FetchOngoingEventsUseCase.self), fetchOutdatedEventsUseCase: DIContainer.resolve(type: FetchOutdatedEventsUseCase.self), fetchPatchNotesUseCase: DIContainer.resolve(type: FetchPatchNotesUseCase.self), setReadUseCase: DIContainer.resolve(type: SetReadUseCase.self)) +// } +// +// DIContainer.register(type: NotificationSettingFactory.self) { +// NotificationSettingFactoryImpl(checkNotificationPermissionUseCase: DIContainer.resolve(type: CheckNotificationPermissionUseCase.self), updateNotificationAgreementUseCase: DIContainer.resolve(type: UpdateNotificationAgreementUseCase.self)) +// } +// +// DIContainer.register(type: LoginFactory.self) { +// MockLoginFactory() +// } +// } +//} 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..458c2059 --- /dev/null +++ b/MLS/MLSMyPageFeatureExample/SceneDelegate.swift @@ -0,0 +1,70 @@ +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 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( + checkNotificationPermissionUseCase: CheckNotificationPermissionUseCaseImpl(), + 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..c0ce3ae6 --- /dev/null +++ b/MLS/MLSMyPageFeatureExample/ViewController.swift @@ -0,0 +1,92 @@ +import UIKit + +import MLSCore +import MLSDesignSystem +import MLSMyPageFeatureInterface + +import SnapKit + +class ViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + } +} + +//class ViewController: UIViewController { +// let tableView: UITableView = { +// let view = UITableView(frame: .zero, style: .plain) +// return view +// }() +// +// lazy var views: [[UIViewController]] = { +// let mainView = BottomTabBarController(viewControllers: [ +// DIContainer.resolve(type: MyPageMainFactory.self).make() +// ]) +// let announceView = BottomTabBarController(viewControllers: [ +// DIContainer.resolve(type: CustomerSupportFactory.self).make(type: .announcement) +// ]) +// +// let eventView = BottomTabBarController(viewControllers: [ +// DIContainer.resolve(type: CustomerSupportFactory.self).make(type: .event) +// ]) +// +// let patchView = BottomTabBarController(viewControllers: [ +// DIContainer.resolve(type: CustomerSupportFactory.self).make(type: .patchNote) +// ]) +// +// let termsView = BottomTabBarController(viewControllers: [ +// DIContainer.resolve(type: CustomerSupportFactory.self).make(type: .terms) +// ]) +// +// let notiView = BottomTabBarController(viewControllers: [ +// DIContainer.resolve(type: NotificationSettingFactory.self).make(isAgreeEventNotification: false, isAgreeNoticeNotification: false, isAgreePatchNoteNotification: false) +// ]) +// +// mainView.title = "마이페이지 메인" +// announceView.title = "공지사항" +// eventView.title = "이벤트" +// patchView.title = "패치 노트" +// termsView.title = "약관" +// notiView.title = "알림설정" +// +// return [ +// [mainView, announceView, eventView, patchView, termsView, notiView] +// ] +// }() +// +// override func viewDidLoad() { +// super.viewDidLoad() +// view.backgroundColor = .systemBackground +// tableView.dataSource = self +// tableView.delegate = self +// navigationItem.title = "MLS Feature System" +// view.addSubview(tableView) +// +// tableView.snp.makeConstraints { make in +// make.edges.equalTo(view.safeAreaLayoutGuide) +// } +// } +//} +// +//extension ViewController: UITableViewDataSource, UITableViewDelegate { +// func numberOfSections(in tableView: UITableView) -> Int { +// return views.count +// } +// +// func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { +// return views[section].count +// } +// +// func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { +// let cell = UITableViewCell() +// cell.textLabel?.text = views[indexPath.section][indexPath.row].title +// cell.selectionStyle = .none +// return cell +// } +// +// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { +// let nextController = views[indexPath.section][indexPath.row] +// navigationController?.pushViewController(nextController, animated: true) +// } +//} From afb0a93ec35006d270e1638f8071ce71575942cd Mon Sep 17 00:00:00 2001 From: p2glet Date: Thu, 30 Apr 2026 15:29:44 +0900 Subject: [PATCH 3/7] =?UTF-8?q?test/#326:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20Reactor=20/=20Reposit?= =?UTF-8?q?ory=20/=20UseCase=EC=97=90=20=EB=8C=80=ED=95=B4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MLS/MLSMyPageFeatureExample/AppDelegate.swift | 119 ------------------ .../SceneDelegate.swift | 3 +- .../ViewController.swift | 92 +------------- .../SetProfile/SetProfileReactor.swift | 4 +- 4 files changed, 6 insertions(+), 212 deletions(-) diff --git a/MLS/MLSMyPageFeatureExample/AppDelegate.swift b/MLS/MLSMyPageFeatureExample/AppDelegate.swift index 653802e4..133694e5 100644 --- a/MLS/MLSMyPageFeatureExample/AppDelegate.swift +++ b/MLS/MLSMyPageFeatureExample/AppDelegate.swift @@ -4,16 +4,12 @@ import UIKit import MLSCore import MLSDesignSystem -import MLSMyPageFeature -import MLSMyPageFeatureInterface -import MLSMyPageFeatureTesting @main class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { ImageLoader.shared.configure.diskCacheCountLimit = 10 FontManager.registerFonts() -// registerDependencies() let center = UNUserNotificationCenter.current() center.delegate = self @@ -46,118 +42,3 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {} } - -//private extension AppDelegate { -// func registerDependencies() { -// registerUseCase() -// registerFactory() -// } -// -// func registerUseCase() { -// DIContainer.register(type: CheckNickNameUseCase.self) { -// CheckNickNameUseCaseImpl() -// } -// DIContainer.register(type: CheckEmptyLevelAndRoleUseCase.self) { -// CheckEmptyLevelAndRoleUseCaseImpl() -// } -// DIContainer.register(type: CheckValidLevelUseCase.self) { -// CheckValidLevelUseCaseImpl() -// } -// DIContainer.register(type: FetchJobListUseCase.self) { -// FetchJobListUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) -// } -// DIContainer.register(type: UpdateUserInfoUseCase.self) { -// UpdateUserInfoUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) -// } -// DIContainer.register(type: UpdateNickNameUseCase.self) { -// UpdateNickNameUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) -// } -// DIContainer.register(type: LogoutUseCase.self) { -// LogoutUseCaseImpl(repository: DIContainer.resolve(type: TokenRepository.self)) -// } -// DIContainer.register(type: WithdrawUseCase.self) { -// WithdrawUseCaseImpl(authRepository: DIContainer.resolve(type: AuthAPIRepository.self), tokenRepository: DIContainer.resolve(type: TokenRepository.self)) -// } -// DIContainer.register(type: FetchTokenFromLocalUseCase.self) { -// FetchTokenFromLocalUseCaseImpl(repository: DIContainer.resolve(type: TokenRepository.self)) -// } -// DIContainer.register(type: FetchNoticesUseCase.self) { -// FetchNoticesUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) -// } -// DIContainer.register(type: FetchOngoingEventsUseCase.self) { -// FetchOngoingEventsUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) -// } -// DIContainer.register(type: FetchOutdatedEventsUseCase.self) { -// FetchOutdatedEventsUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) -// } -// DIContainer.register(type: FetchPatchNotesUseCase.self) { -// FetchPatchNotesUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) -// } -// DIContainer.register(type: SetReadUseCase.self) { -// SetReadUseCaseImpl(repository: DIContainer.resolve(type: AlarmAPIRepository.self)) -// } -// DIContainer.register(type: CheckNotificationPermissionUseCase.self) { -// CheckNotificationPermissionUseCaseImpl() -// } -// DIContainer.register(type: UpdateNotificationAgreementUseCase.self) { -// UpdateNotificationAgreementUseCaseImpl(authRepository: DIContainer.resolve(type: AuthAPIRepository.self)) -// } -// DIContainer.register(type: UpdateProfileImageUseCase.self) { -// UpdateProfileImageUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) -// } -// DIContainer.register(type: FetchJobUseCase.self) { -// FetchJobUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self)) -// } -// DIContainer.register(type: FetchProfileUseCase.self) { -// FetchProfileUseCaseImpl(repository: DIContainer.resolve(type: AuthAPIRepository.self), fetchJobUseCase: DIContainer.resolve(type: FetchJobUseCase.self)) -// } -// } -// -// func registerFactory() { -// DIContainer.register(type: SelectImageFactory.self) { -// SelectImageFactoryImpl(updateProfileImageUseCase: DIContainer.resolve(type: UpdateProfileImageUseCase.self)) -// } -// -// DIContainer.register(type: SetProfileFactory.self) { -// SetProfileFactoryImpl(selectImageFactory: DIContainer.resolve(type: SelectImageFactory.self), checkNickNameUseCase: DIContainer.resolve(type: CheckNickNameUseCase.self), updateNickNameUseCase: DIContainer.resolve(type: UpdateNickNameUseCase.self), logoutUseCase: DIContainer.resolve(type: LogoutUseCase.self), withdrawUseCase: DIContainer.resolve(type: WithdrawUseCase.self), fetchProfileUseCase: DIContainer.resolve(type: FetchProfileUseCase.self)) -// } -// -// DIContainer.register(type: SetCharacterFactory.self) { -// SetCharacterFactoryImpl( -// checkEmptyUseCase: DIContainer -// .resolve(type: CheckEmptyLevelAndRoleUseCase.self), -// checkValidLevelUseCase: DIContainer -// .resolve(type: CheckValidLevelUseCase.self), -// fetchJobListUseCase: DIContainer -// .resolve(type: FetchJobListUseCase.self), -// updateUserInfoUseCase: DIContainer -// .resolve(type: UpdateUserInfoUseCase.self) -// ) -// } -// -// DIContainer.register(type: MyPageMainFactory.self) { -// MyPageMainFactoryImpl( -// loginFactory: DIContainer.resolve(type: LoginFactory.self), setProfileFactory: DIContainer -// .resolve(type: SetProfileFactory.self), -// customerSupportFactory: DIContainer -// .resolve(type: CustomerSupportFactory.self), -// notificationSettingFactory: DIContainer -// .resolve(type: NotificationSettingFactory.self), -// setCharacterFactory: DIContainer -// .resolve(type: SetCharacterFactory.self), fetchProfileUseCase: DIContainer.resolve(type: FetchProfileUseCase.self) -// ) -// } -// -// DIContainer.register(type: CustomerSupportFactory.self) { -// CustomerSupportBaseViewFactoryImpl(policyFactory: DIContainer.resolve(type: PolicyFactory.self), fetchNoticesUseCase: DIContainer.resolve(type: FetchNoticesUseCase.self), fetchOngoingEventsUseCase: DIContainer.resolve(type: FetchOngoingEventsUseCase.self), fetchOutdatedEventsUseCase: DIContainer.resolve(type: FetchOutdatedEventsUseCase.self), fetchPatchNotesUseCase: DIContainer.resolve(type: FetchPatchNotesUseCase.self), setReadUseCase: DIContainer.resolve(type: SetReadUseCase.self)) -// } -// -// DIContainer.register(type: NotificationSettingFactory.self) { -// NotificationSettingFactoryImpl(checkNotificationPermissionUseCase: DIContainer.resolve(type: CheckNotificationPermissionUseCase.self), updateNotificationAgreementUseCase: DIContainer.resolve(type: UpdateNotificationAgreementUseCase.self)) -// } -// -// DIContainer.register(type: LoginFactory.self) { -// MockLoginFactory() -// } -// } -//} diff --git a/MLS/MLSMyPageFeatureExample/SceneDelegate.swift b/MLS/MLSMyPageFeatureExample/SceneDelegate.swift index 458c2059..dd1db16b 100644 --- a/MLS/MLSMyPageFeatureExample/SceneDelegate.swift +++ b/MLS/MLSMyPageFeatureExample/SceneDelegate.swift @@ -26,6 +26,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let authRepository = MockAuthAPIRepository() let tokenRepository = MockTokenRepository() let alarmRepository = MockAlarmRepository() + let notificationRepository = NotificationPermissionRepositoryImpl() let fetchProfileUseCase = FetchProfileUseCaseImpl(repository: myPageRepository) @@ -44,7 +45,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) let notificationSettingFactory = NotificationSettingFactoryImpl( - checkNotificationPermissionUseCase: CheckNotificationPermissionUseCaseImpl(), + notificationRepository: notificationRepository, authRepository: authRepository ) diff --git a/MLS/MLSMyPageFeatureExample/ViewController.swift b/MLS/MLSMyPageFeatureExample/ViewController.swift index c0ce3ae6..edd52383 100644 --- a/MLS/MLSMyPageFeatureExample/ViewController.swift +++ b/MLS/MLSMyPageFeatureExample/ViewController.swift @@ -1,92 +1,4 @@ import UIKit -import MLSCore -import MLSDesignSystem -import MLSMyPageFeatureInterface - -import SnapKit - -class ViewController: UIViewController { - - override func viewDidLoad() { - super.viewDidLoad() - } -} - -//class ViewController: UIViewController { -// let tableView: UITableView = { -// let view = UITableView(frame: .zero, style: .plain) -// return view -// }() -// -// lazy var views: [[UIViewController]] = { -// let mainView = BottomTabBarController(viewControllers: [ -// DIContainer.resolve(type: MyPageMainFactory.self).make() -// ]) -// let announceView = BottomTabBarController(viewControllers: [ -// DIContainer.resolve(type: CustomerSupportFactory.self).make(type: .announcement) -// ]) -// -// let eventView = BottomTabBarController(viewControllers: [ -// DIContainer.resolve(type: CustomerSupportFactory.self).make(type: .event) -// ]) -// -// let patchView = BottomTabBarController(viewControllers: [ -// DIContainer.resolve(type: CustomerSupportFactory.self).make(type: .patchNote) -// ]) -// -// let termsView = BottomTabBarController(viewControllers: [ -// DIContainer.resolve(type: CustomerSupportFactory.self).make(type: .terms) -// ]) -// -// let notiView = BottomTabBarController(viewControllers: [ -// DIContainer.resolve(type: NotificationSettingFactory.self).make(isAgreeEventNotification: false, isAgreeNoticeNotification: false, isAgreePatchNoteNotification: false) -// ]) -// -// mainView.title = "마이페이지 메인" -// announceView.title = "공지사항" -// eventView.title = "이벤트" -// patchView.title = "패치 노트" -// termsView.title = "약관" -// notiView.title = "알림설정" -// -// return [ -// [mainView, announceView, eventView, patchView, termsView, notiView] -// ] -// }() -// -// override func viewDidLoad() { -// super.viewDidLoad() -// view.backgroundColor = .systemBackground -// tableView.dataSource = self -// tableView.delegate = self -// navigationItem.title = "MLS Feature System" -// view.addSubview(tableView) -// -// tableView.snp.makeConstraints { make in -// make.edges.equalTo(view.safeAreaLayoutGuide) -// } -// } -//} -// -//extension ViewController: UITableViewDataSource, UITableViewDelegate { -// func numberOfSections(in tableView: UITableView) -> Int { -// return views.count -// } -// -// func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { -// return views[section].count -// } -// -// func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { -// let cell = UITableViewCell() -// cell.textLabel?.text = views[indexPath.section][indexPath.row].title -// cell.selectionStyle = .none -// return cell -// } -// -// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { -// let nextController = views[indexPath.section][indexPath.row] -// navigationController?.pushViewController(nextController, animated: true) -// } -//} +// SceneDelegate에서 직접 MyPageMainViewController를 띄우기 때문에 사용하지 않습니다. +class ViewController: UIViewController {} 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?) From 3512d3edbf8833b6c506c55c645863b85e04a1ac Mon Sep 17 00:00:00 2001 From: p2glet Date: Thu, 30 Apr 2026 16:08:16 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix/326:=20git=20=EC=84=A4=EC=A0=95=20git?= =?UTF-8?q?=20=EB=AA=A8=EB=93=88=20=EC=9E=AC=EC=84=A4=EC=A0=95..=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EC=BB=A4=EB=B0=8B=20=EC=9E=98=EB=AA=BB=20?= =?UTF-8?q?=EC=B0=8D=ED=9E=8C=EB=93=AF..?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MLS/MLSMyPageFeature | 1 - MLS/MLSMyPageFeature/.gitignore | 8 + .../xcschemes/MLSMyPageFeature.xcscheme | 79 ++++ .../MLSMyPageFeatureTesting.xcscheme | 67 +++ MLS/MLSMyPageFeature/Package.swift | 83 ++++ .../Data/DTOs/AlarmResponseDTO.swift | 91 ++++ .../MLSMyPageFeature/Data/DTOs/JobsDTO.swift | 23 + .../Data/DTOs/MemberDTO.swift | 29 ++ .../Data/Endpoints/AlarmEndPoint.swift | 60 +++ .../Data/Endpoints/MyPageEndpoint.swift | 41 ++ .../Repositories/AlarmAPIRepositoryImpl.swift | 63 +++ .../Repositories/MyPageRepositoryImpl.swift | 46 ++ ...NotificationPermissionRepositoryImpl.swift | 19 + .../CheckEmptyLevelAndRoleUseCaseImpl.swift | 13 + .../Usecases/CheckNickNameUseCaseImpl.swift | 19 + .../Usecases/CheckValidLevelUseCaseImpl.swift | 14 + .../Usecases/FetchProfileUseCaseImpl.swift | 27 ++ .../Domain/Usecases/LogoutUseCaseImpl.swift | 43 ++ .../Domain/Usecases/WithdrawUseCaseImpl.swift | 35 ++ .../Announcement/AnnouncementReactor.swift | 84 ++++ .../AnnouncementViewController.swift | 62 +++ .../CustomerSupportBaseView.swift | 286 ++++++++++++ .../CustomerSupportBaseViewController.swift | 159 +++++++ .../CustomerSupportBaseViewFactoryImpl.swift | 43 ++ .../CustomerSupport/Event/EventReactor.swift | 99 ++++ .../Event/EventViewController.swift | 102 ++++ .../PatchNote/PatchNoteReactor.swift | 84 ++++ .../PatchNote/PatchNoteViewController.swift | 62 +++ .../Policy/PolicyFactoryImpl.swift | 12 + .../CustomerSupport/Policy/PolicyView.swift | 86 ++++ .../Policy/PolicyViewController.swift | 60 +++ .../Policy/TermsViewController.swift | 16 + .../Presentation/Main/MyPageListCell.swift | 88 ++++ .../Presentation/Main/MyPageMainCell.swift | 131 ++++++ .../Main/MyPageMainFactoryImpl.swift | 40 ++ .../Presentation/Main/MyPageMainReactor.swift | 150 ++++++ .../Presentation/Main/MyPageMainView.swift | 68 +++ .../Main/MyPageMainViewController.swift | 266 +++++++++++ .../NotificationSettingFactoryImpl.swift | 26 ++ .../NotificationSettingItemView.swift | 107 +++++ .../NotificationSettingReactor.swift | 101 ++++ .../NotificationSettingView.swift | 121 +++++ .../NotificationSettingViewController.swift | 166 +++++++ .../SelectImage/SelectImageCell.swift | 118 +++++ .../SelectImage/SelectImageFactoryImpl.swift | 18 + .../SelectImage/SelectImageReactor.swift | 82 ++++ .../SelectImage/SelectImageView.swift | 68 +++ .../SelectImageViewContoller.swift | 120 +++++ .../SetCharacterFactoryImpl.swift | 30 ++ .../SetCharacter/SetCharacterReactor.swift | 113 +++++ .../SetCharacter/SetCharacterView.swift | 56 +++ .../SetCharacterViewController.swift | 140 ++++++ .../SetProfile/SetProfileFactoryImpl.swift | 34 ++ .../SetProfile/SetProfileReactor.swift | 149 ++++++ .../SetProfile/SetProfileView.swift | 437 ++++++++++++++++++ .../SetProfile/SetProfileViewController.swift | 217 +++++++++ .../Entities/AlarmResponse.swift | 45 ++ .../Entities/CustomerSupportType.swift | 19 + .../Entities/MyPageResponse.swift | 41 ++ .../Entities/PolicyType.swift | 40 ++ .../Factories/CustomerSupportFactory.swift | 5 + .../Factories/MyPageMainFactory.swift | 5 + .../NotificationSettingFactory.swift | 5 + .../Factories/PolicyFactory.swift | 5 + .../Factories/SelectImageFactory.swift | 6 + .../Factories/SetCharacterFactory.swift | 5 + .../Factories/SetProfileFactory.swift | 5 + .../Repositories/AlarmRepository.swift | 15 + .../Repositories/MyPageRepository.swift | 10 + .../NotificationPermissionRepository.swift | 5 + .../UseCases/CheckNickNameUseCase.swift | 9 + .../UseCases/FetchAllAlarmUseCase.swift | 5 + .../UseCases/FetchProfileUseCase.swift | 5 + .../UseCases/LogoutUseCase.swift | 5 + .../UseCases/WithdrawUseCase.swift | 5 + .../Mock/MockAlarmRepository.swift | 82 ++++ .../Mock/MockInterceptor.swift | 39 ++ .../Mock/MockLoginFactory.swift | 12 + .../Mock/MockMyPageRepository.swift | 36 ++ .../Mock/MockNetworkProvider.swift | 53 +++ ...MockNotificationPermissionRepository.swift | 18 + .../Reactor/CustomerSupportReactorTests.swift | 113 +++++ .../Reactor/MyPageMainReactorTests.swift | 166 +++++++ .../NotificationSettingReactorTests.swift | 173 +++++++ .../Reactor/SetCharacterReactorTests.swift | 167 +++++++ .../Reactor/SetProfileReactorTests.swift | 87 ++++ .../Repository/AlarmRepositoryTests.swift | 206 +++++++++ .../Repository/MyPageRepositoryTests.swift | 155 +++++++ .../UseCase/AuthUseCaseTests.swift | 171 +++++++ .../UseCase/CheckUseCaseTests.swift | 75 +++ 90 files changed, 6554 insertions(+), 1 deletion(-) delete mode 160000 MLS/MLSMyPageFeature create mode 100644 MLS/MLSMyPageFeature/.gitignore create mode 100644 MLS/MLSMyPageFeature/.swiftpm/xcode/xcshareddata/xcschemes/MLSMyPageFeature.xcscheme create mode 100644 MLS/MLSMyPageFeature/.swiftpm/xcode/xcshareddata/xcschemes/MLSMyPageFeatureTesting.xcscheme create mode 100644 MLS/MLSMyPageFeature/Package.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/AlarmResponseDTO.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/JobsDTO.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/MemberDTO.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Endpoints/AlarmEndPoint.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Endpoints/MyPageEndpoint.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/AlarmAPIRepositoryImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/MyPageRepositoryImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/NotificationPermissionRepositoryImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/CheckEmptyLevelAndRoleUseCaseImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/CheckNickNameUseCaseImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/CheckValidLevelUseCaseImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/FetchProfileUseCaseImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/LogoutUseCaseImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Domain/Usecases/WithdrawUseCaseImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Announcement/AnnouncementReactor.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Announcement/AnnouncementViewController.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/CustomerSupportBaseView.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/CustomerSupportBaseViewController.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/CustomerSupportBaseViewFactoryImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Event/EventReactor.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Event/EventViewController.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/PatchNote/PatchNoteReactor.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/PatchNote/PatchNoteViewController.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/PolicyFactoryImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/PolicyView.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/PolicyViewController.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/CustomerSupport/Policy/TermsViewController.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageListCell.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainCell.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainFactoryImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainReactor.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainView.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/Main/MyPageMainViewController.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingFactoryImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingItemView.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingReactor.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingView.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/NotificationSetting/NotificationSettingViewController.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageCell.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageFactoryImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageReactor.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageView.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SelectImage/SelectImageViewContoller.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterFactoryImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterReactor.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterView.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetCharacter/SetCharacterViewController.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileFactoryImpl.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileReactor.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileView.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileViewController.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/AlarmResponse.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/CustomerSupportType.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/MyPageResponse.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Entities/PolicyType.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/CustomerSupportFactory.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/MyPageMainFactory.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/NotificationSettingFactory.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/PolicyFactory.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/SelectImageFactory.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/SetCharacterFactory.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Factories/SetProfileFactory.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Repositories/AlarmRepository.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Repositories/MyPageRepository.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/Repositories/NotificationPermissionRepository.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/CheckNickNameUseCase.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/FetchAllAlarmUseCase.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/FetchProfileUseCase.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/LogoutUseCase.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureInterface/UseCases/WithdrawUseCase.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockAlarmRepository.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockInterceptor.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockLoginFactory.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockMyPageRepository.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNetworkProvider.swift create mode 100644 MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNotificationPermissionRepository.swift create mode 100644 MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/CustomerSupportReactorTests.swift create mode 100644 MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/MyPageMainReactorTests.swift create mode 100644 MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/NotificationSettingReactorTests.swift create mode 100644 MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetCharacterReactorTests.swift create mode 100644 MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetProfileReactorTests.swift create mode 100644 MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/AlarmRepositoryTests.swift create mode 100644 MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/MyPageRepositoryTests.swift create mode 100644 MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/AuthUseCaseTests.swift create mode 100644 MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift diff --git a/MLS/MLSMyPageFeature b/MLS/MLSMyPageFeature deleted file mode 160000 index 45d727ee..00000000 --- a/MLS/MLSMyPageFeature +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 45d727eef8acb62e5806140d23dcd3b0b6bfe4ab 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..b59bdcb2 --- /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..3f3742df --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Endpoints/MyPageEndpoint.swift @@ -0,0 +1,41 @@ +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..fc1299b0 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/MyPageRepositoryImpl.swift @@ -0,0 +1,46 @@ +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..36d432db --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileView.swift @@ -0,0 +1,437 @@ +import UIKit + +import MLSCore +import MLSDesignSystem +import MLSAuthFeatureInterface + +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..74543865 --- /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..6c351ef8 --- /dev/null +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNetworkProvider.swift @@ -0,0 +1,53 @@ +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..8796c8d4 --- /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..af29fa56 --- /dev/null +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/CustomerSupportReactorTests.swift @@ -0,0 +1,113 @@ +@testable import MLSMyPageFeature + +import Testing + +import MLSMyPageFeatureInterface +import MLSMyPageFeatureTesting + +import RxSwift +import RxBlocking +import ReactorKit + +@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..9c9564d2 --- /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..8fb78e9c --- /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 RxSwift +import RxBlocking +import ReactorKit + +@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..660e0d50 --- /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..a1cf5a13 --- /dev/null +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/MyPageRepositoryTests.swift @@ -0,0 +1,155 @@ +@testable import MLSMyPageFeature + +import Testing + +import MLSCore +import MLSAuthFeatureInterface +import MLSMyPageFeatureInterface +import MLSMyPageFeatureTesting + +import RxSwift +import RxBlocking + +@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..82b68dc3 --- /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) + } +} From b944e4b557d7409ded2d331905c255509ceb4fd5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 07:08:59 +0000 Subject: [PATCH 5/7] style/#326: Apply SwiftLint autocorrect --- .../CompositionalLayoutBuilder/LayoutFactory.swift | 2 +- .../MLSMyPageFeature/Data/DTOs/MemberDTO.swift | 2 +- .../Data/Endpoints/MyPageEndpoint.swift | 3 +-- .../Data/Repositories/MyPageRepositoryImpl.swift | 7 +++---- .../Presentation/SetProfile/SetProfileView.swift | 2 +- .../Mock/MockLoginFactory.swift | 2 +- .../Mock/MockNetworkProvider.swift | 2 -- .../Mock/MockNotificationPermissionRepository.swift | 2 +- .../Reactor/CustomerSupportReactorTests.swift | 6 +++--- .../Reactor/SetCharacterReactorTests.swift | 12 ++++++------ .../Reactor/SetProfileReactorTests.swift | 4 ++-- .../Repository/AlarmRepositoryTests.swift | 4 ++-- .../Repository/MyPageRepositoryTests.swift | 4 ++-- .../UseCase/CheckUseCaseTests.swift | 10 +++++----- 14 files changed, 29 insertions(+), 33 deletions(-) diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/LayoutFactory.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/LayoutFactory.swift index 8d734a6f..9cd012b4 100644 --- a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/LayoutFactory.swift +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/LayoutFactory.swift @@ -1,6 +1,6 @@ import UIKit -@MainActor +@MainActor public class LayoutFactory { public init() {} diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/MemberDTO.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/MemberDTO.swift index b59bdcb2..09320180 100644 --- a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/MemberDTO.swift +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/DTOs/MemberDTO.swift @@ -12,7 +12,7 @@ public struct MemberDTO: Decodable { public let jobId: Int? public let level: Int? public let profileImageUrl: String - + func toDomain() -> MyPageResponse { return .init( nickname: nickname, diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Endpoints/MyPageEndpoint.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Endpoints/MyPageEndpoint.swift index 3f3742df..416a4722 100644 --- a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Endpoints/MyPageEndpoint.swift +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Endpoints/MyPageEndpoint.swift @@ -12,7 +12,6 @@ public enum MyPageEndpoint { ) } - public static func fetchJob(jobId: String) -> ResponsableEndPoint { .init( baseURL: base, @@ -20,7 +19,7 @@ public enum MyPageEndpoint { method: .GET ) } - + public static func updateNickName(body: Encodable) -> ResponsableEndPoint { .init( baseURL: base, diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/MyPageRepositoryImpl.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/MyPageRepositoryImpl.swift index fc1299b0..59012969 100644 --- a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/MyPageRepositoryImpl.swift +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Data/Repositories/MyPageRepositoryImpl.swift @@ -12,25 +12,24 @@ public class MyPageRepositoryImpl: MyPageRepository { 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) diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileView.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileView.swift index 36d432db..f6a32f91 100644 --- a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileView.swift +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeature/Presentation/SetProfile/SetProfileView.swift @@ -1,8 +1,8 @@ import UIKit +import MLSAuthFeatureInterface import MLSCore import MLSDesignSystem -import MLSAuthFeatureInterface import RxCocoa import RxSwift diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockLoginFactory.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockLoginFactory.swift index 74543865..cf87876b 100644 --- a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockLoginFactory.swift +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockLoginFactory.swift @@ -7,6 +7,6 @@ public final class MockLoginFactory: LoginFactory { viewcontroller.view.backgroundColor = .redMLS return viewcontroller } - + public init() {} } diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNetworkProvider.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNetworkProvider.swift index 6c351ef8..bc6c6c1d 100644 --- a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNetworkProvider.swift +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNetworkProvider.swift @@ -12,13 +12,11 @@ public final class MockNetworkProvider: NetworkProvider { 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? diff --git a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNotificationPermissionRepository.swift b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNotificationPermissionRepository.swift index 8796c8d4..c9723f57 100644 --- a/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNotificationPermissionRepository.swift +++ b/MLS/MLSMyPageFeature/Sources/MLSMyPageFeatureTesting/Mock/MockNotificationPermissionRepository.swift @@ -6,7 +6,7 @@ import RxSwift public final class MockNotificationPermissionRepository: NotificationPermissionRepository { public var fetchAuthorizationStatusResult = false - + public init() {} public func fetchAuthorizationStatus() -> Single { diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/CustomerSupportReactorTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/CustomerSupportReactorTests.swift index af29fa56..9258794f 100644 --- a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/CustomerSupportReactorTests.swift +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/CustomerSupportReactorTests.swift @@ -5,9 +5,9 @@ import Testing import MLSMyPageFeatureInterface import MLSMyPageFeatureTesting -import RxSwift -import RxBlocking import ReactorKit +import RxBlocking +import RxSwift @Suite("CustomerSupportReactorTests") struct CustomerSupportReactorTests { @@ -48,7 +48,7 @@ struct CustomerSupportReactorTests { #expect(Bool(false), "Expected setIndex") } } - + // MARK: - 공통 @Test("reset = true로 업데이트면 alarm 교체") func setAlarms_replacesItems() { diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetCharacterReactorTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetCharacterReactorTests.swift index 9c9564d2..11fbc63c 100644 --- a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetCharacterReactorTests.swift +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetCharacterReactorTests.swift @@ -20,7 +20,7 @@ struct SetCharacterReactorTests { @Test("viewWillAppear에서 jobList 불러오기") func viewWillAppear_setsJobList() throws { let reactor = makeSUT() - + let mutation = try reactor .mutate(action: .viewWillAppear) .toBlocking() @@ -38,7 +38,7 @@ struct SetCharacterReactorTests { @Test("backButton 클릭하면 dismiss") func backButtonTapped_routesDismiss() throws { let reactor = makeSUT() - + let mutation = try reactor .mutate(action: .backButtonTapped) .toBlocking() @@ -55,7 +55,7 @@ struct SetCharacterReactorTests { @Test("레벨 200 입력하면 level/buttonEnabled 활성화") func inputLevel_emitsThreeMutations() throws { let reactor = makeSUT() - + reactor.isStubEnabled = true reactor.stub.state.value.job = testJob @@ -91,7 +91,7 @@ struct SetCharacterReactorTests { @Test("job 입력하면 role/buttonEnabled 활성화") func inputRole_emitsTwoMutations() throws { let reactor = makeSUT() - + reactor.isStubEnabled = true reactor.stub.state.value.level = 200 @@ -120,7 +120,7 @@ struct SetCharacterReactorTests { @Test("입력값 없이 applyButton 클릭하면 error 방출") func applyButtonTapped_empty_routesError() throws { let reactor = makeSUT() - + let mutation = try reactor .mutate(action: .applyButtonTapped) .toBlocking() @@ -137,7 +137,7 @@ struct SetCharacterReactorTests { @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 diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetProfileReactorTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetProfileReactorTests.swift index 8fb78e9c..f5aa8909 100644 --- a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetProfileReactorTests.swift +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Reactor/SetProfileReactorTests.swift @@ -6,9 +6,9 @@ import MLSAuthFeatureTesting import MLSMyPageFeatureInterface import MLSMyPageFeatureTesting -import RxSwift -import RxBlocking import ReactorKit +import RxBlocking +import RxSwift @Suite("SetProfileReactorTests") struct SetProfileReactorTests { diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/AlarmRepositoryTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/AlarmRepositoryTests.swift index 660e0d50..d32f16a1 100644 --- a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/AlarmRepositoryTests.swift +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/AlarmRepositoryTests.swift @@ -30,7 +30,7 @@ struct AlarmAPIRepositoryImplTests { link: "https://example.com/2", date: "2026-04-02" ) - ), + ) ], hasMore: true ) @@ -60,7 +60,7 @@ struct AlarmAPIRepositoryImplTests { ), alreadyRead: false ) - ), + ) ], hasMore: false ) diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/MyPageRepositoryTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/MyPageRepositoryTests.swift index a1cf5a13..593e8180 100644 --- a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/MyPageRepositoryTests.swift +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/Repository/MyPageRepositoryTests.swift @@ -2,13 +2,13 @@ import Testing -import MLSCore import MLSAuthFeatureInterface +import MLSCore import MLSMyPageFeatureInterface import MLSMyPageFeatureTesting -import RxSwift import RxBlocking +import RxSwift @Suite("MyPageRepositoryImplTests") struct MyPageRepositoryImplTests { diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift index 82b68dc3..275933bd 100644 --- a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift @@ -25,13 +25,13 @@ struct CheckNickNameUseCaseTests { 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() @@ -48,7 +48,7 @@ struct CheckValidLevelUseCaseTests { let result = sut.execute(level: 1) #expect(result == true) } - + @Test("level이 200이면 true") func maximumLevel_returnsTrue() { let result = sut.execute(level: 200) @@ -60,13 +60,13 @@ struct CheckValidLevelUseCaseTests { 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) From 94642b1df6c409fefa5878b29feab259cab5ef8e Mon Sep 17 00:00:00 2001 From: p2glet Date: Sat, 2 May 2026 16:19:48 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix/#326:=20=EC=A0=9C=EB=AF=B8=EB=82=98?= =?UTF-8?q?=EC=9D=B4=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?isOnlyKorean()=20=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20LayoutFacotry=20=EB=82=B4=EB=B6=80=20=EB=A9=94?= =?UTF-8?q?=EC=86=8C=EB=93=9C=EB=93=A4=20static=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/MLSCore/Extension/String+.swift | 25 +++++++++++----- .../LayoutFactory.swift | 14 ++++----- .../UseCase/CheckUseCaseTests.swift | 1 + .../Utills/Extension/String+.swift | 30 ++++++++++++------- 4 files changed, 45 insertions(+), 25 deletions(-) diff --git a/MLS/MLSCore/Sources/MLSCore/Extension/String+.swift b/MLS/MLSCore/Sources/MLSCore/Extension/String+.swift index 59091f42..72f0b89c 100644 --- a/MLS/MLSCore/Sources/MLSCore/Extension/String+.swift +++ b/MLS/MLSCore/Sources/MLSCore/Extension/String+.swift @@ -1,14 +1,25 @@ import Foundation -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) - } +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 + } + } + } } - public func toDisplayDateString() -> String { + func toDisplayDateString() -> String { let inputFormatter = DateFormatter() inputFormatter.locale = Locale(identifier: "ko_KR") inputFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/LayoutFactory.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/LayoutFactory.swift index 8d734a6f..fb55d23d 100644 --- a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/LayoutFactory.swift +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CompositionalLayoutBuilder/LayoutFactory.swift @@ -37,7 +37,7 @@ public class LayoutFactory { .contentInsets(.init(top: 12, leading: 16, bottom: 32, trailing: 16)) } - public func getDictionaryListLayout(isFilterHidden: Bool = true) -> CompositionalSectionBuilder { + 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)) @@ -46,7 +46,7 @@ public class LayoutFactory { .contentInsets(.init(top: isFilterHidden ? 20 : 0, leading: 16, bottom: 0, trailing: 16)) } - public func getTagChipLayout() -> CompositionalSectionBuilder { + public static func getTagChipLayout() -> CompositionalSectionBuilder { return CompositionalSectionBuilder() .item(width: .estimated(70), height: .estimated(32)) .group(.horizontal, width: .estimated(70), height: .estimated(32)) @@ -57,7 +57,7 @@ public class LayoutFactory { .contentInsets(.init(top: 24, leading: 16, bottom: 24, trailing: 16)) } - public func getDecorationSection() -> CompositionalSectionBuilder { + public static func getDecorationSection() -> CompositionalSectionBuilder { return CompositionalSectionBuilder() .item(width: .fractionalWidth(1.0), height: .absolute(1)) .group(.vertical, width: .fractionalWidth(1.0), height: .absolute(10)) @@ -66,7 +66,7 @@ public class LayoutFactory { .contentInsets(.init(top: 5, leading: 0, bottom: 5, trailing: 0)) } - public func getPopularResultLayout() -> CompositionalSectionBuilder { + 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) @@ -84,7 +84,7 @@ public class LayoutFactory { .contentInsets(.init(top: 0, leading: 16, bottom: 0, trailing: 16)) } - public func getCollectionModalLayout() -> CompositionalSectionBuilder { + public static func getCollectionModalLayout() -> CompositionalSectionBuilder { return CompositionalSectionBuilder() .item(width: .fractionalWidth(1.0), height: .absolute(72)) .group(.vertical, width: .fractionalWidth(1.0), height: .absolute(72)) @@ -92,7 +92,7 @@ public class LayoutFactory { .interGroupSpacing(1) } - public func getCollectionListLayout() -> CompositionalSectionBuilder { + public static func getCollectionListLayout() -> CompositionalSectionBuilder { return CompositionalSectionBuilder() .item(width: .fractionalWidth(1.0), height: .absolute(96)) .group(.vertical, width: .fractionalWidth(1.0), height: .absolute(96)) @@ -101,7 +101,7 @@ public class LayoutFactory { .contentInsets(.init(top: 0, leading: 16, bottom: 0, trailing: 16)) } - public func getCollectionListEditLayout() -> CompositionalSectionBuilder { + public static func getCollectionListEditLayout() -> CompositionalSectionBuilder { return CompositionalSectionBuilder() .item(width: .fractionalWidth(1.0), height: .absolute(104)) .group(.vertical, width: .fractionalWidth(1.0), height: .absolute(104)) diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift index 82b68dc3..565545d3 100644 --- a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift @@ -39,6 +39,7 @@ struct CheckNickNameUseCaseTests { } } + @Suite("CheckValidLevelUseCaseTests") struct CheckValidLevelUseCaseTests { private let sut = CheckValidLevelUseCaseImpl() 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) } } From 9fc1e3f69ec7d5d1ec140eeeb6bc6dddbdc19c55 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 2 May 2026 07:20:45 +0000 Subject: [PATCH 7/7] style/#326: Apply SwiftLint autocorrect --- .../Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift index 3347e1dc..275933bd 100644 --- a/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift +++ b/MLS/MLSMyPageFeature/Tests/MLSMyPageFeatureTests/UseCase/CheckUseCaseTests.swift @@ -39,7 +39,6 @@ struct CheckNickNameUseCaseTests { } } - @Suite("CheckValidLevelUseCaseTests") struct CheckValidLevelUseCaseTests { private let sut = CheckValidLevelUseCaseImpl()