From 4f85137e393794dfae0d55ead3b1ccad8cc32990 Mon Sep 17 00:00:00 2001 From: p2glet Date: Tue, 19 May 2026 22:05:49 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat/#332:=20MLSDictionaryFeature=20?= =?UTF-8?q?=EB=AA=A8=E3=85=97=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/Core/.DS_Store | Bin 6148 -> 6148 bytes MLS/Core/MLSDictionaryFeature | 1 + MLS/MLS.xcworkspace/contents.xcworkspacedata | 3 +++ 3 files changed, 4 insertions(+) create mode 160000 MLS/Core/MLSDictionaryFeature diff --git a/MLS/Core/.DS_Store b/MLS/Core/.DS_Store index aba2f81455d0491422496384fb1dff64b029700b..68ba41931a5c133fd0c2a92e99360f2c3ac50159 100644 GIT binary patch delta 326 zcmZoMXfc=|#>B)qu~2NHo+2aL#DLw4KQJ;fvP~{vRG6&8xVm11!I!~@A(+91A(J7Q zp#(_gGvqNOG88dXGPp6M0$C*tr9fV4K}nKNX>myr0|SFTLnuQrP_76_>N6BD7%`+X zB`mu~2NHo+2a5#DLw5ERzeE6ejC1t=@c<*@$KH1Ll8>o7p+|Ie^MG aUu6EyJegm_l7j&V7#SFtHV24oVFm!&ITFkO diff --git a/MLS/Core/MLSDictionaryFeature b/MLS/Core/MLSDictionaryFeature new file mode 160000 index 00000000..c7406144 --- /dev/null +++ b/MLS/Core/MLSDictionaryFeature @@ -0,0 +1 @@ +Subproject commit c740614456e4e4f21c2a79d8e29e058103cf2964 diff --git a/MLS/MLS.xcworkspace/contents.xcworkspacedata b/MLS/MLS.xcworkspace/contents.xcworkspacedata index c889074c..f07f779c 100644 --- a/MLS/MLS.xcworkspace/contents.xcworkspacedata +++ b/MLS/MLS.xcworkspace/contents.xcworkspacedata @@ -50,6 +50,9 @@ + + From 31cb9f6fbe90861de764075ca7053b812478bc3f Mon Sep 17 00:00:00 2001 From: p2glet Date: Tue, 19 May 2026 22:06:22 +0900 Subject: [PATCH 02/16] =?UTF-8?q?Revert=20"feat/#332:=20MLSDictionaryFeatu?= =?UTF-8?q?re=20=EB=AA=A8=E3=85=97=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 This reverts commit 4f85137e393794dfae0d55ead3b1ccad8cc32990. --- MLS/Core/.DS_Store | Bin 6148 -> 6148 bytes MLS/Core/MLSDictionaryFeature | 1 - MLS/MLS.xcworkspace/contents.xcworkspacedata | 3 --- 3 files changed, 4 deletions(-) delete mode 160000 MLS/Core/MLSDictionaryFeature diff --git a/MLS/Core/.DS_Store b/MLS/Core/.DS_Store index 68ba41931a5c133fd0c2a92e99360f2c3ac50159..aba2f81455d0491422496384fb1dff64b029700b 100644 GIT binary patch delta 71 zcmZoMXfc=|#>B`mu~2NHo+2a5#DLw5ERzeE6ejC1t=@c<*@$KH1Ll8>o7p+|Ie^MG aUu6EyJegm_l7j&V7#SFtHV24oVFm!&ITFkO delta 326 zcmZoMXfc=|#>B)qu~2NHo+2aL#DLw4KQJ;fvP~{vRG6&8xVm11!I!~@A(+91A(J7Q zp#(_gGvqNOG88dXGPp6M0$C*tr9fV4K}nKNX>myr0|SFTLnuQrP_76_>N6BD7%`+X z - - From 3662508e40f97b77c261f8b95af5b8ad41326201 Mon Sep 17 00:00:00 2001 From: p2glet Date: Tue, 19 May 2026 22:15:58 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat/#332:=20MLSDictionaryFeature=20?= =?UTF-8?q?=EB=AA=A8=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 | 3 +++ MLS/MLSDictionaryFeature/.gitignore | 8 ++++++ MLS/MLSDictionaryFeature/Package.swift | 27 +++++++++++++++++++ .../MLSDictionaryFeature.swift | 2 ++ .../MLSDictionaryFeatureTests.swift | 6 +++++ 5 files changed, 46 insertions(+) create mode 100644 MLS/MLSDictionaryFeature/.gitignore create mode 100644 MLS/MLSDictionaryFeature/Package.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/MLSDictionaryFeature.swift create mode 100644 MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MLSDictionaryFeatureTests.swift diff --git a/MLS/MLS.xcworkspace/contents.xcworkspacedata b/MLS/MLS.xcworkspace/contents.xcworkspacedata index c889074c..f07f779c 100644 --- a/MLS/MLS.xcworkspace/contents.xcworkspacedata +++ b/MLS/MLS.xcworkspace/contents.xcworkspacedata @@ -50,6 +50,9 @@ + + diff --git a/MLS/MLSDictionaryFeature/.gitignore b/MLS/MLSDictionaryFeature/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/MLS/MLSDictionaryFeature/.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/MLSDictionaryFeature/Package.swift b/MLS/MLSDictionaryFeature/Package.swift new file mode 100644 index 00000000..b52ab420 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Package.swift @@ -0,0 +1,27 @@ +// 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: "MLSDictionaryFeature", + platforms: [.iOS(.v15)], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "MLSDictionaryFeature", + targets: ["MLSDictionaryFeature"] + ), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "MLSDictionaryFeature" + ), + .testTarget( + name: "MLSDictionaryFeatureTests", + dependencies: ["MLSDictionaryFeature"] + ), + ] +) diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/MLSDictionaryFeature.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/MLSDictionaryFeature.swift new file mode 100644 index 00000000..08b22b80 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/MLSDictionaryFeature.swift @@ -0,0 +1,2 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book diff --git a/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MLSDictionaryFeatureTests.swift b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MLSDictionaryFeatureTests.swift new file mode 100644 index 00000000..83b3d0ce --- /dev/null +++ b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MLSDictionaryFeatureTests.swift @@ -0,0 +1,6 @@ +import Testing +@testable import MLSDictionaryFeature + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} From 852ae71b5832840b6b87d71a946b0732019f687d Mon Sep 17 00:00:00 2001 From: p2glet Date: Tue, 26 May 2026 17:12:06 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat/#332:=20DictionaryFeature=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EB=B6=84=EB=A6=AC=20DI=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=A0=95=EB=A6=AC=20/=20=EB=8D=B0=EB=AA=A8?= =?UTF-8?q?=EC=95=B1=20=EA=B5=AC=EC=84=B1=20/=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=A0=95=EB=A6=AC=20/=20=EB=8B=A8=EC=88=9C?= =?UTF-8?q?=EB=9E=98=ED=95=91=20useCase=20=EC=A0=9C=EA=B1=B0=20/=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=ED=95=84?= =?UTF-8?q?=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserDefaultsRepositoryImpl.swift | 21 +- MLS/MLS.xcodeproj/project.pbxproj | 192 +++++- .../MLSCore/ImageLoader/ImageLoader.swift | 4 +- .../DictionaryTabControllable.swift | 3 + .../Navigation/DictionaryTabRegistry.swift | 11 + .../MLSDesignSystem/Components/CardList.swift | 1 + .../Layouts/CheckBoxButtonListSmallCell.swift | 54 ++ .../Layouts/PageTabbarCell.swift | 58 ++ .../Layouts/TapButtonCell.swift | 50 ++ .../Utills/DesignSystemAsset.swift | 7 +- MLS/MLSDictionaryFeature/Package.swift | 70 +- .../Data/DTOs/DictionaryAllDTO.swift | 18 + .../Data/DTOs/DictionaryDTOProtocol.swift | 28 + ...naryDetailItemDropMonsterResponseDTO.swift | 13 + .../DictionaryDetailItemResponseDTO.swift | 35 + .../DictionaryDetailMapNpcResponseDTO.swift | 12 + .../DTOs/DictionaryDetailMapResponseDTO.swift | 17 + ...naryDetailMapSpawnMonsterResponseDTO.swift | 13 + ...naryDetailMonsterDropItemResponseDTO.swift | 13 + ...ictionaryDetailMonsterMapResponseDTO.swift | 15 + .../DictionaryDetailMonsterResponseDTO.swift | 43 ++ .../DictionaryDetailNpcQuestResponseDTO.swift | 14 + .../DTOs/DictionaryDetailNpcResponseDTO.swift | 13 + ...ryDetailQuestLinkedQuestsResponseDTO.swift | 10 + .../DictionaryDetailQuestResponseDTO.swift | 45 ++ .../Data/DTOs/DictionaryItemDTO.swift | 18 + .../Data/DTOs/DictionaryMapDTO.swift | 18 + .../Data/DTOs/DictionaryMonsterDTO.swift | 18 + .../Data/DTOs/DictionaryNPCDTO.swift | 18 + .../Data/DTOs/DictionaryQuestDTO.swift | 18 + .../Data/DTOs/PagedListResponseDTO.swift | 23 + .../Data/DTOs/SearchCountDTO.swift | 9 + .../Endpoints/DictionaryDetailEndPoint.swift | 66 ++ .../Endpoints/DictionaryListEndPoint.swift | 60 ++ .../DictionaryDetailAPIRepositoryImpl.swift | 86 +++ .../DictionaryListAPIRepositoryImpl.swift | 59 ++ .../RecentSearchRepositoryImpl.swift | 67 ++ .../UserDefaultsRepositoryImpl.swift | 28 + .../Usecases/CheckLoginUseCaseImpl.swift | 46 ++ ...eckNotificationPermissionUseCaseImpl.swift | 23 + ...etchVisitDictionaryDetailUseCaseImpl.swift | 23 + .../ParseItemFilterResultUseCaseImpl.swift | 59 ++ .../Usecases/SetBookmarkUseCaseImpl.swift | 22 + .../MLSDictionaryFeature.swift | 2 - .../Presentation/BaseListView.swift | 178 +++++ .../DictionaryDetailBaseView.swift | 488 ++++++++++++++ .../DictionaryDetailBaseViewController.swift | 429 ++++++++++++ .../DictionaryDetailFactoryImpl.swift | 153 +++++ .../Item/ItemDictionaryDetailReactor.swift | 176 +++++ .../ItemDictionaryDetailViewController.swift | 276 ++++++++ .../Map/MapDictionaryDetailReactor.swift | 197 ++++++ .../MapDictionaryDetailViewController.swift | 232 +++++++ .../MonsterDictionaryDetailReactor.swift | 219 ++++++ ...onsterDictionaryDetailViewController.swift | 253 +++++++ .../NPC/NpcDictionaryDetailReactor.swift | 188 ++++++ .../NpcDictionaryDetailViewController.swift | 202 ++++++ .../DetailOnBoardingFactoryImpl.swift | 14 + .../OnBoarding/DetailOnBoardingReactor.swift | 52 ++ .../OnBoarding/DetailOnBoardingView.swift | 191 ++++++ .../DetailOnBoardingViewController.swift | 83 +++ .../Quest/QuestDictionaryDetailReactor.swift | 235 +++++++ .../QuestDictionaryDetailViewController.swift | 233 +++++++ .../SectionStackView/DetailEmptyView.swift | 53 ++ .../DetailStackCardView.swift | 270 ++++++++ .../DetailStackInfoView.swift | 296 +++++++++ .../SectionStackView/DetailStackMapView.swift | 70 ++ .../SectionStackView/PinchMapView.swift | 94 +++ .../PinchMapViewController.swift | 64 ++ .../DictionaryListFactoryImpl.swift | 66 ++ .../DictionaryListReactor.swift | 371 +++++++++++ .../DictionaryList/DictionaryListView.swift | 23 + .../DictionaryListViewController.swift | 438 ++++++++++++ .../Presentation/DictionaryListCell.swift | 105 +++ .../DictionaryMainReactor.swift | 84 +++ .../DictionaryMain/DictionaryMainView.swift | 96 +++ .../DictionaryMainViewController.swift | 251 +++++++ .../DictionaryMainViewFactoryImpl.swift | 28 + .../DictionaryNotificationFactoryImpl.swift | 30 + .../DictionaryNotificationReactor.swift | 143 ++++ .../DictionaryNotificationView.swift | 91 +++ ...DictionaryNotificationViewController.swift | 181 +++++ .../NotificationEmptyView.swift | 104 +++ .../DictionaryNotificationCell.swift | 89 +++ .../DictionarySearchFactoryImpl.swift | 19 + .../DictionarySearchReactor.swift | 133 ++++ .../DictionarySearchView.swift | 78 +++ .../DictionarySearchViewController.swift | 275 ++++++++ .../DictionarySearch/EmptyRecentCell.swift | 35 + .../DictionarySearch/PopularResultCell.swift | 63 ++ .../DictionarySearch/TagChipCell.swift | 68 ++ .../DictionarySearchResultFactoryImpl.swift | 20 + .../DictionarySearchResultReactor.swift | 112 ++++ ...DictionarySearchResultViewController.swift | 300 +++++++++ .../ItemFilterBottomSheetFactoryImpl.swift | 15 + .../ItemFilterBottomSheetReactor.swift | 162 +++++ .../ItemFilterBottomSheetViewController.swift | 621 ++++++++++++++++++ .../Views/FilterLevelSectionCell.swift | 59 ++ .../Views/FilterLevelSectionView.swift | 193 ++++++ .../Views/FilterSlider.swift | 293 +++++++++ .../Views/ItemFilterBottomSheetView.swift | 154 +++++ .../MonsterFilterBottomSheetFactoryImpl.swift | 19 + .../MonsterFilterBottomSheetReactor.swift | 69 ++ .../MonsterFilterBottomSheetView.swift | 121 ++++ ...nsterFilterBottomSheetViewController.swift | 133 ++++ .../SortedBottomSheetFactoryImpl.swift | 14 + .../SortedBottomSheetReactor.swift | 66 ++ .../SortedBottomSheetView.swift | 68 ++ .../SortedBottomSheetViewController.swift | 128 ++++ ...tionaryDetailItemDropMonsterResponse.swift | 15 + .../DictionaryDetailItemResponse.swift | 118 ++++ .../DictionaryDetailMapNpcResponse.swift | 13 + .../DictionaryDetailMapResponse.swift | 23 + ...tionaryDetailMapSpawnMonsterResponse.swift | 15 + ...tionaryDetailMonsterDropItemResponse.swift | 15 + .../DictionaryDetailMonsterMapResponse.swift | 19 + .../DictionaryDetailMonsterResponse.swift | 111 ++++ .../DictionaryDetailNpcQuestResponse.swift | 17 + .../DictionaryDetailNpcResponse.swift | 15 + ...onaryDetailQuestLinkedQuestsResponse.swift | 25 + .../DictionaryDetailQuestResponse.swift | 92 +++ .../Entities/DictionaryDetailText.swift | 10 + .../Entities/DictionaryListQuery.swift | 25 + .../Entities/DictionaryMainResponse.swift | 29 + .../Entities/DictionaryMainViewType.swift | 16 + .../Entities/DictionaryType.swift | 129 ++++ .../Entities/DictionnaryItemType.swift | 118 ++++ .../Entities/ItemFilterCriteria.swift | 13 + .../Entities/SearchCountResponse.swift | 7 + .../Entities/SortType.swift | 51 ++ .../Factories/BookmarkModalFactory.swift | 7 + .../Factories/DetailOnBoardingFactory.swift | 5 + .../Factories/DictionaryDetailFactory.swift | 7 + .../Factories/DictionaryMainListFactory.swift | 5 + .../Factories/DictionaryMainViewFactory.swift | 5 + .../DictionaryNotificationFactory.swift | 5 + .../Factories/DictionarySearchFactory.swift | 5 + .../DictionarySearchResultFactory.swift | 5 + .../ItemFilterBottomSheetFactory.swift | 5 + .../MonsterFilterBottomSheetFactory.swift | 6 + .../Factories/SortedBottomSheetFactory.swift | 6 + .../Repositories/BookmarkRepository.swift | 38 ++ .../DictionaryDetailAPIRepository.swift | 32 + .../DictionaryListAPIRepository.swift | 20 + .../Repositories/RecentSearchRepository.swift | 8 + .../Repositories/UserDefaultsRepository.swift | 6 + .../UseCases/CheckLoginUseCase.swift | 5 + .../CheckNotificationPermissionUseCase.swift | 5 + .../FetchVisitDictionaryDetailUseCase.swift | 5 + .../UseCases/ParseItemFilterUseCase.swift | 3 + .../UseCases/SetBookmarkUseCase.swift | 11 + .../Mock/MockAppCoordinator.swift | 15 + .../Mock/MockBookmarkModalFactory.swift | 18 + .../Mock/MockBookmarkRepository.swift | 41 ++ .../MockDictionaryDetailAPIRepository.swift | 263 ++++++++ .../MockDictionaryListAPIRepository.swift | 235 +++++++ .../Mock/MockFetchProfileUseCase.swift | 13 + .../Mock/MockNotificationSettingFactory.swift | 15 + .../AppDelegate.swift | 18 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 + .../Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard | 25 + MLS/MLSDictionaryFeatureExample/Info.plist | 23 + .../SceneDelegate.swift | 148 +++++ .../ViewController.swift | 4 + 165 files changed, 13304 insertions(+), 23 deletions(-) create mode 100644 MLS/MLSCore/Sources/MLSCore/Navigation/DictionaryTabControllable.swift create mode 100644 MLS/MLSCore/Sources/MLSCore/Navigation/DictionaryTabRegistry.swift create mode 100644 MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CheckBoxButtonListSmallCell.swift create mode 100644 MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/PageTabbarCell.swift create mode 100644 MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/TapButtonCell.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryAllDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDTOProtocol.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailItemDropMonsterResponseDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailItemResponseDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMapNpcResponseDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMapResponseDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMapSpawnMonsterResponseDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMonsterDropItemResponseDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMonsterMapResponseDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMonsterResponseDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailNpcQuestResponseDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailNpcResponseDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailQuestLinkedQuestsResponseDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailQuestResponseDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryItemDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMapDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMonsterDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryNPCDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryQuestDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/PagedListResponseDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/SearchCountDTO.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Endpoints/DictionaryDetailEndPoint.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Endpoints/DictionaryListEndPoint.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/DictionaryDetailAPIRepositoryImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/DictionaryListAPIRepositoryImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/RecentSearchRepositoryImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/UserDefaultsRepositoryImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/CheckLoginUseCaseImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/CheckNotificationPermissionUseCaseImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/FetchVisitDictionaryDetailUseCaseImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/ParseItemFilterResultUseCaseImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/SetBookmarkUseCaseImpl.swift delete mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/MLSDictionaryFeature.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/BaseListView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailFactoryImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailReactor.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailReactor.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailReactor.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingFactoryImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingReactor.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailReactor.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailEmptyView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailStackCardView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailStackInfoView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailStackMapView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/PinchMapView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/PinchMapViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListFactoryImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListReactor.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryListCell.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainReactor.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainViewFactoryImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationFactoryImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationReactor.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/NotificationEmptyView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotificationCell.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchFactoryImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchReactor.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/EmptyRecentCell.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/PopularResultCell.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/TagChipCell.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultFactoryImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultReactor.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/ItemFilterBottomSheetFactoryImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/ItemFilterBottomSheetReactor.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/ItemFilterBottomSheetViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/FilterLevelSectionCell.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/FilterLevelSectionView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/FilterSlider.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/ItemFilterBottomSheetView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetFactoryImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetReactor.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetFactoryImpl.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetReactor.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetView.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetViewController.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailItemDropMonsterResponse.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailItemResponse.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMapNpcResponse.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMapResponse.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMapSpawnMonsterResponse.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMonsterDropItemResponse.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMonsterMapResponse.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMonsterResponse.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailNpcQuestResponse.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailNpcResponse.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailQuestLinkedQuestsResponse.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailQuestResponse.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailText.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryListQuery.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryMainResponse.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryMainViewType.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryType.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionnaryItemType.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/ItemFilterCriteria.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/SearchCountResponse.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/SortType.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/BookmarkModalFactory.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DetailOnBoardingFactory.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryDetailFactory.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryMainListFactory.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryMainViewFactory.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryNotificationFactory.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionarySearchFactory.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionarySearchResultFactory.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/ItemFilterBottomSheetFactory.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/MonsterFilterBottomSheetFactory.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/SortedBottomSheetFactory.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/BookmarkRepository.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/DictionaryDetailAPIRepository.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/DictionaryListAPIRepository.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/RecentSearchRepository.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/UserDefaultsRepository.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/CheckLoginUseCase.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/CheckNotificationPermissionUseCase.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/FetchVisitDictionaryDetailUseCase.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/ParseItemFilterUseCase.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/SetBookmarkUseCase.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockAppCoordinator.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockBookmarkModalFactory.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockBookmarkRepository.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockDictionaryDetailAPIRepository.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockDictionaryListAPIRepository.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockFetchProfileUseCase.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockNotificationSettingFactory.swift create mode 100644 MLS/MLSDictionaryFeatureExample/AppDelegate.swift create mode 100644 MLS/MLSDictionaryFeatureExample/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 MLS/MLSDictionaryFeatureExample/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 MLS/MLSDictionaryFeatureExample/Assets.xcassets/Contents.json create mode 100644 MLS/MLSDictionaryFeatureExample/Base.lproj/LaunchScreen.storyboard create mode 100644 MLS/MLSDictionaryFeatureExample/Info.plist create mode 100644 MLS/MLSDictionaryFeatureExample/SceneDelegate.swift create mode 100644 MLS/MLSDictionaryFeatureExample/ViewController.swift diff --git a/MLS/Data/Data/Repository/UserDefaultsRepositoryImpl.swift b/MLS/Data/Data/Repository/UserDefaultsRepositoryImpl.swift index 55a01487..a0976d39 100644 --- a/MLS/Data/Data/Repository/UserDefaultsRepositoryImpl.swift +++ b/MLS/Data/Data/Repository/UserDefaultsRepositoryImpl.swift @@ -22,17 +22,20 @@ public final class UserDefaultsRepositoryImpl: UserDefaultsRepository { } public func addRecentSearch(keyword: String) -> Completable { - return Completable.create { completable in - var current = UserDefaults.standard.stringArray(forKey: self.recentSearchkey) ?? [] - - // 중복 제거 - current.removeAll(where: { $0 == keyword }) - current.insert(keyword, at: 0) + let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) - UserDefaults.standard.set(current, forKey: self.recentSearchkey) - completable(.completed) - return Disposables.create() + guard !keyword.isEmpty else { + return .empty() } + + var current = UserDefaults.standard.stringArray(forKey: recentSearchkey) ?? [] + + current.removeAll(where: { $0 == keyword }) + current.insert(keyword, at: 0) + + UserDefaults.standard.set(current, forKey: recentSearchkey) + + return .empty() } public func removeRecentSearch(keyword: String) -> Completable { diff --git a/MLS/MLS.xcodeproj/project.pbxproj b/MLS/MLS.xcodeproj/project.pbxproj index 6c989cc7..fee16634 100644 --- a/MLS/MLS.xcodeproj/project.pbxproj +++ b/MLS/MLS.xcodeproj/project.pbxproj @@ -65,11 +65,16 @@ 779A49112E1AD26D00ABDE4F /* BookmarkFeatureInterface.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 779A490F2E1AD26D00ABDE4F /* BookmarkFeatureInterface.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 77A293312F79989200845081 /* DesignSystem.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 77A293302F79989200845081 /* DesignSystem.framework */; }; 77A293322F79989200845081 /* DesignSystem.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 77A293302F79989200845081 /* DesignSystem.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 77AB59ED2FBE20FD00BAB8A3 /* MLSAuthFeatureTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 77AB59EC2FBE20FD00BAB8A3 /* MLSAuthFeatureTesting */; }; + 77AB59EF2FBE20FD00BAB8A3 /* MLSMyPageFeatureTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 77AB59EE2FBE20FD00BAB8A3 /* MLSMyPageFeatureTesting */; }; 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, ); }; }; + 77F7C2262FBCB2A200D515A8 /* MLSDictionaryFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 77F7C2252FBCB2A200D515A8 /* MLSDictionaryFeature */; }; + 77F7C2282FBCB2A200D515A8 /* MLSDictionaryFeatureTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 77F7C2272FBCB2A200D515A8 /* MLSDictionaryFeatureTesting */; }; + 77F7C22C2FBE18D400D515A8 /* MLSDictionaryFeatureInterface in Frameworks */ = {isa = PBXBuildFile; productRef = 77F7C22B2FBE18D400D515A8 /* MLSDictionaryFeatureInterface */; }; 77FA68B82F72C9C10064B6EB /* RxCocoa in Frameworks */ = {isa = PBXBuildFile; productRef = 77FA68B72F72C9C10064B6EB /* RxCocoa */; }; 77FA68BA2F72C9C10064B6EB /* RxSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 77FA68B92F72C9C10064B6EB /* RxSwift */; }; 77FA68BC2F72C9C70064B6EB /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 77FA68BB2F72C9C70064B6EB /* SnapKit */; }; @@ -158,6 +163,7 @@ 7777F7012E9EAB8400F53D68 /* BookmarkFeatureInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = BookmarkFeatureInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7777F7062E9EAC0D00F53D68 /* MyPageFeature.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MyPageFeature.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7777F7072E9EAC0D00F53D68 /* MyPageFeatureInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MyPageFeatureInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7794E1062FBCA8AA001474A2 /* MLSDictionaryFeatureExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLSDictionaryFeatureExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 779A490C2E1AD26700ABDE4F /* BookmarkFeature.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = BookmarkFeature.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 779A490F2E1AD26D00ABDE4F /* BookmarkFeatureInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = BookmarkFeatureInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 77A293302F79989200845081 /* DesignSystem.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = DesignSystem.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -196,6 +202,13 @@ ); target = 77217FBD2F9A04CF000915EF /* MLSMyPageFeatureExample */; }; + 7794E1172FBCA8AB001474A2 /* Exceptions for "MLSDictionaryFeatureExample" folder in "MLSDictionaryFeatureExample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 7794E1052FBCA8AA001474A2 /* MLSDictionaryFeatureExample */; + }; 77FA688B2F72C7380064B6EB /* Exceptions for "MLSDesignSystemExample" folder in "MLSDesignSystemExample" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -238,6 +251,14 @@ path = MLSMyPageFeatureExample; sourceTree = ""; }; + 7794E1072FBCA8AA001474A2 /* MLSDictionaryFeatureExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7794E1172FBCA8AB001474A2 /* Exceptions for "MLSDictionaryFeatureExample" folder in "MLSDictionaryFeatureExample" target */, + ); + path = MLSDictionaryFeatureExample; + sourceTree = ""; + }; 77BEB0412DBA84B0002FFCFC /* MLSTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = MLSTests; @@ -323,6 +344,18 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7794E1032FBCA8AA001474A2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 77F7C2262FBCB2A200D515A8 /* MLSDictionaryFeature in Frameworks */, + 77AB59ED2FBE20FD00BAB8A3 /* MLSAuthFeatureTesting in Frameworks */, + 77F7C2282FBCB2A200D515A8 /* MLSDictionaryFeatureTesting in Frameworks */, + 77AB59EF2FBE20FD00BAB8A3 /* MLSMyPageFeatureTesting in Frameworks */, + 77F7C22C2FBE18D400D515A8 /* MLSDictionaryFeatureInterface in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 77BEB03D2DBA84B0002FFCFC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -428,6 +461,7 @@ 08F7A9242F86745C00EF5C06 /* MLSAuthFeatureExample */, 77217FBF2F9A04CF000915EF /* MLSMyPageFeatureExample */, 08F7DC622F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */, + 7794E1072FBCA8AA001474A2 /* MLSDictionaryFeatureExample */, 084A25312DB93A5400C395C0 /* Frameworks */, 087D3EE92DA7972C002F924D /* Products */, ); @@ -442,6 +476,7 @@ 08F7A9232F86745C00EF5C06 /* MLSAuthFeatureExample.app */, 77217FBE2F9A04CF000915EF /* MLSMyPageFeatureExample.app */, 08F7DC612F9DEA8000EF5C06 /* MLSRecommendationFeatureExample.app */, + 7794E1062FBCA8AA001474A2 /* MLSDictionaryFeatureExample.app */, ); name = Products; sourceTree = ""; @@ -563,6 +598,33 @@ productReference = 77217FBE2F9A04CF000915EF /* MLSMyPageFeatureExample.app */; productType = "com.apple.product-type.application"; }; + 7794E1052FBCA8AA001474A2 /* MLSDictionaryFeatureExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7794E1182FBCA8AB001474A2 /* Build configuration list for PBXNativeTarget "MLSDictionaryFeatureExample" */; + buildPhases = ( + 7794E1022FBCA8AA001474A2 /* Sources */, + 7794E1032FBCA8AA001474A2 /* Frameworks */, + 7794E1042FBCA8AA001474A2 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7794E1072FBCA8AA001474A2 /* MLSDictionaryFeatureExample */, + ); + name = MLSDictionaryFeatureExample; + packageProductDependencies = ( + 77F7C2252FBCB2A200D515A8 /* MLSDictionaryFeature */, + 77F7C2272FBCB2A200D515A8 /* MLSDictionaryFeatureTesting */, + 77F7C22B2FBE18D400D515A8 /* MLSDictionaryFeatureInterface */, + 77AB59EC2FBE20FD00BAB8A3 /* MLSAuthFeatureTesting */, + 77AB59EE2FBE20FD00BAB8A3 /* MLSMyPageFeatureTesting */, + ); + productName = MLSDictionaryFeatureExample; + productReference = 7794E1062FBCA8AA001474A2 /* MLSDictionaryFeatureExample.app */; + productType = "com.apple.product-type.application"; + }; 77BEB03F2DBA84B0002FFCFC /* MLSTests */ = { isa = PBXNativeTarget; buildConfigurationList = 77BEB0482DBA84B0002FFCFC /* Build configuration list for PBXNativeTarget "MLSTests" */; @@ -635,6 +697,9 @@ 77217FBD2F9A04CF000915EF = { CreatedOnToolsVersion = 26.1.1; }; + 7794E1052FBCA8AA001474A2 = { + CreatedOnToolsVersion = 26.1.1; + }; 77BEB03F2DBA84B0002FFCFC = { CreatedOnToolsVersion = 16.2; TestTargetID = 087D3EE72DA7972C002F924D; @@ -699,6 +764,7 @@ 08F7A9222F86745C00EF5C06 /* MLSAuthFeatureExample */, 77217FBD2F9A04CF000915EF /* MLSMyPageFeatureExample */, 08F7DC602F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */, + 7794E1052FBCA8AA001474A2 /* MLSDictionaryFeatureExample */, ); }; /* End PBXProject section */ @@ -735,6 +801,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7794E1042FBCA8AA001474A2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 77BEB03E2DBA84B0002FFCFC /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -801,6 +874,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7794E1022FBCA8AA001474A2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 77BEB03C2DBA84B0002FFCFC /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1184,8 +1264,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = MLSMyPageFeatureExample/Info.plist; @@ -1201,6 +1283,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLSDesignSystemExample.MLSMyPageFeatureExample; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1220,8 +1303,9 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = MLSMyPageFeatureExample/Info.plist; @@ -1237,6 +1321,83 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLSDesignSystemExample.MLSMyPageFeatureExample; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + 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; + }; + 7794E1192FBCA8AB001474A2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MLSDictionaryFeatureExample/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.MLSDictionaryFeatureExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + 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; + }; + 7794E11A2FBCA8AB001474A2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MLSDictionaryFeatureExample/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.MLSDictionaryFeatureExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1413,6 +1574,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 7794E1182FBCA8AB001474A2 /* Build configuration list for PBXNativeTarget "MLSDictionaryFeatureExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7794E1192FBCA8AB001474A2 /* Debug */, + 7794E11A2FBCA8AB001474A2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 77BEB0482DBA84B0002FFCFC /* Build configuration list for PBXNativeTarget "MLSTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1591,6 +1761,14 @@ package = 77660AD32DD0D3DD007A4EF3 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */; productName = KakaoSDKUser; }; + 77AB59EC2FBE20FD00BAB8A3 /* MLSAuthFeatureTesting */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSAuthFeatureTesting; + }; + 77AB59EE2FBE20FD00BAB8A3 /* MLSMyPageFeatureTesting */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSMyPageFeatureTesting; + }; 77B1F9942EE06A4E00AE4B4D /* RxGesture */ = { isa = XCSwiftPackageProductDependency; package = 77B1F9932EE06A4E00AE4B4D /* XCRemoteSwiftPackageReference "RxGesture" */; @@ -1600,6 +1778,18 @@ isa = XCSwiftPackageProductDependency; productName = MLSAuthFeatureTesting; }; + 77F7C2252FBCB2A200D515A8 /* MLSDictionaryFeature */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSDictionaryFeature; + }; + 77F7C2272FBCB2A200D515A8 /* MLSDictionaryFeatureTesting */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSDictionaryFeatureTesting; + }; + 77F7C22B2FBE18D400D515A8 /* MLSDictionaryFeatureInterface */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSDictionaryFeatureInterface; + }; 77FA68B72F72C9C10064B6EB /* RxCocoa */ = { isa = XCSwiftPackageProductDependency; package = 08ED49202DCFDE9C002C21A2 /* XCRemoteSwiftPackageReference "RxSwift" */; diff --git a/MLS/MLSCore/Sources/MLSCore/ImageLoader/ImageLoader.swift b/MLS/MLSCore/Sources/MLSCore/ImageLoader/ImageLoader.swift index 3e2bbc3e..3052a4d1 100644 --- a/MLS/MLSCore/Sources/MLSCore/ImageLoader/ImageLoader.swift +++ b/MLS/MLSCore/Sources/MLSCore/ImageLoader/ImageLoader.swift @@ -34,7 +34,7 @@ public final class ImageLoader: @unchecked Sendable { private init() {} - public func loadImage(stringURL: String?, defaultImage: UIImage? = nil, completion: @escaping @Sendable (UIImage?) -> Void) { + @MainActor public func loadImage(stringURL: String?, defaultImage: UIImage? = nil, completion: @MainActor @escaping @Sendable (UIImage?) -> Void) { guard let stringURL, let url = URL(string: stringURL), ["http", "https"].contains(url.scheme?.lowercased() ?? "") @@ -52,7 +52,7 @@ public final class ImageLoader: @unchecked Sendable { /// - stringURL: 이미지 URL 문자열 /// - defaultImage: 로드 실패 시 반환할 기본 이미지 /// - completion: 로드 완료 후 호출되는 클로저 - public func loadImage(url: URL?, defaultImage: UIImage? = nil, completion: @escaping @Sendable (UIImage?) -> Void) { + @MainActor public func loadImage(url: URL?, defaultImage: UIImage? = nil, completion: @MainActor @escaping @Sendable (UIImage?) -> Void) { loadImage(url: url) { result in DispatchQueue.main.async { switch result { diff --git a/MLS/MLSCore/Sources/MLSCore/Navigation/DictionaryTabControllable.swift b/MLS/MLSCore/Sources/MLSCore/Navigation/DictionaryTabControllable.swift new file mode 100644 index 00000000..1f0e5479 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Navigation/DictionaryTabControllable.swift @@ -0,0 +1,3 @@ +public protocol DictionaryTabControllable: AnyObject { + func changeTab(index: Int) +} diff --git a/MLS/MLSCore/Sources/MLSCore/Navigation/DictionaryTabRegistry.swift b/MLS/MLSCore/Sources/MLSCore/Navigation/DictionaryTabRegistry.swift new file mode 100644 index 00000000..467443c7 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Navigation/DictionaryTabRegistry.swift @@ -0,0 +1,11 @@ +public enum DictionaryTabRegistry { + private static weak var controller: DictionaryTabControllable? + + public static func register(controller: DictionaryTabControllable) { + self.controller = controller + } + + public static func changeTab(index: Int) { + controller?.changeTab(index: index) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift index c5d967e7..9d1dbfe0 100644 --- a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift @@ -294,6 +294,7 @@ public extension CardList { iconButton.isHidden = false dropInfoStack.isHidden = true badge.isHidden = true + rankContainer.isHidden = true } } diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CheckBoxButtonListSmallCell.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CheckBoxButtonListSmallCell.swift new file mode 100644 index 00000000..b657c4ff --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CheckBoxButtonListSmallCell.swift @@ -0,0 +1,54 @@ +import UIKit + +import RxSwift +import SnapKit + +public class CheckBoxButtonListSmallCell: UICollectionViewCell { + + private let checkBoxButton: CheckBoxButton = { + let button = CheckBoxButton(style: .listSmall, mainTitle: nil, subTitle: nil) + button.isUserInteractionEnabled = false + return button + }() + + private var disposeBag = DisposeBag() + + override init(frame: CGRect) { + super.init(frame: frame) + addViews() + setupConstraints() + configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override var isSelected: Bool { + didSet { + checkBoxButton.isSelected = isSelected + } + } +} + +// MARK: - SetUp +private extension CheckBoxButtonListSmallCell { + func addViews() { + contentView.addSubview(checkBoxButton) + } + + func setupConstraints() { + checkBoxButton.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + func configureUI() { + } +} + +public extension CheckBoxButtonListSmallCell { + func inject(title: String?) { + checkBoxButton.mainTitle = title + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/PageTabbarCell.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/PageTabbarCell.swift new file mode 100644 index 00000000..d26591f8 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/PageTabbarCell.swift @@ -0,0 +1,58 @@ +import UIKit + +import SnapKit + +public class PageTabbarCell: UICollectionViewCell { + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .b_m_r + label.textColor = .neutral600 + label.numberOfLines = 1 + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.8 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addViews() + setupConstraints() + configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override var isSelected: Bool { + didSet { + let font: UIFont? = isSelected ? .sub_m_b : .b_m_r + let textColor: UIColor? = isSelected ? .textColor : .neutral600 + titleLabel.font = font + titleLabel.textColor = textColor + } + } +} + +// MARK: - SetUp +private extension PageTabbarCell { + func addViews() { + contentView.addSubview(titleLabel) + } + + func setupConstraints() { + titleLabel.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview() + make.centerY.equalToSuperview() + } + } + + func configureUI() { } +} + +public extension PageTabbarCell { + func inject(title: String?) { + titleLabel.text = title + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/TapButtonCell.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/TapButtonCell.swift new file mode 100644 index 00000000..15a9c49a --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/TapButtonCell.swift @@ -0,0 +1,50 @@ +import UIKit + +import SnapKit + +public class TapButtonCell: UICollectionViewCell { + + public let button: TapButton = { + let button = TapButton() + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addViews() + setupConstraints() + configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override var isSelected: Bool { + didSet { + button.isSelected = isSelected + } + } +} + +// MARK: - SetUp +private extension TapButtonCell { + func addViews() { + contentView.addSubview(button) + } + + func setupConstraints() { + button.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + func configureUI() { } +} + +public extension TapButtonCell { + func inject(title: String?) { + button.text = title + button.isUserInteractionEnabled = false + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignSystemAsset.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignSystemAsset.swift index 9ac04ad1..f6a15335 100644 --- a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignSystemAsset.swift +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Utills/DesignSystemAsset.swift @@ -5,9 +5,10 @@ public enum DesignSystemAsset { public static func image(named name: String) -> UIImage { guard let image = UIImage(named: name, in: .module, compatibleWith: nil) else { - fatalError("❌ Image not found: \(name)") - } - return image + print("❌ Image not found: \(name)") + return UIImage() + } + return image } } diff --git a/MLS/MLSDictionaryFeature/Package.swift b/MLS/MLSDictionaryFeature/Package.swift index b52ab420..bfff778b 100644 --- a/MLS/MLSDictionaryFeature/Package.swift +++ b/MLS/MLSDictionaryFeature/Package.swift @@ -7,21 +7,79 @@ let package = Package( name: "MLSDictionaryFeature", platforms: [.iOS(.v15)], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "MLSDictionaryFeatureInterface", + targets: ["MLSDictionaryFeatureInterface"] + ), .library( name: "MLSDictionaryFeature", targets: ["MLSDictionaryFeature"] ), + .library( + name: "MLSDictionaryFeatureTesting", + targets: ["MLSDictionaryFeatureTesting"] + ) + ], + dependencies: [ + .package(path: "../MLSAuthFeature"), + .package(path: "../MLSMyPageFeature"), + .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: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. + // Interface + .target( + name: "MLSDictionaryFeatureInterface", + dependencies: [ + .product(name: "MLSCore", package: "MLSCore"), + .product(name: "MLSDesignSystem", package: "MLSDesignSystem"), + .product(name: "RxSwift", package: "RxSwift") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + // Feature .target( - name: "MLSDictionaryFeature" + name: "MLSDictionaryFeature", + dependencies: [ + "MLSDictionaryFeatureInterface", + .product(name: "MLSAuthFeatureInterface", package: "MLSAuthFeature"), + .product(name: "MLSCore", package: "MLSCore"), + .product(name: "MLSDesignSystem", package: "MLSDesignSystem"), + .product(name: "MLSMyPageFeatureInterface", package: "MLSMyPageFeature"), + .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: "MLSDictionaryFeatureTesting", + dependencies: [ + "MLSDictionaryFeatureInterface", + .product(name: "MLSAuthFeatureInterface",package: "MLSAuthFeature"), + .product(name: "MLSMyPageFeatureInterface",package: "MLSMyPageFeature"), + .product(name: "RxSwift", package: "RxSwift") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + // Tests .testTarget( name: "MLSDictionaryFeatureTests", - dependencies: ["MLSDictionaryFeature"] - ), + dependencies: [ + "MLSDictionaryFeature", + "MLSDictionaryFeatureInterface", + "MLSDictionaryFeatureTesting", + .product(name: "MLSAuthFeatureInterface", package: "MLSAuthFeature"), + .product(name: "RxBlocking", package: "RxSwift") + ], + ) ] ) diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryAllDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryAllDTO.swift new file mode 100644 index 00000000..3f2cf42a --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryAllDTO.swift @@ -0,0 +1,18 @@ +public struct DictionaryAllDTO: DictionaryDTOProtocol { + public let originalId: Int + public let name: String + public let imageUrl: String? + public let level: Int? + public let type: String + public let bookmarkId: Int? + public var id: Int { originalId } + + public init(originalId: Int, name: String, imageUrl: String?, level: Int?, type: String, bookmarkId: Int?) { + self.originalId = originalId + self.name = name + self.imageUrl = imageUrl + self.level = level + self.type = type + self.bookmarkId = bookmarkId + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDTOProtocol.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDTOProtocol.swift new file mode 100644 index 00000000..7fba9deb --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDTOProtocol.swift @@ -0,0 +1,28 @@ +import MLSDictionaryFeatureInterface + +public protocol DictionaryDTOProtocol: Decodable { + var id: Int { get } + var name: String { get } + var imageUrl: String? { get } + var level: Int? { get } + var type: String { get } + var bookmarkId: Int? { get } + + func toDomain() -> DictionaryMainItemResponse? +} + +extension DictionaryDTOProtocol { + public func toDomain() -> DictionaryMainItemResponse? { + if let type = DictionaryItemType(rawValue: type) { + return DictionaryMainItemResponse( + id: id, + name: name, + imageUrl: imageUrl, level: level, + type: type, + bookmarkId: bookmarkId + ) + } else { + return nil + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailItemDropMonsterResponseDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailItemDropMonsterResponseDTO.swift new file mode 100644 index 00000000..893f89cd --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailItemDropMonsterResponseDTO.swift @@ -0,0 +1,13 @@ +import MLSDictionaryFeatureInterface + +public struct DictionaryDetailItemDropMonsterResponseDTO: Decodable { + public let monsterId: Int? + public let monsterName: String? + public let level: Int? + public let dropRate: Double? + public let imageUrl: String? + + public func toDomain() -> DictionaryDetailItemDropMonsterResponse { + return DictionaryDetailItemDropMonsterResponse(monsterId: monsterId, monsterName: monsterName, level: level, dropRate: dropRate, imageUrl: imageUrl) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailItemResponseDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailItemResponseDTO.swift new file mode 100644 index 00000000..3917ef98 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailItemResponseDTO.swift @@ -0,0 +1,35 @@ +import MLSDictionaryFeatureInterface + +public struct DictionaryDetailItemResponseDTO: Decodable { + public let itemId: Int + public let nameKr: String? + public let nameEn: String? + public let descriptionText: String? + public let itemImageUrl: String? + public let npcPrice: Int? + public let itemType: String? + public let categoryHierachy: CategoryHierachy? + public let availableJobs: [Jobs]? + public let requiredStats: RequiredStats? // 요구 스탯 + public let equipmentStats: EquipmentStats? // 착용하면 올라가는 스탯 + public let scrollDetail: ScrollDetail? // 주문서 상세정보 + public let bookmarkId: Int? + + public func toDomain() -> DictionaryDetailItemResponse { + return DictionaryDetailItemResponse( + itemId: itemId, + nameKr: nameKr, + nameEn: nameEn, + descriptionText: descriptionText, + imgUrl: itemImageUrl, + npcPrice: npcPrice, + itemType: itemType, + categoryHierachy: categoryHierachy, + availableJobs: availableJobs, + requiredStats: requiredStats, + equipmentStats: equipmentStats, + scrollDetail: scrollDetail, + bookmarkId: bookmarkId + ) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMapNpcResponseDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMapNpcResponseDTO.swift new file mode 100644 index 00000000..c4d436be --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMapNpcResponseDTO.swift @@ -0,0 +1,12 @@ +import MLSDictionaryFeatureInterface + +public struct DictionaryDetailMapNpcResponseDTO: Decodable { + public let npcId: Int? + public let npcName: String? + public let npcNameEn: String? + public let iconUrl: String? + + public func toDomain() -> DictionaryDetailMapNpcResponse { + return DictionaryDetailMapNpcResponse(npcId: npcId, npcName: npcName, npcNameEn: npcNameEn, iconUrl: iconUrl) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMapResponseDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMapResponseDTO.swift new file mode 100644 index 00000000..18ba133c --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMapResponseDTO.swift @@ -0,0 +1,17 @@ +import MLSDictionaryFeatureInterface + +public struct DictionaryDetailMapResponseDTO: Decodable { + public let mapId: Int + public let nameKr: String? + public let nameEn: String? + public let regionName: String? + public let detailName: String? + public let topRegionName: String? + public let mapUrl: String? + public let iconUrl: String? + public let bookmarkId: Int? + + public func toDomain() -> DictionaryDetailMapResponse { + return DictionaryDetailMapResponse(mapId: mapId, nameKr: nameKr, nameEn: nameEn, regionName: regionName, detailName: detailName, topRegionName: topRegionName, mapUrl: mapUrl, iconUrl: iconUrl, bookmarkId: bookmarkId) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMapSpawnMonsterResponseDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMapSpawnMonsterResponseDTO.swift new file mode 100644 index 00000000..dd427ea2 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMapSpawnMonsterResponseDTO.swift @@ -0,0 +1,13 @@ +import MLSDictionaryFeatureInterface + +public struct DictionaryDetailMapSpawnMonsterResponseDTO: Decodable { + public let monsterId: Int? + public let monsterName: String? + public let level: Int? + public let maxSpawnCount: Int? + public let imageUrl: String? + + public func toDomain() -> DictionaryDetailMapSpawnMonsterResponse { + return DictionaryDetailMapSpawnMonsterResponse(monsterId: monsterId, monsterName: monsterName, level: level, maxSpawnCount: maxSpawnCount, imageUrl: imageUrl) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMonsterDropItemResponseDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMonsterDropItemResponseDTO.swift new file mode 100644 index 00000000..5e58f98e --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMonsterDropItemResponseDTO.swift @@ -0,0 +1,13 @@ +import MLSDictionaryFeatureInterface + +public struct DictionaryDetailMonsterDropItemResponseDTO: Decodable { + public let itemId: Int + public let itemName: String + public let dropRate: Double + public let imageUrl: String + public let itemLevel: Int + + public func toDomain() -> DictionaryDetailMonsterDropItemResponse { + DictionaryDetailMonsterDropItemResponse(itemId: itemId, itemName: itemName, dropRate: dropRate, imageUrl: imageUrl, itemLevel: itemLevel) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMonsterMapResponseDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMonsterMapResponseDTO.swift new file mode 100644 index 00000000..538e3fba --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMonsterMapResponseDTO.swift @@ -0,0 +1,15 @@ +import MLSDictionaryFeatureInterface + +public struct DictionaryDetailMonsterMapResponseDTO: Decodable { + public let mapId: Int + public let mapName: String + public let regionName: String + public let detailName: String + public let topRegionName: String + public let iconUrl: String + public let maxSpawnCount: Int? + + public func toDomain() -> DictionaryDetailMonsterMapResponse { + return DictionaryDetailMonsterMapResponse(mapId: mapId, mapName: mapName, regionName: regionName, detailName: detailName, topRegionName: topRegionName, iconUrl: iconUrl, maxSpawnCount: maxSpawnCount) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMonsterResponseDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMonsterResponseDTO.swift new file mode 100644 index 00000000..b8e0ffa1 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailMonsterResponseDTO.swift @@ -0,0 +1,43 @@ +import MLSDictionaryFeatureInterface + +public struct DictionaryDetailMonsterResponseDTO: Decodable { + public let monsterId: Int + public let nameKr: String + public let nameEn: String + public let imageUrl: String + public let level: Int + public let exp: Int + public let hp: Int + public let mp: Int + public let physicalDefense: Int + public let magicDefense: Int + public let requiredAccuracy: Int + public let bonusAccuracyPerLevelLower: Double + public let evasionRate: Int + public let mesoDropAmount: Int? + public let mesoDropRate: Int? + public let typeEffectiveness: Effectiveness? + public let bookmarkId: Int? + + public func toDomain() -> DictionaryDetailMonsterResponse { + return DictionaryDetailMonsterResponse( + monsterId: monsterId, + nameKr: nameKr, + nameEn: nameEn, + imageUrl: imageUrl, + level: level, + exp: exp, + hp: hp, + mp: mp, + physicalDefense: physicalDefense, + magicDefense: magicDefense, + requiredAccuracy: requiredAccuracy, + bonusAccuracyPerLevelLower: bonusAccuracyPerLevelLower, + evasionRate: evasionRate, + mesoDropAmount: mesoDropAmount, + mesoDropRate: mesoDropRate, + typeEffectiveness: typeEffectiveness, + bookmarkId: bookmarkId + ) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailNpcQuestResponseDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailNpcQuestResponseDTO.swift new file mode 100644 index 00000000..7ab0e6e6 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailNpcQuestResponseDTO.swift @@ -0,0 +1,14 @@ +import MLSDictionaryFeatureInterface + +public struct DictionaryDetailNpcQuestResponseDTO: Decodable { + public let questId: Int + public let questNameKr: String + public let questNameEn: String + public let questIconUrl: String + public let minLevel: Int? + public let maxLevel: Int? + + public func toDomain() -> DictionaryDetailNpcQuestResponse { + return DictionaryDetailNpcQuestResponse(questId: questId, questNameKr: questNameKr, questNameEn: questNameEn, questIconUrl: questIconUrl, minLevel: minLevel, maxLevel: maxLevel) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailNpcResponseDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailNpcResponseDTO.swift new file mode 100644 index 00000000..8e5963fa --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailNpcResponseDTO.swift @@ -0,0 +1,13 @@ +import MLSDictionaryFeatureInterface + +public struct DictionaryDetailNpcResponseDTO: Decodable { + public let npcId: Int + public let nameKr: String + public let nameEn: String + public let iconUrlDetail: String? + public let bookmarkId: Int? + + public func toDomain() -> DictionaryDetailNpcResponse { + return DictionaryDetailNpcResponse(npcId: npcId, nameKr: nameKr, nameEn: nameEn, iconUrlDetail: iconUrlDetail, bookmarkId: bookmarkId) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailQuestLinkedQuestsResponseDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailQuestLinkedQuestsResponseDTO.swift new file mode 100644 index 00000000..298ea608 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailQuestLinkedQuestsResponseDTO.swift @@ -0,0 +1,10 @@ +import MLSDictionaryFeatureInterface + +public struct DictionaryDetailQuestLinkedQuestsResponseDTO: Decodable { + public let previousQuests: [Quest]? + public let nextQuests: [Quest]? + + public func toDomain() -> DictionaryDetailQuestLinkedQuestsResponse { + return DictionaryDetailQuestLinkedQuestsResponse(previousQuests: previousQuests, nextQuests: nextQuests) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailQuestResponseDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailQuestResponseDTO.swift new file mode 100644 index 00000000..73de6515 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryDetailQuestResponseDTO.swift @@ -0,0 +1,45 @@ +import MLSDictionaryFeatureInterface + +public struct DictionaryDetailQuestResponseDTO: Decodable { + public let questId: Int + public let titlePrefix: String? + public let nameKr: String? + public let nameEn: String? + public let iconUrl: String? + public let questType: String? + public let minLevel: Int? + public let maxLevel: Int? + public let requiredMesoStart: Int? + public let startNpcId: Int? + public let startNpcName: String? + public let endNpcId: Int? + public let endNpcName: String? + public let reward: Reward? + public let rewardItems: [RewardItem]? + public let requirements: [Requirements]? + public let allowedJobs: [AllowedJob]? + public let bookmarkId: Int? + + public func toDomain() -> DictionaryDetailQuestResponse { + return DictionaryDetailQuestResponse( + questId: questId, + titlePrefix: titlePrefix, + nameKr: nameKr, + nameEn: nameEn, + iconUrl: iconUrl, + questType: questType, + minLevel: minLevel, + maxLevel: maxLevel, + requiredMesoStart: requiredMesoStart, + startNpcId: startNpcId, + startNpcName: startNpcName, + endNpcId: endNpcId, + endNpcName: endNpcName, + reward: reward, + rewardItems: rewardItems, + requirements: requirements, + allowedJobs: allowedJobs, + bookmarkId: bookmarkId + ) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryItemDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryItemDTO.swift new file mode 100644 index 00000000..1174483e --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryItemDTO.swift @@ -0,0 +1,18 @@ +public struct DictionaryItemDTO: DictionaryDTOProtocol { + public let itemId: Int + public let name: String + public let imageUrl: String? + public let level: Int? + public let type: String + public let bookmarkId: Int? + public var id: Int { itemId } + + public init(itemId: Int, name: String, imageUrl: String?, level: Int?, type: String, bookmarkId: Int?) { + self.itemId = itemId + self.name = name + self.imageUrl = imageUrl + self.level = level + self.type = type + self.bookmarkId = bookmarkId + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMapDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMapDTO.swift new file mode 100644 index 00000000..be9d876c --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMapDTO.swift @@ -0,0 +1,18 @@ +public struct DictionaryMapDTO: DictionaryDTOProtocol { + public let mapId: Int + public let name: String + public let imageUrl: String? + public let level: Int? + public let type: String + public let bookmarkId: Int? + public var id: Int { mapId } + + public init(mapId: Int, name: String, imageUrl: String?, level: Int?, type: String, bookmarkId: Int?) { + self.mapId = mapId + self.name = name + self.imageUrl = imageUrl + self.level = level + self.type = type + self.bookmarkId = bookmarkId + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMonsterDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMonsterDTO.swift new file mode 100644 index 00000000..6601796f --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMonsterDTO.swift @@ -0,0 +1,18 @@ +public struct DictionaryMonsterDTO: DictionaryDTOProtocol { + public let monsterId: Int + public let name: String + public let imageUrl: String? + public let level: Int? + public let type: String + public let bookmarkId: Int? + public var id: Int { monsterId } + + public init(monsterId: Int, name: String, imageUrl: String?, level: Int?, type: String, bookmarkId: Int?) { + self.monsterId = monsterId + self.name = name + self.imageUrl = imageUrl + self.level = level + self.type = type + self.bookmarkId = bookmarkId + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryNPCDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryNPCDTO.swift new file mode 100644 index 00000000..b496c87e --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryNPCDTO.swift @@ -0,0 +1,18 @@ +public struct DictionaryNPCDTO: DictionaryDTOProtocol { + public let npcId: Int + public let name: String + public let imageUrl: String? + public let level: Int? + public let type: String + public let bookmarkId: Int? + public var id: Int { npcId } + + public init(npcId: Int, name: String, imageUrl: String?, level: Int?, type: String, bookmarkId: Int?) { + self.npcId = npcId + self.name = name + self.imageUrl = imageUrl + self.level = level + self.type = type + self.bookmarkId = bookmarkId + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryQuestDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryQuestDTO.swift new file mode 100644 index 00000000..69ff8b29 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryQuestDTO.swift @@ -0,0 +1,18 @@ +public struct DictionaryQuestDTO: DictionaryDTOProtocol { + public let questId: Int + public let name: String + public let imageUrl: String? + public let level: Int? + public let type: String + public let bookmarkId: Int? + public var id: Int { questId } + + public init(questId: Int, name: String, imageUrl: String?, level: Int?, type: String, bookmarkId: Int?) { + self.questId = questId + self.name = name + self.imageUrl = imageUrl + self.level = level + self.type = type + self.bookmarkId = bookmarkId + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/PagedListResponseDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/PagedListResponseDTO.swift new file mode 100644 index 00000000..7e683f46 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/PagedListResponseDTO.swift @@ -0,0 +1,23 @@ +import MLSDictionaryFeatureInterface + +public struct PagedListResponseDTO: Decodable { + public let totalPages: Int + public let totalElements: Int + public let content: [Item] + + public init(totalPages: Int, totalElements: Int, content: [Item]) { + self.totalPages = totalPages + self.totalElements = totalElements + self.content = content + } +} + +public extension PagedListResponseDTO where Item: DictionaryDTOProtocol { + func toDomain() -> DictionaryMainResponse { + DictionaryMainResponse( + totalPages: totalPages, + totalElements: totalElements, + contents: content.compactMap { $0.toDomain() } + ) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/SearchCountDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/SearchCountDTO.swift new file mode 100644 index 00000000..b8ba702d --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/SearchCountDTO.swift @@ -0,0 +1,9 @@ +import MLSDictionaryFeatureInterface + +public struct SearchCountDTO: Decodable { + public let counts: Int? + + public func toDomain() -> SearchCountResponse { + return SearchCountResponse(count: counts) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Endpoints/DictionaryDetailEndPoint.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Endpoints/DictionaryDetailEndPoint.swift new file mode 100644 index 00000000..a49282fb --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Endpoints/DictionaryDetailEndPoint.swift @@ -0,0 +1,66 @@ +import MLSCore + +public enum DictionaryDetailEndPoint { + static let base = "https://mapleland.2megabytes.me" + + // 몬스터 디테일 상세정보 + public static func fetchMonsterDetail(id: Int) -> ResponsableEndPoint { + return .init(baseURL: base, path: "/api/v1/monsters/\(id)", method: .GET) + } + + // 몬스터 디테일 드롭아이템 + public static func fetchMonsterDetailDropItem(id: Int, query: Encodable) -> ResponsableEndPoint<[DictionaryDetailMonsterDropItemResponseDTO]> { + return .init(baseURL: base, path: "/api/v1/monsters/\(id)/items", method: .GET, query: query) + } + + // 몬스터 디테일 출현맵 + public static func fetchMonsterDetailMap(id: Int) -> ResponsableEndPoint<[DictionaryDetailMonsterMapResponseDTO]> { + return .init(baseURL: base, path: "/api/v1/monsters/\(id)/maps", method: .GET) + } + + // Npc 디테일 상세정보 + public static func fetchNpcDetail(id: Int) -> ResponsableEndPoint { + return .init(baseURL: base, path: "/api/v1/npcs/\(id)", method: .GET) + } + // Npc 디테일 퀘스트 + public static func fetchNpcDetailQuest(id: Int, query: Encodable) -> ResponsableEndPoint<[DictionaryDetailNpcQuestResponseDTO]> { + return .init(baseURL: base, path: "/api/v1/npcs/\(id)/quests", method: .GET, query: query) + } + // NPC 디테일 맵 + public static func fetchNpcDetailMap(id: Int) -> ResponsableEndPoint<[DictionaryDetailMonsterMapResponseDTO]> { + return .init(baseURL: base, path: "/api/v1/npcs/\(id)/maps", method: .GET) + } + // Item 디테일 상세정보 + public static func fetchItemDetail(id: Int) -> ResponsableEndPoint { + return .init(baseURL: base, path: "/api/v1/items/\(id)", method: .GET) + } + // Item 디테일 드롭몬스터 상세정보 + public static func fetchItemDetailDropMonster(id: Int, query: Encodable) -> ResponsableEndPoint<[DictionaryDetailItemDropMonsterResponseDTO]> { + return .init(baseURL: base, path: "/api/v1/items/\(id)/monsters", method: .GET, query: query) + } + + // Quest 디테일 상세정보 + public static func fetchQuestDetail(id: Int) -> ResponsableEndPoint { + return .init(baseURL: base, path: "/api/v1/quests/\(id)", method: .GET) + } + + // Quest 디테일 연계퀘스트 + public static func fetchQuestDetailLinkedQuests(id: Int) -> ResponsableEndPoint { + return .init(baseURL: base, path: "/api/v1/quests/\(id)/chain", method: .GET) + } + + // Map 디테일 상세정보 + public static func fetchMapDetail(id: Int) -> ResponsableEndPoint { + return .init(baseURL: base, path: "/api/v1/maps/\(id)", method: .GET) + } + + // Map 디테일 출현 몬스터 + public static func fetchMapDetailSpawnMonster(id: Int, query: Encodable) -> ResponsableEndPoint<[DictionaryDetailMapSpawnMonsterResponseDTO]> { + return .init(baseURL: base, path: "/api/v1/maps/\(id)/monsters", method: .GET, query: query) + } + + // Map 디테일 출현 npc + public static func fetchMapDetailNpc(id: Int) -> ResponsableEndPoint<[DictionaryDetailMapNpcResponseDTO]> { + return .init(baseURL: base, path: "/api/v1/maps/\(id)/npcs", method: .GET) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Endpoints/DictionaryListEndPoint.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Endpoints/DictionaryListEndPoint.swift new file mode 100644 index 00000000..47a81015 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Endpoints/DictionaryListEndPoint.swift @@ -0,0 +1,60 @@ +import Foundation +import UIKit + +import MLSCore +import MLSDictionaryFeatureInterface + +public enum DictionaryListEndPoint { + static let base = "https://mapleland.2megabytes.me" + // 검색 카운트 + public static func fetchListCount(type: String, keyword: String?) -> ResponsableEndPoint { + let query = ["keyword": keyword ?? ""] + return .init(baseURL: base, path: "/api/v1/\(type)/counts", method: .GET, query: query) + } + // 전체 리스트 + public static func fetchAllList(keyword: String?, page: Int? = nil, size: Int? = nil) -> ResponsableEndPoint> { + let query = DictionaryListQuery(keyword: keyword ?? "", page: page ?? 0, size: size ?? 20, sort: nil) + return .init(baseURL: base, path: "/api/v1/search", method: .GET, query: query) + } + // 몬스터 리스트 + public static func fetchMonsterList(keyword: String?, minLevel: Int?, maxLevel: Int?, page: Int, size: Int, sort: String?) -> ResponsableEndPoint> { + let query = DictionaryListQuery(keyword: keyword ?? "", page: page, size: size, sort: sort, minLevel: minLevel ?? 1, maxLevel: maxLevel ?? 200) + return .init(baseURL: base, path: "/api/v1/monsters", method: .GET, query: query + ) + } + // NPC 리스트 + public static func fetchNPCList(keyword: String?, page: Int, size: Int, sort: String?) -> ResponsableEndPoint> { + let query = DictionaryListQuery(keyword: keyword ?? "", page: page, size: size, sort: sort) + return .init(baseURL: base, path: "/api/v1/npcs", method: .GET, query: query + ) + } + // 퀘스트 리스트 + public static func fetchQuestList(keyword: String?, page: Int, size: Int, sort: String?) -> ResponsableEndPoint> { + let query = DictionaryListQuery(keyword: keyword ?? "", page: page, size: size, sort: sort) + return .init(baseURL: base, path: "/api/v1/quests", method: .GET, query: query + ) + } + // 아이템 리스트 + public static func fetchItemList( + keyword: String? = nil, + jobId: [Int]? = nil, + minLevel: Int? = nil, + maxLevel: Int? = nil, + categoryIds: [Int]? = nil, + page: Int? = nil, + size: Int? = nil, + sort: String? = nil + ) -> ResponsableEndPoint> { + let joinedCategoryIds = categoryIds?.map(String.init).joined(separator: ",") + let joinedJobIds = jobId?.map(String.init).joined(separator: ",") + + let query = DictionaryListQuery(keyword: keyword, page: page ?? 0, size: size ?? 20, sort: sort, minLevel: minLevel, maxLevel: maxLevel, jobIds: joinedJobIds, categoryIds: joinedCategoryIds) + return .init(baseURL: base, path: "/api/v1/items", method: .GET, query: query + ) + } + // 맵 리스트 + public static func fetchMapList(keyword: String?, page: Int, size: Int, sort: String?) -> ResponsableEndPoint> { + let query = DictionaryListQuery(keyword: keyword ?? "", page: page, size: size, sort: sort) + return .init(baseURL: base, path: "/api/v1/maps", method: .GET, query: query) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/DictionaryDetailAPIRepositoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/DictionaryDetailAPIRepositoryImpl.swift new file mode 100644 index 00000000..6156fc49 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/DictionaryDetailAPIRepositoryImpl.swift @@ -0,0 +1,86 @@ +import MLSCore +import MLSDictionaryFeatureInterface + +import RxSwift + +public final class DictionaryDetailAPIRepositoryImpl: DictionaryDetailAPIRepository { + + private let provider: NetworkProvider + private let tokenInterceptor: Interceptor? + + public init(provider: NetworkProvider, tokenInterceptor: Interceptor?) { + self.provider = provider + self.tokenInterceptor = tokenInterceptor + } + + // MARK: - 몬스터 디테일 상세정보 + public func fetchMonsterDetail(id: Int) -> Observable { + let endPoint = DictionaryDetailEndPoint.fetchMonsterDetail(id: id) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map { $0.toDomain() } + } + + public func fetchMonsterDetailDropItem(id: Int, sort: String?) -> Observable<[DictionaryDetailMonsterDropItemResponse]> { + let endPoint = DictionaryDetailEndPoint.fetchMonsterDetailDropItem(id: id, query: SortQuery(sort: sort)) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map {$0.map {$0.toDomain()}} + } + + public func fetchMonsterDetailMap(id: Int) -> Observable<[DictionaryDetailMonsterMapResponse]> { + let endPoint = DictionaryDetailEndPoint.fetchMonsterDetailMap(id: id) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map { $0.map {$0.toDomain() }} + } + // MARK: - Npc 디테일 상세정보 + public func fetchNpcDetail(id: Int) -> Observable { + let endPoint = DictionaryDetailEndPoint.fetchNpcDetail(id: id) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map { $0.toDomain() } + } + + public func fetchNpcDetailQuest(id: Int, sort: String?) -> Observable<[DictionaryDetailNpcQuestResponse]> { + let endPoint = DictionaryDetailEndPoint.fetchNpcDetailQuest(id: id, query: SortQuery(sort: sort)) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map { $0.map {$0.toDomain()} } + + } + + public func fetchNpcDetailMap(id: Int) -> Observable<[DictionaryDetailMonsterMapResponse]> { + let endPoint = DictionaryDetailEndPoint.fetchNpcDetailMap(id: id) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map { $0.map {$0.toDomain()} } + } + + public func fetchItemDetail(id: Int) -> Observable { + let endPoint = DictionaryDetailEndPoint.fetchItemDetail(id: id) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map { $0.toDomain() } + } + + public func fetchItemDetailDropMonster(id: Int, sort: String?) -> Observable<[DictionaryDetailItemDropMonsterResponse]> { + let endPoint = DictionaryDetailEndPoint.fetchItemDetailDropMonster(id: id, query: SortQuery(sort: sort)) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map { $0.map {$0.toDomain() } } + } + + public func fetchQuestDetail(id: Int) -> Observable { + let endPoint = DictionaryDetailEndPoint.fetchQuestDetail(id: id) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map { $0.toDomain() } + } + + public func fetchQuestDetailLinkedQuestsDetail(id: Int) -> Observable { + let endPoint = DictionaryDetailEndPoint.fetchQuestDetailLinkedQuests(id: id) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map { $0.toDomain() } + } + + public func fetchMapDetail(id: Int) -> Observable { + let endPoint = DictionaryDetailEndPoint.fetchMapDetail(id: id) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map { $0.toDomain() } + } + + public func fetchMapDetailSpawnMonster(id: Int, sort: String?) -> Observable<[DictionaryDetailMapSpawnMonsterResponse]> { + let endPoint = DictionaryDetailEndPoint.fetchMapDetailSpawnMonster(id: id, query: SortQuery(sort: sort)) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map { $0.map {$0.toDomain()} } + } + + public func fetchMapDetailNpc(id: Int) -> Observable<[DictionaryDetailMapNpcResponse]> { + let endPoint = DictionaryDetailEndPoint.fetchMapDetailNpc(id: id) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map {$0.map {$0.toDomain()}} + } +} + +struct SortQuery: Encodable { + let sort: String? +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/DictionaryListAPIRepositoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/DictionaryListAPIRepositoryImpl.swift new file mode 100644 index 00000000..a65f2b8a --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/DictionaryListAPIRepositoryImpl.swift @@ -0,0 +1,59 @@ +import MLSCore +import MLSDictionaryFeatureInterface + +import RxSwift + +public final class DictionaryListAPIRepositoryImpl: DictionaryListAPIRepository { + private let provider: NetworkProvider + private let tokenInterceptor: Interceptor? + + public init(provider: NetworkProvider, tokenInterceptor: Interceptor? = nil) { + self.provider = provider + self.tokenInterceptor = tokenInterceptor + } + // MARK: - 검색 카운트 + public func fetchSearchListCount(type: String, keyword: String?) -> Observable { + let endPoint = DictionaryListEndPoint.fetchListCount(type: type, keyword: keyword) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map {$0.toDomain()} + } + // MARK: - 검색 리스트 + public func fetchSearchList(keyword: String?) -> Observable { + let endPoint = DictionaryListEndPoint.fetchAllList(keyword: keyword) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map {$0.toDomain()} + } + // MARK: - 전체 리스트 + public func fetchAllList(keyword: String?, page: Int?) -> Observable { + let endPoint = DictionaryListEndPoint.fetchAllList(keyword: keyword, page: page) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor).map {$0.toDomain()} + } + // MARK: - 몬스터 리스트 + public func fetchMonsterList(keyword: String?, minLevel: Int?, maxLevel: Int?, page: Int, size: Int, sort: String?) -> Observable { + let endPoint = DictionaryListEndPoint.fetchMonsterList(keyword: keyword, minLevel: minLevel, maxLevel: maxLevel, page: page, size: size, sort: sort ?? "ASC") + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) + .map { $0.toDomain() } + } + // MARK: - NPC 리스트 + public func fetchNpcList(keyword: String, page: Int, size: Int, sort: String?) -> Observable { + let endPoint = DictionaryListEndPoint.fetchNPCList(keyword: keyword, page: page, size: 20, sort: sort ?? "ASC") + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) + .map { $0.toDomain() } + } + // MARK: - Quest 리스트 + public func fetchQuestList(keyword: String, page: Int, size: Int, sort: String?) -> Observable { + let endPoint = DictionaryListEndPoint.fetchQuestList(keyword: keyword, page: page, size: 20, sort: sort ?? "ASC") + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) + .map { $0.toDomain() } + } + // MARK: - Item 리스트 + public func fetchItemList(keyword: String?, jobId: [Int]?, minLevel: Int?, maxLevel: Int?, categoryIds: [Int]?, page: Int?, size: Int?, sort: String?) -> Observable { + let endPoint = DictionaryListEndPoint.fetchItemList(keyword: keyword, jobId: jobId, minLevel: minLevel, maxLevel: maxLevel, categoryIds: categoryIds, page: page, size: size, sort: sort) + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) + .map { $0.toDomain() } + } + // MARK: - Map 리스트 + public func fetchMapList(keyword: String, page: Int, size: Int, sort: String?) -> Observable { + let endPoint = DictionaryListEndPoint.fetchMapList(keyword: keyword, page: page, size: 20, sort: sort ?? "ASC") + return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) + .map { $0.toDomain() } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/RecentSearchRepositoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/RecentSearchRepositoryImpl.swift new file mode 100644 index 00000000..3307cb26 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/RecentSearchRepositoryImpl.swift @@ -0,0 +1,67 @@ +import Foundation + +import MLSDictionaryFeatureInterface + +import RxSwift + +public final class RecentSearchRepositoryImpl: RecentSearchRepository { + private let recentSearchkey = "recentSearch" + + public init() {} + + public func fetchRecentSearch() -> Observable<[String]> { + return Observable.create { observer in + let current = UserDefaults.standard.stringArray(forKey: self.recentSearchkey) ?? [] + observer.onNext(current) + observer.onCompleted() + return Disposables.create() + } + } + + public func addRecentSearch(keyword: String) -> Completable { + let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !keyword.isEmpty else { + return .empty() + } + + var current = UserDefaults.standard.stringArray(forKey: recentSearchkey) ?? [] + + current.removeAll(where: { $0 == keyword }) + current.insert(keyword, at: 0) + + UserDefaults.standard.set(current, forKey: recentSearchkey) + + return .empty() + } + + public func removeRecentSearch(keyword: String) -> Completable { + return Completable.create { completable in + var current = UserDefaults.standard.stringArray(forKey: self.recentSearchkey) ?? [] + + // 해당 키워드 제거 + current.removeAll { $0 == keyword } + + // 다시 저장 + UserDefaults.standard.set(current, forKey: self.recentSearchkey) + + completable(.completed) + return Disposables.create() + } + } + + public func removeAllSearch() -> Completable { + return Completable.create { completable in + var current = UserDefaults.standard.stringArray(forKey: self.recentSearchkey) ?? [] + + // 해당 키워드 제거 + current.removeAll() + + // 다시 저장 + UserDefaults.standard.set(current, forKey: self.recentSearchkey) + + completable(.completed) + return Disposables.create() + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/UserDefaultsRepositoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/UserDefaultsRepositoryImpl.swift new file mode 100644 index 00000000..3b31c140 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/UserDefaultsRepositoryImpl.swift @@ -0,0 +1,28 @@ +import Foundation + +import MLSDictionaryFeatureInterface + +import RxSwift + +public final class UserDefaultsRepositoryImpl: UserDefaultsRepository { + private let dictionaryDetailkey = "dictionaryDetailkey" + + public init() {} + + public func fetchDictionaryDetail() -> Observable { + return Observable.create { observer in + let hasVisited = UserDefaults.standard.bool(forKey: self.dictionaryDetailkey) + observer.onNext(hasVisited) + observer.onCompleted() + return Disposables.create() + } + } + + public func saveDictionaryDetail() -> Completable { + return Completable.create { completable in + UserDefaults.standard.set(true, forKey: self.dictionaryDetailkey) + completable(.completed) + return Disposables.create() + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/CheckLoginUseCaseImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/CheckLoginUseCaseImpl.swift new file mode 100644 index 00000000..74d8866a --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/CheckLoginUseCaseImpl.swift @@ -0,0 +1,46 @@ +import MLSAuthFeatureInterface +import MLSDictionaryFeatureInterface + +import RxRelay +import RxSwift + +public final class CheckLoginUseCaseImpl: CheckLoginUseCase { + private let authRepository: AuthAPIRepository + private let tokenRepository: TokenRepository + + public init(authRepository: AuthAPIRepository, tokenRepository: TokenRepository) { + self.authRepository = authRepository + self.tokenRepository = tokenRepository + } + + public func execute() -> Observable { + switch tokenRepository.fetchToken(type: .refreshToken) { + case .success(let token): + guard !token.isEmpty else { return .just(false) } + + return authRepository.reissueToken(refreshToken: token) + .map { [weak self] response in + guard let self else { return false } + + let accessResult = self.tokenRepository.saveToken(type: .accessToken, value: response.accessToken) + let refreshResult = self.tokenRepository.saveToken(type: .refreshToken, value: response.refreshToken) + + switch (accessResult, refreshResult) { + case (.success, .success): + return true + case (.failure(let error), _), + (_, .failure(let error)): + print("Token 저장 실패:", error.localizedDescription) + return false + } + } + .catch { error in + print("reissueToken 실패:", error.localizedDescription) + return .just(false) + } + + case .failure: + return .just(false) + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/CheckNotificationPermissionUseCaseImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/CheckNotificationPermissionUseCaseImpl.swift new file mode 100644 index 00000000..b9ed8015 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/CheckNotificationPermissionUseCaseImpl.swift @@ -0,0 +1,23 @@ +import UserNotifications + +import MLSDictionaryFeatureInterface + +import RxSwift + +public final class CheckNotificationPermissionUseCaseImpl: CheckNotificationPermissionUseCase { + public init() {} + + public func execute() -> Single { + return Single.create { single in + UNUserNotificationCenter.current().getNotificationSettings { settings in + switch settings.authorizationStatus { + case .authorized: + single(.success(true)) + default: + single(.success(false)) + } + } + return Disposables.create() + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/FetchVisitDictionaryDetailUseCaseImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/FetchVisitDictionaryDetailUseCaseImpl.swift new file mode 100644 index 00000000..e72aa801 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/FetchVisitDictionaryDetailUseCaseImpl.swift @@ -0,0 +1,23 @@ +import MLSDictionaryFeatureInterface +import Foundation + +import RxSwift + +public class FetchVisitDictionaryDetailUseCaseImpl: FetchVisitDictionaryDetailUseCase { + var repository: UserDefaultsRepository + public init(repository: UserDefaultsRepository) { + self.repository = repository + } + + public func execute() -> Observable { + return repository.fetchDictionaryDetail() + .flatMap { hasVisited -> Observable in + if hasVisited { + return .just(true) + } else { + return self.repository.saveDictionaryDetail() + .andThen(.just(false)) + } + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/ParseItemFilterResultUseCaseImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/ParseItemFilterResultUseCaseImpl.swift new file mode 100644 index 00000000..c80fe5f7 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/ParseItemFilterResultUseCaseImpl.swift @@ -0,0 +1,59 @@ +import MLSDictionaryFeatureInterface + +public final class ParseItemFilterResultUseCaseImpl: ParseItemFilterResultUseCase { + + private let jobIdMap: [String: Int] = [ + "전사": 100, "마법사": 200, "도적": 400, "궁수": 300 + ] + private let categoryIdMap: [String: Int] = [ + "한손검": 7, "한손도끼": 8, "한손둔기": 9, "단검": 10, "완드": 12, + "스태프": 13, "두손검": 14, "두손도끼": 15, "두손둔기": 16, "창": 17, + "폴암": 18, "활": 19, "석궁": 20, "아대": 21, "모자": 24, "상의": 25, + "하의": 26, "전신갑옷": 27, "신발": 28, "장갑": 29, "방패": 31, "망토": 30, + "얼굴장식": 32, "눈장식": 33, "귀고리": 34, "반지": 35, "펜던트": 36, + "벨트": 37, "어깨장식": 38, "화살": 81, "표창": 83, + "한손검주문서": 50, "한손도끼주문서": 51, "한손둔기주문서": 52, + "단검주문서": 53, "완드주문서": 55, "스태프주문서": 56, "두손검주문서": 57, + "두손도끼주문서": 58, "두손둔기주문서": 59, "창주문서": 60, "폴암주문서": 61, + "활주문서": 62, "석궁주문서": 63, "아대주문서": 64, "투구주문서": 67, "상의주문서": 68, "하의주문서": 69, "전신갑옷주문서": 70, "신발주문서": 71, "장갑주문서": 72, "망토주문서": 73, "방패주문서": 74, + "귀장식주문서": 78, "펫장비주문서": 75, "연성서주문서": 76, "귀환주문서": 77, + "마스터리북": 42, "스킬북": 43, "소비": 44, "설치": 45, "이동수단": 46 + ] + + public init() {} + + public func execute(results: [(String, String)]) -> ItemFilterCriteria { + let initialCriteria = (jobIds: [Int](), startLevel: Int?(nil), endLevel: Int?(nil), categoryIds: [Int]()) + + let finalCriteria = results.reduce(into: initialCriteria) { criteria, result in + let (key, value) = result + switch key { + case "직업": + if let id = jobIdMap[value] { + criteria.jobIds.append(id) + } + case "레벨": + let levelText = value.replacingOccurrences(of: "레벨", with: "").trimmingCharacters(in: .whitespaces) + let parts = levelText.split(separator: "~") + .map { $0.trimmingCharacters(in: .whitespaces) } + .map { $0.filter { $0.isNumber } } + if let low = Int(parts.first ?? ""), let high = Int(parts.last ?? "") { + criteria.startLevel = low + criteria.endLevel = high + } + case "무기", "발사체", "방어구", "장신구", "기타아이템": + if let id = categoryIdMap[value] { + criteria.categoryIds.append(id) + } + case "무기주문서", "방어구주문서", "기타주문서": + if let id = categoryIdMap[value + "주문서"] { + criteria.categoryIds.append(id) + } + default: + break + } + } + + return ItemFilterCriteria(jobIds: finalCriteria.jobIds, startLevel: finalCriteria.startLevel, endLevel: finalCriteria.endLevel, categoryIds: finalCriteria.categoryIds) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/SetBookmarkUseCaseImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/SetBookmarkUseCaseImpl.swift new file mode 100644 index 00000000..3cf23f07 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/SetBookmarkUseCaseImpl.swift @@ -0,0 +1,22 @@ +import MLSDictionaryFeatureInterface + +import RxSwift + +public final class SetBookmarkUseCaseImpl: SetBookmarkUseCase { + private let repository: BookmarkRepository + + public init(repository: BookmarkRepository) { + self.repository = repository + } + + public func execute(bookmarkId: Int, isBookmark: IsBookmark) -> Observable { + switch isBookmark { + case .set(let type): + return repository + .setBookmark(bookmarkId: bookmarkId, type: type) + .map { Optional($0) } + case .delete: + return repository.deleteBookmark(bookmarkId: bookmarkId) + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/MLSDictionaryFeature.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/MLSDictionaryFeature.swift deleted file mode 100644 index 08b22b80..00000000 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/MLSDictionaryFeature.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/BaseListView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/BaseListView.swift new file mode 100644 index 00000000..ffaa01a6 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/BaseListView.swift @@ -0,0 +1,178 @@ +import UIKit + +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import SnapKit + +open class BaseListView: UIView { + // MARK: - Type + enum Constant { + static let filterInset: CGFloat = 6 + static let filterHeight: CGFloat = 32 + static let iconSize: CGFloat = 24 + static let stackViewSpacing: CGFloat = 12 + static let topMargin: CGFloat = 12 + static let cellSpacing: CGFloat = 10 + static let cellWidth: CGFloat = 343 + static let cellHeight: CGFloat = 104 + static let horizontalMargin: CGFloat = 16 + static let bottomInset: CGFloat = 64 + } + + // MARK: - Components + public let editButton: UIButton? + public let listCollectionView: UICollectionView + public let sortButton: UIButton + public let filterButton: UIButton + public let emptyView: DataEmptyView + + private lazy var filterStackView: UIStackView = { + var subviews: [UIView] = [] + + if let editButton = editButton { + subviews.append(editButton) + } + subviews.append(UIView()) + subviews.append(sortButton) + subviews.append(filterButton) + + let view = UIStackView(arrangedSubviews: subviews) + view.axis = .horizontal + view.spacing = Constant.stackViewSpacing + view.alignment = .fill + return view + }() + + // MARK: - Init + public init(editButton: UIButton? = nil, + sortButton: UIButton, + filterButton: UIButton, + emptyView: DataEmptyView, + isFilterHidden: Bool) { + self.editButton = editButton + self.sortButton = sortButton + self.filterButton = filterButton + self.emptyView = emptyView + self.listCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout()) + + super.init(frame: .zero) + addViews(isFilterHidden: isFilterHidden) + setupConstraints(isFilterHidden: isFilterHidden) + configureUI() + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { fatalError() } +} + +// MARK: - Setup +private extension BaseListView { + func addViews(isFilterHidden: Bool) { + if !isFilterHidden { + addSubview(filterStackView) + } + addSubview(listCollectionView) + addSubview(emptyView) + } + + func setupConstraints(isFilterHidden: Bool) { + if isFilterHidden { + listCollectionView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + emptyView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } else { + filterStackView.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.topMargin) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalMargin) + make.height.equalTo(Constant.filterHeight) + } + + listCollectionView.snp.makeConstraints { make in + make.top.equalTo(filterStackView.snp.bottom).offset(Constant.topMargin) + make.horizontalEdges.bottom.equalToSuperview() + } + + emptyView.snp.makeConstraints { make in + make.top.equalTo(filterStackView.snp.bottom).offset(Constant.topMargin) + make.horizontalEdges.bottom.equalToSuperview() + } + } + } + + func configureUI() { + backgroundColor = .neutral100 + listCollectionView.backgroundColor = .neutral100 + } +} + +// MARK: - Methods +public extension BaseListView { + func updateFilter(sortType: SortType?) { + let hasFilter = sortType != nil + filterStackView.isHidden = !hasFilter + + listCollectionView.snp.remakeConstraints { make in + if hasFilter { + make.top.equalTo(filterStackView.snp.bottom).offset(Constant.topMargin) + } else { + make.top.equalToSuperview() + } + make.horizontalEdges.bottom.equalToSuperview() + } + + if let sortType = sortType { + sortButton.setAttributedTitle(.makeStyledString(font: .b_s_r, text: sortType.rawValue, color: sortButton.tintColor), for: .normal) + } + } + + func updateBookmarkFilter(type: DictionaryType) { + if type == .total { + filterButton.isHidden = true + } + } + + static func makeSortButton(title: String, tintColor: UIColor) -> UIButton { + let button = UIButton() + button.setAttributedTitle(.makeStyledString(font: .b_s_r, text: title), for: .normal) + button.setImage(DesignSystemAsset.image(named: "lineArrowDown").withRenderingMode(.alwaysTemplate), for: .normal) + button.tintColor = tintColor + button.setTitleColor(tintColor, for: .normal) + button.semanticContentAttribute = .forceRightToLeft + return button + } + + static func makeFilterButton(title: String, tintColor: UIColor) -> UIButton { + let button = UIButton() + button.setAttributedTitle(.makeStyledString(font: .b_s_r, text: title), for: .normal) + button.setImage(DesignSystemAsset.image(named: "filter").withRenderingMode(.alwaysTemplate), for: .normal) + button.tintColor = tintColor + button.setTitleColor(tintColor, for: .normal) + button.semanticContentAttribute = .forceRightToLeft + return button + } + + func selectSort(selectedType: SortType) { + sortButton.setAttributedTitle(.makeStyledString(font: .b_s_r, text: selectedType.rawValue, color: .primary700), for: .normal) + sortButton.tintColor = .primary700 + } + + func selectFilter() { + filterButton.setAttributedTitle(.makeStyledString(font: .b_s_r, text: "필터", color: .primary700), for: .normal) + filterButton.tintColor = .primary700 + } + + func resetFilter() { + filterButton.setAttributedTitle(.makeStyledString(font: .b_s_r, text: "필터"), for: .normal) + filterButton.tintColor = .black + } + + func checkEmptyData(isEmpty: Bool) { + emptyView.isHidden = !isEmpty + listCollectionView.isHidden = isEmpty + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseView.swift new file mode 100644 index 00000000..6edd1233 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseView.swift @@ -0,0 +1,488 @@ +import UIKit + +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import SnapKit + +class DictionaryDetailBaseView: UIView { + // MARK: - Type + public enum Constant { + static let iconInset: CGFloat = 10 + static let navHeight: CGFloat = 44 + static let buttonSize: CGFloat = 44 + static let imageRadius: CGFloat = 24 + static let imageContentViewSize: CGFloat = 160 + static let imageSize: CGFloat = 112 + static let imageBottomMargin: CGFloat = 12 + static let horizontalInset: CGFloat = 16 + static let bookmarkViewSize: CGFloat = 44 + static let bookmarkViewMargin: CGFloat = 6 + static let bookmarkViewInset: CGFloat = 10 + static let textMargin: CGFloat = 4 + static let stickyHeight: CGFloat = 56 + static let dividerHeight: CGFloat = 1 + static let tagsBottomMargin: CGFloat = 30 + static let tabBarHeight: CGFloat = 40 + static let tabBarTopMargin: CGFloat = 30 + static let imageContentTopMargin: CGFloat = 20 + static let tagVerticalSpacing: CGFloat = 10 + static let tabBarSpacing: CGFloat = 20 + static let badgeHeight: CGFloat = 24 + static let numberOfLines: Int = 0 + static let tabBarStackViewInset: UIEdgeInsets = .init(top: 30, left: 16, bottom: 0, right: 16) + static let tagStackViewInset: UIEdgeInsets = .init(top: 10, left: 0, bottom: 10, right: 0) + static let secondSectionStackViewInset: UIEdgeInsets = .init(top: 0, left: 0, bottom: 20, right: 0) + static let stackViewInset: UIEdgeInsets = .init(top: 20, left: 0, bottom: 0, right: 0) + + static let menuTabBarButtonInset: NSDirectionalEdgeInsets = .init(top: 9, leading: 4, bottom: 9, trailing: 4) + static let underLineHeight: CGFloat = 2 + static let underTag: Int = 999999 + } + + // MARK: - Components + /// header에 들어가 컴포넌트들 담을 컨테이너 뷰 + public let headerView: UIView = { + let view = UIView() + return view + }() + + 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 var titleLabel = UILabel() + + public let dictButton: UIButton = { + let button = UIButton() + button + .setImage( + DesignSystemAsset + .image(named: "dictionary") + .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 reportButton: UIButton = { + let button = UIButton() + button + .setImage( + DesignSystemAsset + .image(named: "errorBlack") + .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 scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.backgroundColor = .neutral100 + return scrollView + }() + + /// 스크롤 뷰에 들어갈 컴포넌트들을 담을 스택 뷰 + /// 각 컴포너트들의 간격이 다 다름 + public let stackView: UIStackView = { + let stackView = UIStackView() + // 수직 스택 뷰 + stackView.axis = .vertical + stackView.backgroundColor = .whiteMLS + // 아이템 기본 중앙배치 + stackView.alignment = .center + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = Constant.stackViewInset + return stackView + }() + + public let imageContentView: UIView = { + let view = UIView() + view.layer.cornerRadius = Constant.imageRadius + return view + }() + + // 이미지 뷰 + public let imageView: UIImageView = { + let view = UIImageView() + view.clipsToBounds = true + view.contentMode = .scaleAspectFit + return view + }() + + public let bookmarkContentView: UIView = { + let view = UIView() + return view + }() + + // 북마크 버튼 + public let bookmarkButton = UIButton() + + // 이름 + public let nameLabel: UILabel = { + let label = UILabel() + // 줄 수 제한 없음 + label.numberOfLines = 0 + // 단어 단위로 줄 바꿈 + label.lineBreakMode = .byWordWrapping + // 가운데 정렬 + label.textAlignment = .center + return label + }() + + // SubText - level, 지역 등 + public let subTextLabel: UILabel = { + let label = UILabel() + + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.textAlignment = .center + + return label + }() + + // tagView들을 담는 가로 stackView들을 담을 세로 stackView -> 말이 너무 어려운데.. + // 충분히 이해 하시겠죠...?ㅠㅠ + public let tagsVerticalStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + // 각 가로 태그줄의 간격 10 + stackView.spacing = Constant.tagVerticalSpacing + // horizontal tag들이 중앙정렬 되도록 + stackView.alignment = .center + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = Constant.tagStackViewInset + + return stackView + }() + + // tabBar StackView + public let tabBarStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.backgroundColor = .whiteMLS + stackView.distribution = .fill + stackView.spacing = Constant.tabBarSpacing + stackView.alignment = .leading + // layoutMargins을 사용하여 inset 설정 + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = Constant.tabBarStackViewInset + + return stackView + }() + + // tabBar Sticky StackView + public let tabBarStickyStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.backgroundColor = .whiteMLS + stackView.distribution = .fill + stackView.spacing = Constant.tabBarSpacing + stackView.alignment = .bottom + // layoutMargins을 사용하여 inset 설정 + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = Constant.tabBarStackViewInset + stackView.isHidden = true + + return stackView + }() + + // tabBar 하단 구분선 + public let tabBarDividerView: UIView = { + let view = UIView() + view.backgroundColor = .neutral300 + + return view + }() + + // sticky tab Bar 하단 구분선 + public let stickyTabBarDividerView: UIView = { + let view = UIView() + view.backgroundColor = .neutral300 + view.isHidden = true + return view + }() + + // 두번째 섹션 스택 뷰 (배경색 바뀌는 부분) + public let secondSectionStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .center + stackView.backgroundColor = .neutral100 + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = Constant.secondSectionStackViewInset + + return stackView + }() + + // MARK: - Init + 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 DictionaryDetailBaseView { + func addViews() { + // forEach 활용하여 중복코드 제거 + [backButton, titleLabel, dictButton, reportButton].forEach { headerView.addSubview($0) } + + [headerView, scrollView].forEach { + addSubview($0) + } + // stackView를 scrollView안에 넣어줘야 함 + scrollView.addSubview(stackView) + + [imageContentView, nameLabel, subTextLabel, tagsVerticalStackView].forEach { + // 스택뷰에 subView 추가 + stackView.addArrangedSubview($0) + } + + scrollView.addSubview(secondSectionStackView) + scrollView.addSubview(tabBarStackView) + scrollView.addSubview(tabBarDividerView) + scrollView.addSubview(tabBarStickyStackView) + scrollView.addSubview(stickyTabBarDividerView) + + imageContentView.addSubview(imageView) + imageContentView.addSubview(bookmarkContentView) + bookmarkContentView.addSubview(bookmarkButton) + } + + 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) + } + + titleLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + dictButton.snp.makeConstraints { make in + make.trailing.equalTo(reportButton.snp.leading) + make.centerY.equalToSuperview() + make.size.equalTo(Constant.buttonSize) + } + + reportButton.snp.makeConstraints { make in + make.trailing.centerY.equalToSuperview() + make.size.equalTo(Constant.buttonSize) + } + + scrollView.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom) + make.bottom.equalToSuperview() + make.leading.equalToSuperview() + make.trailing.equalToSuperview() + } + + stackView.snp.makeConstraints { make in + make.top.equalTo(scrollView.snp.top) + make.centerX.equalToSuperview() + make.horizontalEdges.equalToSuperview() + } + + imageContentView.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.size.equalTo(Constant.imageContentViewSize) + } + + imageView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.equalTo(imageContentView.snp.width).multipliedBy(0.42) + } + + bookmarkContentView.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(Constant.bookmarkViewMargin) + make.size.equalTo(Constant.bookmarkViewSize) + } + + bookmarkButton.snp.makeConstraints { make in + make.center.equalToSuperview().inset(Constant.bookmarkViewInset) + } + + // 스택뷰 속 간격 커스텀 -> imageContentView와 다음 스택뷰 셀의 간격 imageBottomMargin 만큼 + stackView.setCustomSpacing(Constant.imageBottomMargin, after: imageContentView) + + nameLabel.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + } + + // nameLabel과 그 아래에 들어올 subText간 간격 조정 + stackView.setCustomSpacing(Constant.textMargin, after: nameLabel) + + subTextLabel.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + } + + stackView.setCustomSpacing(Constant.textMargin, after: subTextLabel) + + tabBarStackView.snp.makeConstraints { make in + // make.height.equalTo(Constant.tabBarHeight) + make.width.equalToSuperview() + make.top.equalTo(stackView.snp.bottom) + } + + tabBarDividerView.snp.makeConstraints { make in + make.height.equalTo(Constant.dividerHeight) + make.top.equalTo(tabBarStackView.snp.bottom) + make.horizontalEdges.equalToSuperview() + } + + // centerX와 horizontal을 각각 잡은이유 + secondSectionStackView.snp.makeConstraints { make in + make.top.equalTo(tabBarStackView.snp.bottom) + make.centerX.equalToSuperview() + make.horizontalEdges.equalToSuperview() + make.bottom.equalTo(scrollView.snp.bottom) + } + + tabBarStickyStackView.snp.makeConstraints { make in + // make.height.equalTo(Constant.stickyHeight) + make.width.equalToSuperview() + make.top.equalTo(headerView.snp.bottom) + } + + stickyTabBarDividerView.snp.makeConstraints { make in + make.top.equalTo(tabBarStickyStackView.snp.bottom) + make.horizontalEdges.equalToSuperview() + make.height.equalTo(Constant.dividerHeight) + } + } + + func configureUI() { + backgroundColor = .whiteMLS + } +} + +extension DictionaryDetailBaseView { + func createHorizontalStackView() -> UIStackView { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = DictionaryDetailBaseView.Constant.tagVerticalSpacing + stackView.distribution = .fill + return stackView + } + + // 메뉴 탭바 버튼 생성하기 + 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 = Constant.underTag + + 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 setBadgeConstraints(_ badge: Badge, width: CGFloat) { + badge.snp.makeConstraints { make in + make.width.equalTo(width) + make.height.equalTo(DictionaryDetailBaseView.Constant.badgeHeight) + } + + } + + func setTabView(index: Int, contentViews: [UIView]) { + // 기존 뷰 제거 + for view in secondSectionStackView.arrangedSubviews { + secondSectionStackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + // 새 뷰 추가 + let newView = contentViews[index] + secondSectionStackView.addArrangedSubview(newView) + + // constraint 유지 + newView.snp.makeConstraints { make in + make.width.equalToSuperview() + } + } + + func setupSpacerView() { + let spacerView = UIView() + let stickySpacerView = UIView() + spacerView.setContentHuggingPriority(.defaultLow, for: .horizontal) + stickySpacerView.setContentHuggingPriority(.defaultLow, for: .horizontal) + tabBarStackView.addArrangedSubview(spacerView) + tabBarStickyStackView.addArrangedSubview(stickySpacerView) + } + + func setBookmark(isBookmarked: Bool) { + bookmarkButton.isSelected = isBookmarked + bookmarkButton.setImage(DesignSystemAsset.image(named: isBookmarked ? "bookmark" : "bookmarkGrayBorder"), for: .normal) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift new file mode 100644 index 00000000..b7c64bae --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift @@ -0,0 +1,429 @@ +import UIKit + +import MLSAuthFeatureInterface +//import MLSBookmarkFeatureInterface 추가 예정 +import MLSCore +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import RxCocoa +import RxSwift + +class DictionaryDetailBaseViewController: BaseViewController { + // MARK: - Properties + public var disposeBag = DisposeBag() + + private var didSelectInitialTab = false + var selectedIndex = 0 + var bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>? + var loginRelay: PublishRelay? + + /// 각 탭에 해당하는 콘텐츠 뷰들을 담는 배열 + public var contentViews: [UIView] = [] { + didSet { + let index = currentTabIndex ?? 0 + guard index < contentViews.count else { return } + mainView.setTabView(index: index, contentViews: contentViews) + } + } + + /// 현재 보여지고 있는 뷰의 인덱스 + private var currentTabIndex: Int? + + let bookmarkModalFactory: BookmarkModalFactory + let loginFactory: LoginFactory + public let dictionaryDetailFactory: DictionaryDetailFactory + private let detailOnBoardingFactory: DetailOnBoardingFactory + private let appCoordinator: AppCoordinatorProtocol + + private let fetchVisitDictionaryDetailUseCase: FetchVisitDictionaryDetailUseCase + + // MARK: - Components + public var mainView = DictionaryDetailBaseView() + + // 타입설정 + public var type: DictionaryItemType + + public init( + type: DictionaryItemType, + bookmarkModalFactory: BookmarkModalFactory, + loginFactory: LoginFactory, + dictionaryDetailFactory: DictionaryDetailFactory, + detailOnBoardingFactory: DetailOnBoardingFactory, + appCoordinator: AppCoordinatorProtocol, + fetchVisitDictionaryDetailUseCase: FetchVisitDictionaryDetailUseCase, + bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, + loginRelay: PublishRelay? + ) { + self.type = type + self.bookmarkModalFactory = bookmarkModalFactory + self.loginFactory = loginFactory + self.appCoordinator = appCoordinator + self.dictionaryDetailFactory = dictionaryDetailFactory + self.detailOnBoardingFactory = detailOnBoardingFactory + self.fetchVisitDictionaryDetailUseCase = fetchVisitDictionaryDetailUseCase + mainView.titleLabel.attributedText = .makeStyledString(font: .sub_m_b, text: type.detailTitle) + self.bookmarkRelay = bookmarkRelay + self.loginRelay = loginRelay + super.init() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + bind() // 액션 바인딩 + setupMenu(type.detailTypes) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + // 처음 진입 및 뷰가 추가 되었는지 확인 + if !didSelectInitialTab { + didSelectMenuTab(index: 0) + didSelectInitialTab = true + } + } + + open func toggleBookmark() { + assertionFailure("Subclass should override toggleBookmark()") + } + + open func checkLogin() -> Bool? { + return nil + } + + open func undoBookmark() { + assertionFailure("Subclass should override undoBookmark()") + } +} + +// MARK: - SetUp +private extension DictionaryDetailBaseViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.horizontalEdges.bottom.equalToSuperview() + } + } + + func configureUI() { + mainView.scrollView.delegate = self + checkVisited() + } +} + +/// 스티키 헤더 만들기 위한 델리게이트 +extension DictionaryDetailBaseViewController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + // 탭바의 frame을 self.view 기준으로 변환 + let tabBarY = mainView.tabBarStackView.convert(mainView.tabBarStackView.bounds, to: view) + + if tabBarY.origin.y <= view.safeAreaInsets.top + DictionaryDetailBaseView.Constant.buttonSize + DictionaryDetailBaseView.Constant.horizontalInset - DictionaryDetailBaseView.Constant.tabBarStackViewInset.top { + // safearea + 헤더뷰 + 헤더뷰와의 간격 16 = 122 + mainView.tabBarStickyStackView.isHidden = false + mainView.stickyTabBarDividerView.isHidden = false + } else { + mainView.tabBarStickyStackView.isHidden = true + mainView.stickyTabBarDividerView.isHidden = true + } + + let nameY = mainView.nameLabel.convert(mainView.nameLabel.bounds, to: view) + + if nameY.origin.y <= view.safeAreaInsets.top + DictionaryDetailBaseView.Constant.buttonSize + DictionaryDetailBaseView.Constant.horizontalInset { + // 메랜에서 이름이 가장 긴 몬스터의 경우 '다크 주니어 예티와 페페'로 알고 있는데, 따로 텍스트 길이에 대한 제약사항을 안줘도 다크 주니어 예티와 페페가 잘 표시가 됨. 제약사항 필요한가? + mainView.titleLabel.attributedText = .makeStyledString(font: .sub_m_b, text: mainView.nameLabel.text) + } else { + mainView.titleLabel.attributedText = .makeStyledString(font: .sub_m_b, text: type.detailTitle) + } + } +} + +extension DictionaryDetailBaseViewController { + /// image: 메인 이미지 + /// backgroundColor: dictionaryItemType에 따른 배경 색 + /// name: 해당 dict의 이름 + /// subText: level, 지역 등 다양한 서브 텍스트 + struct Input { + let imageUrl: String? + let backgroundColor: UIColor + let name: String + let subText: String? // 없는 경우도 있는듯 + } + + func inject(input: Input) { + // Load image if URL exists + if let imageUrlString = input.imageUrl { + ImageLoader.shared.loadImage(stringURL: imageUrlString) { [weak self] image in + guard let self = self, let image = image else { return } + self.mainView.imageView.image = image + } + } else { + mainView.imageView.image = nil + } + mainView.imageContentView.backgroundColor = input.backgroundColor + mainView.nameLabel.attributedText = .makeStyledString(font: .sub_l_m, text: input.name, color: .textColor) + mainView.subTextLabel.attributedText = .makeStyledString(font: .b_s_r, text: input.subText, color: .neutral500) + } + + func makeTagsRow(_ tags: Effectiveness) { + mainView.tagsVerticalStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + let maxWidth = UIScreen.main.bounds.width - DictionaryDetailBaseView.Constant.horizontalInset // 좌우 여백 고려 (16 * 2) + let tagSpacing: CGFloat = DictionaryDetailBaseView.Constant.tagVerticalSpacing + + var tagsRowStackView = mainView.createHorizontalStackView() + mainView.tagsVerticalStackView.addArrangedSubview(tagsRowStackView) + + var currentRowWidth: CGFloat = 0 + for (element, value) in tags.nonNilElements() { + let badge = Badge(style: .element("\(element.rawValue) \(value)")) + let fittingSize = badge.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + let badgeWidth = fittingSize.width + + if currentRowWidth + badgeWidth + tagSpacing > maxWidth { + tagsRowStackView = mainView.createHorizontalStackView() + mainView.tagsVerticalStackView.addArrangedSubview(tagsRowStackView) + currentRowWidth = 0 + } + + tagsRowStackView.addArrangedSubview(badge) + currentRowWidth += badgeWidth + tagSpacing + mainView.setBadgeConstraints(badge, width: badgeWidth) + } + } + + // 탭바 메뉴 버튼 구성하기(스티키 쪽도 똑같이 구현) + func setupMenu(_ menus: [DetailType]) { + var firstIndexButton: UIButton? // 처음 화면 나올때 첫번째 버튼 클릭되게 하기 위해 첫번째 버튼 저장할 변수 + var firstStickyIndexButton: UIButton? + // 버튼 configuration 설정 -> inset 설정 + for (index, menu) in menus.enumerated() { + let button = mainView.createMenuButton(title: menu.description, tag: index) + button.rx.tap.bind { [weak self] _ in + self?.menuTabTapped(button) + } + .disposed(by: disposeBag) + + let stickyButton = mainView.createMenuButton(title: menu.description, tag: index) + stickyButton.rx.tap.bind { [weak self] _ in + self?.menuTabTapped(stickyButton) + } + .disposed(by: disposeBag) + + mainView.tabBarStackView.addArrangedSubview(button) + mainView.tabBarStickyStackView.addArrangedSubview(stickyButton) // 스티키 역할을 할 스택뷰에다가도 똑같은 버튼 추가 + + if index == 0 { + firstIndexButton = button + firstStickyIndexButton = button + } + } + mainView.setupSpacerView() + // 화면에 버튼 다 생성 한 이후에 첫번째 버튼 클릭 이벤트 유발 + if let firstIndexButton = firstIndexButton, let firstStickyIndexButton = firstStickyIndexButton { + menuTabTapped(firstIndexButton) + menuTabTapped(firstStickyIndexButton) + } + } + + private func menuTabTapped(_ sender: UIButton) { + let selectedTag = sender.tag + + updateButtonStates(in: mainView.tabBarStackView, selectedTag: selectedTag) + // 스티키 탭 버튼 상태 변경 + updateButtonStates(in: mainView.tabBarStickyStackView, selectedTag: selectedTag) + + // 자식 디테일 뷰컨에서 오버라이드 해서 사용하기? + didSelectMenuTab(index: sender.tag) + } + + // 버튼 상태 변경 함수 + 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 == DictionaryDetailBaseView.Constant.underTag } + + 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 + } + } + } + + func didSelectMenuTab(index: Int) { + // 인덱스 유효성 검사 + guard index < contentViews.count else { return } + + // 현재 뷰가 같다면 변경 안함 + if currentTabIndex == index { return } + // 각 탭에 맞는 뷰 설정 + mainView.setTabView(index: index, contentViews: contentViews) + currentTabIndex = index + } + + func checkVisited() { + fetchVisitDictionaryDetailUseCase.execute() + .withUnretained(self) + .subscribe { owner, isVisit in + if !isVisit { + let viewController = owner.detailOnBoardingFactory.make() + viewController.modalPresentationStyle = .overFullScreen + viewController.modalTransitionStyle = .crossDissolve + owner.present(viewController, animated: true) + } + } + .disposed(by: disposeBag) + } +} + +private extension DictionaryDetailBaseViewController { + func bind() { + // 뒤로가기 버튼 액션 바인드 + bindBackButton() + } + + func bindBackButton() { + mainView.backButton.rx.tap + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind { owner, _ in + owner.navigationController?.popViewController(animated: true) + } + .disposed(by: disposeBag) + + mainView.dictButton.rx.tap + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind { owner, _ in + owner.appCoordinator.showMainTab() + } + .disposed(by: disposeBag) + + mainView.bookmarkButton.rx.tap + .withUnretained(self) + .subscribe(onNext: { owner, _ in + owner.handleBookmarkTapped() + }) + .disposed(by: disposeBag) + } +} + +extension DictionaryDetailBaseViewController { + public func bindReportButton(providerId: Observable, itemName: Observable) { + mainView.reportButton.rx.tap + .withLatestFrom(Observable.combineLatest(providerId, itemName)) + .bind { [weak self] id, name in + guard let self = self else { return } + let urlString = "https://forms.fillout.com/t/1JR9wiyv1Uus?providerId=\(id)&item=\(name)" + GuideAlertFactory.show( + mainText: "잘못된 정보를 발견하셨나요?\n운영진에게 알려주세요!", + ctaText: "건의하기", + cancelText: "취소", + ctaAction: { [weak self] in + guard self != nil else { return } + if let url = URL(string: urlString) { + UIApplication.shared.open(url) + } + }, + cancelAction: { GuideAlertFactory.dismiss() } + ) + } + .disposed(by: disposeBag) + } +} + +extension DictionaryDetailBaseViewController { + func handleBookmarkTapped() { + guard let isLogin = checkLogin() else { return } + + if !isLogin { + presentLoginGuide() + loginRelay?.accept(()) + return + } + + toggleBookmark() + } +} + +extension DictionaryDetailBaseViewController { + func presentAddSnackBar(bookmarkId: Int?, imageUrl: String?, background: UIColor) { + guard let bookmarkId else { return } + ImageLoader.shared.loadImage(stringURL: imageUrl) { image in + guard let image = image else { return } + SnackBarFactory.createSnackBar( + type: .normal, + image: image, + imageBackgroundColor: background, + text: "아이템을 북마크에 추가했어요.", + buttonText: "컬렉션 추가", + buttonAction: { [weak self] in + self?.presentCollectionModal(bookmarkId: bookmarkId) + } + ) + } + } + + func presentDeleteSnackBar(imageUrl: String?, background: UIColor) { + ImageLoader.shared.loadImage(stringURL: imageUrl) { image in + guard let image = image else { return } + SnackBarFactory.createSnackBar( + type: .delete, + image: image, + imageBackgroundColor: background, + text: "아이템을 북마크에서 삭제했어요.", + buttonText: "되돌리기", + buttonAction: { [weak self] in + self?.undoBookmark() + } + ) + } + } + + private func presentCollectionModal(bookmarkId: Int) { + let viewController = bookmarkModalFactory.make(bookmarkIds: [bookmarkId]) { isAdd in + if isAdd { + ToastFactory.createToast(message: "컬렉션에 추가되었어요. 북마크 탭에서 확인 할 수 있어요.") + } + } + viewController.modalPresentationStyle = .pageSheet + if let sheet = viewController.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 16 + } + self.present(viewController, animated: true) + } + + func presentLoginGuide() { + GuideAlertFactory.show( + mainText: "북마크를 하려면 로그인이 필요해요.", + ctaText: "로그인 하기", + cancelText: "취소", + ctaAction: { [weak self] in + guard let self else { return } + let vc = self.loginFactory.make(exitRoute: .pop) + self.navigationController?.pushViewController(vc, animated: true) + }, + cancelAction: nil + ) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailFactoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailFactoryImpl.swift new file mode 100644 index 00000000..4d17c049 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailFactoryImpl.swift @@ -0,0 +1,153 @@ +import MLSAuthFeatureInterface +import MLSCore +import MLSDictionaryFeatureInterface + +import RxCocoa + +public final class DictionaryDetailFactoryImpl: DictionaryDetailFactory { + private let loginFactory: () -> LoginFactory + private let bookmarkModalFactory: BookmarkModalFactory + private let dictionaryDetailFactory: () -> DictionaryDetailFactory + private let detailOnBoardingFactory: DetailOnBoardingFactory + private let appCoordinator: () -> AppCoordinatorProtocol + private let dictionaryDetailAPIRepository: DictionaryDetailAPIRepository + private let checkLoginUseCase: CheckLoginUseCase + private let setBookmarkUseCase: SetBookmarkUseCase + private let fetchVisitDictionaryDetailUseCase: FetchVisitDictionaryDetailUseCase + + public init( + loginFactory: @escaping () -> LoginFactory, + bookmarkModalFactory: BookmarkModalFactory, + dictionaryDetailFactory: @escaping () -> DictionaryDetailFactory, + detailOnBoardingFactory: DetailOnBoardingFactory, + appCoordinator: @escaping () -> AppCoordinatorProtocol, + dictionaryDetailAPIRepository: DictionaryDetailAPIRepository, + checkLoginUseCase: CheckLoginUseCase, + setBookmarkUseCase: SetBookmarkUseCase, + fetchVisitDictionaryDetailUseCase: FetchVisitDictionaryDetailUseCase + ) { + self.loginFactory = loginFactory + self.bookmarkModalFactory = bookmarkModalFactory + self.detailOnBoardingFactory = detailOnBoardingFactory + self.dictionaryDetailAPIRepository = dictionaryDetailAPIRepository + self.checkLoginUseCase = checkLoginUseCase + self.setBookmarkUseCase = setBookmarkUseCase + self.appCoordinator = appCoordinator + self.dictionaryDetailFactory = dictionaryDetailFactory + self.fetchVisitDictionaryDetailUseCase = fetchVisitDictionaryDetailUseCase + } + + public func make(type: DictionaryType, id: Int, bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, loginRelay: PublishRelay?) -> BaseViewController { + var viewController = BaseViewController() + switch type { + case .total: + break + case .collection: + break + case .item: + viewController = ItemDictionaryDetailViewController( + type: .item, + bookmarkModalFactory: bookmarkModalFactory, + loginFactory: loginFactory(), + dictionaryDetailFactory: dictionaryDetailFactory(), + detailOnBoardingFactory: detailOnBoardingFactory, + appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay + ) + let reactor = ItemDictionaryDetailReactor( + dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, + checkLoginUseCase: checkLoginUseCase, + setBookmarkUseCase: setBookmarkUseCase, + id: id + ) + if let viewController = viewController as? ItemDictionaryDetailViewController { + viewController.reactor = reactor + } + case .monster: + viewController = MonsterDictionaryDetailViewController( + type: .monster, + bookmarkModalFactory: bookmarkModalFactory, + loginFactory: loginFactory(), + dictionaryDetailFactory: dictionaryDetailFactory(), + detailOnBoardingFactory: detailOnBoardingFactory, + appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay + ) + let reactor = MonsterDictionaryDetailReactor( + dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, + checkLoginUseCase: checkLoginUseCase, + setBookmarkUseCase: setBookmarkUseCase, + id: id + ) + if let viewController = viewController as? MonsterDictionaryDetailViewController { + viewController.reactor = reactor + } + case .map: + viewController = MapDictionaryDetailViewController( + type: .map, + bookmarkModalFactory: bookmarkModalFactory, + loginFactory: loginFactory(), + dictionaryDetailFactory: dictionaryDetailFactory(), + detailOnBoardingFactory: detailOnBoardingFactory, + appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay + ) + let reactor = MapDictionaryDetailReactor( + dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, + checkLoginUseCase: checkLoginUseCase, + setBookmarkUseCase: setBookmarkUseCase, + id: id + ) + if let viewController = viewController as? MapDictionaryDetailViewController { + viewController.reactor = reactor + } + case .npc: + viewController = NpcDictionaryDetailViewController( + type: .npc, + bookmarkModalFactory: bookmarkModalFactory, + loginFactory: loginFactory(), + dictionaryDetailFactory: dictionaryDetailFactory(), + detailOnBoardingFactory: detailOnBoardingFactory, + appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay + ) + let reactor = NpcDictionaryDetailReactor( + dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, + checkLoginUseCase: checkLoginUseCase, + setBookmarkUseCase: setBookmarkUseCase, + id: id + ) + if let viewController = viewController as? NpcDictionaryDetailViewController { + viewController.reactor = reactor + } + case .quest: + viewController = QuestDictionaryDetailViewController( + type: .quest, + bookmarkModalFactory: bookmarkModalFactory, + loginFactory: loginFactory(), + dictionaryDetailFactory: dictionaryDetailFactory(), + detailOnBoardingFactory: detailOnBoardingFactory, + appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay + ) + let reactor = QuestDictionaryDetailReactor( + dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, + checkLoginUseCase: checkLoginUseCase, + setBookmarkUseCase: setBookmarkUseCase, + id: id + ) + if let viewController = viewController as? QuestDictionaryDetailViewController { + viewController.reactor = reactor + } + } + + // 하단 탭바 히든 + viewController.isBottomTabbarHidden = true + return viewController + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailReactor.swift new file mode 100644 index 00000000..713edb3d --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailReactor.swift @@ -0,0 +1,176 @@ +import MLSDictionaryFeatureInterface + +import ReactorKit + +// MARK: - Reactor +public final class ItemDictionaryDetailReactor: Reactor { + // MARK: Type + public enum Route { + case none + case filter(DictionaryType) + case detail(Int) + case bookmarkError + } + + public enum UIEvent { + case none + case add(DictionaryDetailItemResponse) + case delete(DictionaryDetailItemResponse) + case undo + } + + // MARK: Action + public enum Action { + case filterButtonTapped + case viewWillAppear + case selectFilter(SortType) + case toggleBookmark + case undoLastDeletedBookmark + case dataTapped(index: Int) + } + + // MARK: Mutation + public enum Mutation { + case navigatTo(Route) + case setDetailData(DictionaryDetailItemResponse) + case setDetailDropMonsterData([DictionaryDetailItemDropMonsterResponse]) + case setLoginState(Bool) + case setEvent(UIEvent) + } + + // MARK: State + public struct State { + @Pulse var event: UIEvent = .none + @Pulse var route: Route = .none + var itemDetailInfo: DictionaryDetailItemResponse + var type: DictionaryType = .item + var monsters: [DictionaryDetailItemDropMonsterResponse] + var id: Int + var isLogin = false + } + + public var initialState: State + private let disposeBag = DisposeBag() + + private let dictionaryDetailAPIRepository: DictionaryDetailAPIRepository + private let checkLoginUseCase: CheckLoginUseCase + private let setBookmarkUseCase: SetBookmarkUseCase + + // MARK: Init + public init( + dictionaryDetailAPIRepository: DictionaryDetailAPIRepository, + checkLoginUseCase: CheckLoginUseCase, + setBookmarkUseCase: SetBookmarkUseCase, + id: Int + ) { + self.dictionaryDetailAPIRepository = dictionaryDetailAPIRepository + self.checkLoginUseCase = checkLoginUseCase + self.setBookmarkUseCase = setBookmarkUseCase + self.initialState = .init( + itemDetailInfo: DictionaryDetailItemResponse( + itemId: 0, nameKr: nil, nameEn: nil, descriptionText: nil, + imgUrl: nil, npcPrice: nil, itemType: nil, categoryHierachy: nil, + availableJobs: nil, requiredStats: nil, equipmentStats: nil, + scrollDetail: nil, bookmarkId: nil + ), + type: .item, + monsters: [], + id: id + ) + } + + // MARK: Mutate + public func mutate(action: Action) -> Observable { + switch action { + case .filterButtonTapped: + return .just(.navigatTo(.filter(currentState.type))) + case .viewWillAppear: + return .merge([ + checkLoginUseCase.execute().map { .setLoginState($0) }, + dictionaryDetailAPIRepository.fetchItemDetail(id: currentState.id).map { .setDetailData($0) }, + dictionaryDetailAPIRepository.fetchItemDetailDropMonster(id: currentState.id, sort: nil).map { .setDetailDropMonsterData($0) } + ]) + case let .selectFilter(type): + return dictionaryDetailAPIRepository.fetchItemDetailDropMonster(id: currentState.id, sort: type.sortParameter).map { .setDetailDropMonsterData($0) } + + case .toggleBookmark: + return handleToggleBookmark() + + case .undoLastDeletedBookmark: + return handleUndoLastDeletedBookmark() + + case .dataTapped(let index): + guard let id = currentState.monsters[index].monsterId else { return .empty() } + return .just(.navigatTo(.detail(id))) + } + } + + // MARK: Reduce + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case let .setDetailData(data): + newState.itemDetailInfo = data + case let .setDetailDropMonsterData(data): + newState.monsters = data + case let .setLoginState(isLogin): + newState.isLogin = isLogin + case .navigatTo(let route): + newState.route = route + case let .setEvent(event): + newState.event = event + } + return newState + } +} + +private extension ItemDictionaryDetailReactor { + func handleToggleBookmark() -> Observable { + var item = currentState.itemDetailInfo + let isSelected = item.bookmarkId != nil + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: isSelected ? item.bookmarkId ?? item.itemId : item.itemId, + isBookmark: isSelected ? .delete : .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + item.bookmarkId = newBookmarkId + let event: UIEvent = isSelected ? .delete(item) : .add(item) + let eventMutation = Observable.just(Mutation.setEvent(event)) + + let refresh = self.dictionaryDetailAPIRepository.fetchItemDetail(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } + + func handleUndoLastDeletedBookmark() -> Observable { + var item = currentState.itemDetailInfo + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: item.itemId, + isBookmark: .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + item.bookmarkId = newBookmarkId + let eventMutation = Observable.just(Mutation.setEvent(.add(item))) + let refresh = self.dictionaryDetailAPIRepository.fetchItemDetail(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift new file mode 100644 index 00000000..863171ee --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift @@ -0,0 +1,276 @@ +import UIKit + +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import ReactorKit + +final class ItemDictionaryDetailViewController: DictionaryDetailBaseViewController, View { + public typealias Reactor = ItemDictionaryDetailReactor + + // MARK: - Components + private let detailInfoView = DetailStackInfoView(type: .item) + private let monsterCardView = DetailStackCardView() + private let sortedFactory: SortedBottomSheetFactory = SortedBottomSheetFactoryImpl() + + override func toggleBookmark() { + reactor?.action.onNext(.toggleBookmark) + } + + override func checkLogin() -> Bool { + guard let reactor = reactor else { return false } + return reactor.currentState.isLogin + } + + override func undoBookmark() { + reactor?.action.onNext(.undoLastDeletedBookmark) + } +} + +// MARK: - Populate Data +private extension ItemDictionaryDetailViewController { + func setupMainInfo() { + // 상세 정보(메인?) + inject(input: DictionaryDetailBaseViewController.Input( + imageUrl: reactor?.currentState.itemDetailInfo.imgUrl, + backgroundColor: type.backgroundColor, + name: reactor?.currentState.itemDetailInfo.nameKr ?? "이름 없음", + subText: reactor?.currentState.itemDetailInfo.requiredStats?.level.map { "Lv. \($0)" } ?? "" // 착용 레벨이 존재하는 아이템일 경우 레벨 보여주기 + )) + } + + func setUpInfoStackView() { + guard let reactor = reactor else { return } + let infos = reactor.currentState.itemDetailInfo + + contentViews.append(detailInfoView) + // descriptionText + detailInfoView.descriptionLabel.text = infos.descriptionText ?? "" + + detailInfoView.reset() + if let npcPrice = infos.npcPrice { + detailInfoView.addInfo(mainText: "상점판매가", subText: "\(npcPrice.formatted()) 메소") + } + + if let availableJobs = infos.availableJobs { + let jobNames = availableJobs.compactMap { $0.jobName }.joined(separator: ", ") + if !jobNames.isEmpty { + detailInfoView.addInfo(mainText: "직업", subText: jobNames) + } + } + + if let requiredStats = infos.requiredStats { + if let level = requiredStats.level { + detailInfoView.addInfo(mainText: "착용레벨", subText: "Lv. \(level)") + } + if let str = requiredStats.str { + detailInfoView.addInfo(mainText: "필요 STR", subText: "\(str)") + } + if let dex = requiredStats.dex { + detailInfoView.addInfo(mainText: "필요 DEX", subText: "\(dex)") + } + if let int = requiredStats.intelligence { + detailInfoView.addInfo(mainText: "필요 INT", subText: "\(int)") + } + if let luk = requiredStats.luk { + detailInfoView.addInfo(mainText: "필요 LUK", subText: "\(luk)") + } + if let pop = requiredStats.pop { + detailInfoView.addInfo(mainText: "필요 POP", subText: "\(pop)") + } + } + // 해당 스트링들을 상수 추출 하는게 좋을지.. + if let equipmentStats = infos.equipmentStats { + let statMappings: [(title: String, stat: Stats?)] = [ + ("STR 증가", equipmentStats.str), + ("DEX 증가", equipmentStats.dex), + ("INT 증가", equipmentStats.intelligence), + ("LUK 증가", equipmentStats.luk), + ("HP 증가", equipmentStats.hp), + ("MP 증가", equipmentStats.mp), + ("물리공격력 증가", equipmentStats.weaponAttack), + ("마법공격력 증가", equipmentStats.magicAttack), + ("물리방어력 증가", equipmentStats.physicalDefense), + ("마법방어력 증가", equipmentStats.magicDefense), + ("명중률 증가", equipmentStats.accuracy), + ("회피율 증가", equipmentStats.evasion), + ("이동속도 증가", equipmentStats.speed), + ("점프력 증가", equipmentStats.jump) + ] + + for (title, stat) in statMappings { + if let base = stat?.base { + let subText = formatStatText(base: base, min: stat?.min, max: stat?.max) + detailInfoView.addInfo(mainText: title, subText: subText) + } + } + + if let attackSpeed = equipmentStats.attackSpeed, let attackSpeedDetails = equipmentStats.attackSpeedDetails { + detailInfoView.addInfo(mainText: "공격속도", subText: "\(attackSpeed.formatted()) (\(attackSpeedDetails))") + } + } + + if let scrollDetail = infos.scrollDetail { + let scrollMappings: [(title: String, value: Int?)] = [ + ("STR 증가", scrollDetail.strChange), + ("DEX 증가", scrollDetail.dexChange), + ("INT 증가", scrollDetail.intelligenceChange), + ("LUK 증가", scrollDetail.lukChange), + ("HP 증가", scrollDetail.hpChange), + ("MP 증가", scrollDetail.mpChange), + ("물리공격력 증가", scrollDetail.weaponAttackChange), + ("마법공격력 증가", scrollDetail.magicAttackChange), + ("물리방어력 증가", scrollDetail.physicalDefenseChange), + ("마법방어력 증가", scrollDetail.magicDefenseChange), + ("명중률 증가", scrollDetail.accuracyChange), + ("회피율 증가", scrollDetail.evasionChange), + ("이동속도 증가", scrollDetail.speedChange), + ("점프력 증가", scrollDetail.jumpChange) + ] + + if let successRate = scrollDetail.successRatePercent { + detailInfoView.addInfo(mainText: "성공 확률", subText: "\(successRate)%") + } + + if let targetItem = scrollDetail.targetItemTypeText { + detailInfoView.addInfo(mainText: "사용 가능 장비", subText: targetItem) + } + + for (title, value) in scrollMappings { + if let value = value { + let sign = value >= 0 ? "+" : "" + detailInfoView.addInfo(mainText: title, subText: "\(sign)\(value.formatted())") + } + } + } + } + + func setUpMonsterView() { + guard let reactor = reactor, + let detailType = reactor.currentState.type.detailTypes.first, + let filter = detailType.sortFilter.first else { return } + monsterCardView.initFilter(firstFilter: filter) + let monsters = reactor.currentState.monsters + monsterCardView.reset() + + contentViews.append(monsterCardView) + if monsters.isEmpty { + contentViews[1] = DetailEmptyView(type: .dropMonsterWithText) + } else { + contentViews[1] = monsterCardView + for monster in monsters { + monsterCardView.inject(input: DetailStackCardView.Input(type: .dropMonsterWithText, imageUrl: monster.imageUrl ?? "", mainText: monster.monsterName, additionalText: "\(monster.dropRate ?? 0)%") + ) + } + } + } +} + +// MARK: - Bind +extension ItemDictionaryDetailViewController { + public func bind(reactor: Reactor) { + bindUserAction(reactor: reactor) + bindViewState(reactor: reactor) + bindReportButton( + providerId: reactor.state.map { $0.itemDetailInfo.itemId }, + itemName: reactor.state.map { $0.itemDetailInfo.nameKr ?? "" } + ) + } + + private func bindUserAction(reactor: Reactor) { + rx.viewWillAppear + .map { Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + monsterCardView.filterButton.rx.tap + .map { Reactor.Action.filterButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + monsterCardView.tap + .map { Reactor.Action.dataTapped(index: $0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + private func bindViewState(reactor: Reactor) { + reactor.state.map(\.itemDetailInfo) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, item in + owner.setupMainInfo() + owner.setUpInfoStackView() + owner.mainView.setBookmark(isBookmarked: item.bookmarkId != nil) + }) + .disposed(by: disposeBag) + + reactor.state.map(\.monsters) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, _ in + owner.setUpMonsterView() + }) + .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 let .filter(type): + guard let option = type.detailTypes.first else { return } + let viewController = owner.sortedFactory.make(sortedOptions: option.sortFilter, selectedIndex: owner.selectedIndex) { index in + owner.selectedIndex = index + let selectedFilter = option.sortFilter[index] + owner.monsterCardView.selectFilter(selectedType: selectedFilter) + reactor.action.onNext(.selectFilter(selectedFilter)) + } + owner.tabBarController?.presentModal(viewController, hideTabBar: true) + case let .detail(id): + let viewController = owner.dictionaryDetailFactory.make(type: .monster, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) + owner.navigationController?.pushViewController(viewController, animated: true) + case .bookmarkError: + ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") + default: + break + } + } + .disposed(by: disposeBag) + + reactor.pulse(\.$event) + .bind(onNext: { [weak self] event in + switch event { + case let .add(item): + self?.presentAddSnackBar( + bookmarkId: item.bookmarkId, + imageUrl: item.imgUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.itemId, item.bookmarkId)) + case let .delete(item): + self?.presentDeleteSnackBar( + imageUrl: item.imgUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.itemId, item.bookmarkId)) + default: break + } + }) + .disposed(by: disposeBag) + } +} + +private extension ItemDictionaryDetailViewController { + func formatStatText(base: Int, min: Int?, max: Int?) -> String { + if let min = min, let max = max { + return "\(base.formatted()) [\(min.formatted())-\(max.formatted())]" + } else { + return "\(base)" + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailReactor.swift new file mode 100644 index 00000000..c8b62245 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailReactor.swift @@ -0,0 +1,197 @@ +import MLSDictionaryFeatureInterface + +import ReactorKit + +public final class MapDictionaryDetailReactor: Reactor { + // MARK: - Reactor + public enum Route { + case none + case filter([SortType]) + case detail(type: DictionaryType, id: Int) + case bookmarkError + } + + public enum UIEvent { + case none + case add(DictionaryDetailMapResponse) + case delete(DictionaryDetailMapResponse) + case undo + } + + public enum Action { + case monsterFilterButtonTapped + case viewWillAppear + case toggleBookmark + case undoLastDeletedBookmark + case monsterTapped(index: Int) + case npcTapped(index: Int) + case selectFilter(SortType) + } + + public enum Mutation { + case navigatTo(Route) + case setDetailData(DictionaryDetailMapResponse) + case setDetailSpawnMonsters([DictionaryDetailMapSpawnMonsterResponse]) + case setDetailNpc([DictionaryDetailMapNpcResponse]) + case setBookmark(DictionaryDetailMapResponse) + case setLastDeletedBookmark(DictionaryDetailMapResponse?) + case setLoginState(Bool) + case setEvent(UIEvent) + } + + public let dictionaryDetailAPIRepository: DictionaryDetailAPIRepository + private let checkLoginUseCase: CheckLoginUseCase + private let setBookmarkUseCase: SetBookmarkUseCase + + public struct State { + @Pulse var event: UIEvent = .none + @Pulse var route: Route = .none + var mapDetailInfo: DictionaryDetailMapResponse + var spawnMonsters: [DictionaryDetailMapSpawnMonsterResponse] + var npcs: [DictionaryDetailMapNpcResponse] + var type: DictionaryType = .map + var monsterFilter: [SortType] { + type.detailTypes[0].sortFilter + } + var id = 0 + var isLogin = false + var lastDeletedBookmark: DictionaryDetailMapResponse? + } + + public var initialState: State + private let disposBag = DisposeBag() + + public init( + dictionaryDetailAPIRepository: DictionaryDetailAPIRepository, + checkLoginUseCase: CheckLoginUseCase, + setBookmarkUseCase: SetBookmarkUseCase, + id: Int + ) { + initialState = State( + mapDetailInfo: DictionaryDetailMapResponse( + mapId: 0, + nameKr: nil, + nameEn: nil, + regionName: nil, + detailName: nil, + topRegionName: nil, + mapUrl: nil, + iconUrl: nil, + bookmarkId: nil + ), + spawnMonsters: [], + npcs: [], + type: .map, + id: id + ) + + self.dictionaryDetailAPIRepository = dictionaryDetailAPIRepository + self.checkLoginUseCase = checkLoginUseCase + self.setBookmarkUseCase = setBookmarkUseCase + } + + public func mutate(action: Action) -> Observable { + switch action { + case .monsterFilterButtonTapped: + return Observable.just(.navigatTo(.filter(currentState.monsterFilter))) + case .viewWillAppear: + return .merge([ + checkLoginUseCase.execute().map { .setLoginState($0) }, + dictionaryDetailAPIRepository.fetchMapDetail(id: currentState.id).map {.setDetailData($0)}, + dictionaryDetailAPIRepository.fetchMapDetailSpawnMonster(id: currentState.id, sort: nil).map {.setDetailSpawnMonsters($0)}, + dictionaryDetailAPIRepository.fetchMapDetailNpc(id: currentState.id).map {.setDetailNpc($0)} + ]) + case .toggleBookmark: + return handleToggleBookmark() + + case .undoLastDeletedBookmark: + return handleUndoLastDeletedBookmark() + + case let .selectFilter(type): + return dictionaryDetailAPIRepository.fetchMapDetailSpawnMonster(id: currentState.id, sort: type.sortParameter).map { .setDetailSpawnMonsters($0) } + + case .monsterTapped(index: let index): + guard let id = currentState.spawnMonsters[index].monsterId else { return .empty() } + + return .just(.navigatTo(.detail(type: .monster, id: id))) + case .npcTapped(index: let index): + guard let id = currentState.npcs[index].npcId else { return .empty() } + return .just(.navigatTo(.detail(type: .npc, id: id))) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .navigatTo(let route): + newState.route = route + case let .setDetailData(data): + newState.mapDetailInfo = data + case let .setDetailSpawnMonsters(data): + newState.spawnMonsters = data + case let .setDetailNpc(data): + newState.npcs = data + case let .setBookmark(map): + newState.mapDetailInfo = map + case let .setLastDeletedBookmark(map): + newState.lastDeletedBookmark = map + case let .setLoginState(isLogin): + newState.isLogin = isLogin + case let .setEvent(event): + newState.event = event + } + return newState + } +} + +private extension MapDictionaryDetailReactor { + func handleToggleBookmark() -> Observable { + var map = currentState.mapDetailInfo + let isSelected = map.bookmarkId != nil + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: isSelected ? map.bookmarkId ?? map.mapId : map.mapId, + isBookmark: isSelected ? .delete : .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + map.bookmarkId = newBookmarkId + let event: UIEvent = isSelected ? .delete(map) : .add(map) + let eventMutation = Observable.just(Mutation.setEvent(event)) + + let refresh = self.dictionaryDetailAPIRepository.fetchMapDetail(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } + + func handleUndoLastDeletedBookmark() -> Observable { + var map = currentState.mapDetailInfo + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: map.mapId, + isBookmark: .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + map.bookmarkId = newBookmarkId + let eventMutation = Observable.just(Mutation.setEvent(.add(map))) + let refresh = self.dictionaryDetailAPIRepository.fetchMapDetail(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailViewController.swift new file mode 100644 index 00000000..99dc0513 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailViewController.swift @@ -0,0 +1,232 @@ +import UIKit + +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift + +final class MapDictionaryDetailViewController: DictionaryDetailBaseViewController, View { + public typealias Reactor = MapDictionaryDetailReactor + + // MARK: - Componenets + private var mapInfoView = DetailStackMapView(imageUrl: "") + private var appearMonsterView = DetailStackCardView() + private var appearNpcView = DetailStackCardView() + private let sortedFactory: SortedBottomSheetFactory = SortedBottomSheetFactoryImpl() + + override func viewDidLoad() { + super.viewDidLoad() + bindImageView() + } + + override func toggleBookmark() { + reactor?.action.onNext(.toggleBookmark) + } + + override func checkLogin() -> Bool { + guard let reactor = reactor else { return false } + return reactor.currentState.isLogin + } + + override func undoBookmark() { + reactor?.action.onNext(.undoLastDeletedBookmark) + } +} + +// MARK: - SetUp +private extension MapDictionaryDetailViewController { + func setUpMainInfo() { + guard let reactor = reactor else { return } + let info = reactor.currentState.mapDetailInfo + inject( + input: DictionaryDetailBaseViewController + .Input( + imageUrl: info.iconUrl, + backgroundColor: type.backgroundColor, + name: info.nameKr ?? "이름 없음", + subText: info.detailName ?? "" + ) + ) + } + + func setUpMapView() { + guard let reactor = reactor else { return } + + contentViews.append(mapInfoView) + if let mapUrl = reactor.currentState.mapDetailInfo.mapUrl, !mapUrl.isEmpty { + mapInfoView.setUpMapView(imageUrl: reactor.currentState.mapDetailInfo.mapUrl) + contentViews[0] = mapInfoView + } else { + contentViews[0] = DetailEmptyView(type: .mapInfo) + } + } + + func setUpMonsterView() { + guard let reactor = reactor, + let filter = reactor.currentState.monsterFilter.first else { return } + appearMonsterView.initFilter(firstFilter: filter) + + appearMonsterView.reset() + let monsters = reactor.currentState.spawnMonsters + contentViews.append(appearMonsterView) + if monsters.isEmpty { + contentViews[1] = DetailEmptyView(type: .appearMonsterWithText) + } else { + contentViews[1] = appearMonsterView + for monster in monsters { + appearMonsterView.inject(input: DetailStackCardView.Input(type: .appearMonsterWithText, imageUrl: monster.imageUrl ?? "", mainText: monster.monsterName, subText: "Lv.\(monster.level ?? 0)", additionalText: { + if let count = monster.maxSpawnCount { + return "\(count)마리" + } else { + return "??마리" + } + }())) + } + } + } + + func setUpNpcView() { + guard let reactor = reactor else { return } + + let npcs = reactor.currentState.npcs + appearNpcView.reset() + contentViews.append(appearNpcView) + if npcs.isEmpty { + contentViews[2] = DetailEmptyView(type: .appearNPC) + } else { + contentViews[2] = appearNpcView + for npc in npcs { + appearNpcView.inject(input: DetailStackCardView.Input(type: .appearNPC, imageUrl: npc.iconUrl ?? "", mainText: npc.npcName)) + } + } + } + + func bindImageView() { + let tapGesture = UITapGestureRecognizer() + mapInfoView.mapImageView.isUserInteractionEnabled = true + mapInfoView.mapImageView.addGestureRecognizer(tapGesture) + + tapGesture.rx.event + .withUnretained(self) + .bind(onNext: { owner, _ in + guard let reactor = owner.reactor, + let url = reactor.currentState.mapDetailInfo.mapUrl else { return } + let viewController = PinchMapViewController(imageUrl: url) + viewController.modalPresentationStyle = .overFullScreen + owner.isBottomTabbarHidden = true + self.present(viewController, animated: true) + }) + .disposed(by: disposeBag) + } +} + +// MARK: - Bind +extension MapDictionaryDetailViewController { + func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + bindReportButton(providerId: reactor.state.map { $0.mapDetailInfo.mapId }, itemName: reactor.state.map { $0.mapDetailInfo.nameKr ?? "" }) + } + + private func bindUserActions(reactor: Reactor) { + rx.viewWillAppear + .map { Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + appearMonsterView.filterButton.rx.tap + .map { Reactor.Action.monsterFilterButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + appearMonsterView.tap + .map { Reactor.Action.monsterTapped(index: $0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + appearNpcView.tap + .map { Reactor.Action.npcTapped(index: $0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + private func bindViewState(reactor: Reactor) { + reactor.state.map(\.mapDetailInfo) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, item in + owner.setUpMainInfo() + owner.setUpMapView() + owner.mainView.setBookmark(isBookmarked: item.bookmarkId != nil) + }) + .disposed(by: disposeBag) + + reactor.state.map(\.spawnMonsters) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, _ in + owner.setUpMonsterView() + }) + .disposed(by: disposeBag) + + reactor.state.map(\.npcs) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, _ in + owner.setUpNpcView() + }) + .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 .filter(let sort): + let viewController = owner.sortedFactory.make(sortedOptions: sort, selectedIndex: owner.selectedIndex) { index in + owner.selectedIndex = index + let selectedFilter = reactor.currentState.monsterFilter[index] + owner.appearMonsterView.selectFilter(selectedType: selectedFilter) + reactor.action.onNext(.selectFilter(selectedFilter)) + } + owner.tabBarController?.presentModal(viewController, hideTabBar: true) + case .detail(let type, let id): + let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) + owner.navigationController?.pushViewController(viewController, animated: true) + case .bookmarkError: + ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") + default: + break + } + } + .disposed(by: disposeBag) + + reactor.pulse(\.$event) + .bind(onNext: { [weak self] event in + switch event { + case let .add(item): + self?.presentAddSnackBar( + bookmarkId: item.bookmarkId, + imageUrl: item.iconUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.mapId, item.bookmarkId)) + case let .delete(item): + self?.presentDeleteSnackBar( + imageUrl: item.iconUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.mapId, item.bookmarkId)) + default: break + } + }) + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift new file mode 100644 index 00000000..f73cc40f --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift @@ -0,0 +1,219 @@ +import MLSDictionaryFeatureInterface + +import ReactorKit + +public final class MonsterDictionaryDetailReactor: Reactor { + // MARK: - Type + public enum Route { + case none + case filter(type: DictionaryType, sort: [SortType]) + case detail(type: DictionaryType, id: Int) + case bookmarkError + } + + public struct Info: Equatable { + var name: String + var desc: String + } + + public enum UIEvent { + case none + case add(DictionaryDetailMonsterResponse) + case delete(DictionaryDetailMonsterResponse) + case undo + } + + // MARK: - Action + public enum Action { + case filterButtonTapped(DictionaryType) + case viewWillAppear + case selectFilter(SortType) + case toggleBookmark + case undoLastDeletedBookmark + case itemTapped(index: Int) + case mapTapped(index: Int) + } + + // MARK: - Mutation + public enum Mutation { + case navigatTo(Route) + case setDetailData(DictionaryDetailMonsterResponse) + case setDetailDropItemData([DictionaryDetailMonsterDropItemResponse]) + case setDetailMapData([DictionaryDetailMonsterMapResponse]) + case setBookmark(DictionaryDetailMonsterResponse) + case setLastDeletedBookmark(DictionaryDetailMonsterResponse?) + case setLoginState(Bool) + case setEvent(UIEvent) + } + + // MARK: - State + public struct State { + @Pulse var event: UIEvent = .none + @Pulse var route: Route = .none + var type: DictionaryType = .monster + var id = 0 + var monsterDetailInfo = DictionaryDetailMonsterResponse( + monsterId: 0, nameKr: "", nameEn: "", imageUrl: "", + level: 0, exp: 0, hp: 0, mp: 0, + physicalDefense: 0, magicDefense: 0, + requiredAccuracy: 0, bonusAccuracyPerLevelLower: 0, + evasionRate: 0, mesoDropAmount: nil, mesoDropRate: nil, + typeEffectiveness: nil, bookmarkId: nil + ) + var spawnMaps = [DictionaryDetailMonsterMapResponse]() + var dropItems = [DictionaryDetailMonsterDropItemResponse]() + var mapFilter: [SortType] { + type.detailTypes[0].sortFilter + } + + var itemFilter: [SortType] { + type.detailTypes[1].sortFilter + } + var infos = [Info]() + var isLogin = false + var lastDeletedBookmark: DictionaryDetailMonsterResponse? + } + + // MARK: - Properties + private let disposeBag = DisposeBag() + private let dictionaryDetailAPIRepository: DictionaryDetailAPIRepository + private let checkLoginUseCase: CheckLoginUseCase + private let setBookmarkUseCase: SetBookmarkUseCase + + public var initialState: State + + // MARK: - Init + public init( + dictionaryDetailAPIRepository: DictionaryDetailAPIRepository, + checkLoginUseCase: CheckLoginUseCase, + setBookmarkUseCase: SetBookmarkUseCase, + id: Int + ) { + self.initialState = State(type: .monster, id: id) + self.dictionaryDetailAPIRepository = dictionaryDetailAPIRepository + self.checkLoginUseCase = checkLoginUseCase + self.setBookmarkUseCase = setBookmarkUseCase + } + + // MARK: - Mutate + public func mutate(action: Action) -> Observable { + switch action { + case let .filterButtonTapped(type): + return .just(.navigatTo(.filter(type: type, sort: type == .map ? currentState.mapFilter : currentState.itemFilter))) + + case .viewWillAppear: + return .merge([ + checkLoginUseCase.execute().map { .setLoginState($0) }, + dictionaryDetailAPIRepository.fetchMonsterDetail(id: currentState.id).map { .setDetailData($0) }, + dictionaryDetailAPIRepository.fetchMonsterDetailDropItem(id: currentState.id, sort: nil).map { .setDetailDropItemData($0) }, + dictionaryDetailAPIRepository.fetchMonsterDetailMap(id: currentState.id).map { .setDetailMapData($0) } + ]) + + case let .selectFilter(type): + return dictionaryDetailAPIRepository.fetchMonsterDetailDropItem(id: currentState.id, sort: type.sortParameter).map { .setDetailDropItemData($0) } + + case .toggleBookmark: + return handleToggleBookmark() + + case .undoLastDeletedBookmark: + return handleUndoLastDeletedBookmark() + + case .itemTapped(index: let index): + return .just(.navigatTo(.detail(type: .item, id: currentState.dropItems[index].itemId))) + + case .mapTapped(index: let index): + return .just(.navigatTo(.detail(type: .map, id: currentState.spawnMaps[index].mapId))) + } + } + + // MARK: - Reduce + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case let .navigatTo(route): + newState.route = route + + case let .setDetailData(data): + newState.monsterDetailInfo = data + + var infos: [Info] = [] + infos.append(.init(name: "HP", desc: "\(data.hp.formatted())")) + infos.append(.init(name: "MP", desc: "\(data.mp.formatted())")) + infos.append(.init(name: "EXP", desc: "\(data.exp.formatted())")) + infos.append(.init(name: "물리방어력", desc: "\(data.physicalDefense.formatted())")) + infos.append(.init(name: "마법방어력", desc: "\(data.magicDefense.formatted())")) + newState.infos = infos + + case let .setDetailDropItemData(data): + newState.dropItems = data + + case let .setDetailMapData(data): + newState.spawnMaps = data + + case let .setBookmark(monster): + newState.monsterDetailInfo = monster + + case let .setLastDeletedBookmark(monster): + newState.lastDeletedBookmark = monster + + case let .setLoginState(isLogin): + newState.isLogin = isLogin + case let .setEvent(event): + newState.event = event + } + + return newState + } +} + +private extension MonsterDictionaryDetailReactor { + func handleToggleBookmark() -> Observable { + var monster = currentState.monsterDetailInfo + let isSelected = monster.bookmarkId != nil + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: isSelected ? monster.bookmarkId ?? monster.monsterId : monster.monsterId, + isBookmark: isSelected ? .delete : .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + monster.bookmarkId = newBookmarkId + let event: UIEvent = isSelected ? .delete(monster) : .add(monster) + let eventMutation = Observable.just(Mutation.setEvent(event)) + + let refresh = self.dictionaryDetailAPIRepository.fetchMonsterDetail(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } + + func handleUndoLastDeletedBookmark() -> Observable { + var monster = currentState.monsterDetailInfo + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: monster.monsterId, + isBookmark: .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + monster.bookmarkId = newBookmarkId + let eventMutation = Observable.just(Mutation.setEvent(.add(monster))) + let refresh = self.dictionaryDetailAPIRepository.fetchMonsterDetail(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift new file mode 100644 index 00000000..ef98f631 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift @@ -0,0 +1,253 @@ +import UIKit + +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift + +class MonsterDictionaryDetailViewController: DictionaryDetailBaseViewController, View { + public typealias Reactor = MonsterDictionaryDetailReactor + + // MARK: - Properties + private var dropItemSelectedIndex = 0 + private var mapSelectedIntdex = 0 + + // MARK: - Componenets + private var detailView = DetailStackInfoView(type: .monster) + private var appearMapView = DetailStackCardView() + private var dropItemView = DetailStackCardView() + private let sortedFactory: SortedBottomSheetFactory = SortedBottomSheetFactoryImpl() + + override func toggleBookmark() { + reactor?.action.onNext(.toggleBookmark) + } + + override func checkLogin() -> Bool { + guard let reactor = reactor else { return false } + return reactor.currentState.isLogin + } + + override func undoBookmark() { + reactor?.action.onNext(.undoLastDeletedBookmark) + } +} + +// MARK: - Populate Data +private extension MonsterDictionaryDetailViewController { + func setUpMainInfo(name: String, subText: String, imageUrl: String?) { + // 상세정보 + inject( + input: DictionaryDetailBaseViewController + .Input( + imageUrl: imageUrl, + backgroundColor: type.backgroundColor, + name: name, + subText: subText + ) + ) + } + + func setUpInfoStackView() { + guard let reactor = reactor else { return } + let infos = reactor.currentState.infos + + contentViews.append(detailView) + + for info in infos { + detailView.addInfo(mainText: info.name, subText: info.desc) + } + } + + func setUpMapView() { + guard let reactor = reactor, + let filter = reactor.currentState.mapFilter.first else { return } + + appearMapView.initFilter(firstFilter: filter) + + let maps = reactor.currentState.spawnMaps + appearMapView.reset() + contentViews.append(appearMapView) + if maps.isEmpty { + contentViews[1] = DetailEmptyView(type: .appearMap) + } else { + contentViews[1] = appearMapView + + for map in maps { + appearMapView.inject(input: DetailStackCardView + .Input( + type: .appearMapWithText, + imageUrl: map.iconUrl, + mainText: map.mapName, + subText: map.regionName, + additionalText: { + if let count = map.maxSpawnCount { + return "\(count)마리" + } else { + return "??마리" + } + }() + ) + ) + } + } + } + + func setUpDropItemView() { + guard let reactor = reactor, + let filter = reactor.currentState.itemFilter.first else { return } + + dropItemView.initFilter(firstFilter: filter) + let items = reactor.currentState.dropItems + + dropItemView.reset() + contentViews.append(dropItemView) + // 드롭아이템 + if items.isEmpty { + // 드롭 아이템 + contentViews[2] = DetailEmptyView(type: .dropItemWithText) + } else { + contentViews[2] = dropItemView + for item in items { + dropItemView + .inject( + input: DetailStackCardView + .Input( + type: .dropItemWithText, + imageUrl: item.imageUrl, + mainText: item.itemName, + subText: "Lv.\(item.itemLevel)", + additionalText: "\(item.dropRate)%" + ) + ) + } + } + } +} + +// MARK: - Bind +extension MonsterDictionaryDetailViewController { + public func bind(reactor: Reactor) { + bindcUserActions(reactor: reactor) + bindViewState(reactor: reactor) + bindReportButton(providerId: reactor.state.map { $0.id }, itemName: reactor.state.map { $0.monsterDetailInfo.nameKr }) + } + + private func bindcUserActions(reactor: Reactor) { + rx.viewWillAppear + .map { Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + dropItemView.filterButton.rx.tap + .map { Reactor.Action.filterButtonTapped(.item) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + appearMapView.filterButton.rx.tap + .map { Reactor.Action.filterButtonTapped(.map) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + dropItemView.tap + .map { Reactor.Action.itemTapped(index: $0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + appearMapView.tap + .map { Reactor.Action.mapTapped(index: $0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + private func bindViewState(reactor: Reactor) { + reactor.state.map(\.monsterDetailInfo) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, monster in + if let tags = monster.typeEffectiveness { + owner.makeTagsRow(tags) + } + owner.setUpMainInfo(name: reactor.currentState.monsterDetailInfo.nameKr, subText: "Lv. \(reactor.currentState.monsterDetailInfo.level)", imageUrl: reactor.currentState.monsterDetailInfo.imageUrl) + owner.mainView.setBookmark(isBookmarked: monster.bookmarkId != nil) + owner.setUpInfoStackView() + }) + .disposed(by: disposeBag) + + reactor.state.map(\.spawnMaps) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, _ in + owner.setUpMapView() + }) + .disposed(by: disposeBag) + + reactor.state.map(\.dropItems) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, _ in + owner.setUpDropItemView() + }) + .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 .filter(let type, let sort): + let selectedIndex = (type == .item) ? owner.dropItemSelectedIndex : owner.mapSelectedIntdex + + let viewController = owner.sortedFactory.make(sortedOptions: sort, selectedIndex: selectedIndex) { index in + if type == .item { + owner.dropItemSelectedIndex = index + let selectedFilter = sort[index] + owner.dropItemView.selectFilter(selectedType: selectedFilter) + reactor.action.onNext(.selectFilter(selectedFilter)) + } else if type == .map { + owner.mapSelectedIntdex = index + let selectedFilter = sort[index] + owner.appearMapView.selectFilter(selectedType: selectedFilter) + reactor.action.onNext(.selectFilter(selectedFilter)) + } + } + owner.tabBarController?.presentModal(viewController, hideTabBar: true) + case let .detail(type: type, id: id): + let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) + owner.navigationController?.pushViewController(viewController, animated: true) + case .bookmarkError: + ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") + default: + break + } + } + .disposed(by: disposeBag) + + reactor.pulse(\.$event) + .bind(onNext: { [weak self] event in + switch event { + case let .add(item): + self?.presentAddSnackBar( + bookmarkId: item.bookmarkId, + imageUrl: item.imageUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.monsterId, item.bookmarkId)) + case let .delete(item): + self?.presentDeleteSnackBar( + imageUrl: item.imageUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.monsterId, item.bookmarkId)) + default: break + } + }) + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailReactor.swift new file mode 100644 index 00000000..e7aeabaa --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailReactor.swift @@ -0,0 +1,188 @@ +import MLSDictionaryFeatureInterface + +import ReactorKit + +public final class NpcDictionaryDetailReactor: Reactor { + // MARK: - Route + public enum Route { + case none + case filter([SortType]) + case detail(type: DictionaryType, id: Int) + case bookmarkError + } + + public enum UIEvent { + case none + case add(DictionaryDetailNpcResponse) + case delete(DictionaryDetailNpcResponse) + case undo + } + + // MARK: - Action + public enum Action { + case filterButtonTapped + case viewWillAppear + case selectFilter(SortType) + case toggleBookmark + case undoLastDeletedBookmark + case mapTapped(index: Int) + case questTapped(index: Int) + } + + // MARK: - Mutation + public enum Mutation { + case navigatTo(Route) + case setDetailData(DictionaryDetailNpcResponse) + case setDetailMaps([DictionaryDetailMonsterMapResponse]) + case setDetailQuests([DictionaryDetailNpcQuestResponse]) + case setLoginState(Bool) + case setLastDeletedBookmark(DictionaryDetailNpcResponse?) + case setEvent(UIEvent) + } + + // MARK: - State + public struct State { + @Pulse var event: UIEvent = .none + @Pulse var route: Route = .none + var npcDetailInfo: DictionaryDetailNpcResponse + var type: DictionaryType = .npc + var maps: [DictionaryDetailMonsterMapResponse] + var quests: [DictionaryDetailNpcQuestResponse] + var questFilter: [SortType] { + type.detailTypes[0].sortFilter + } + var id: Int + var isLogin = false + var lastDeletedBookmark: DictionaryDetailNpcResponse? + } + + // MARK: - UseCases + private let dictionaryDetailAPIRepository: DictionaryDetailAPIRepository + private let checkLoginUseCase: CheckLoginUseCase + private let setBookmarkUseCase: SetBookmarkUseCase + + public var initialState: State + private let disposeBag = DisposeBag() + + // MARK: - Init + public init( + dictionaryDetailAPIRepository: DictionaryDetailAPIRepository, + checkLoginUseCase: CheckLoginUseCase, + setBookmarkUseCase: SetBookmarkUseCase, + id: Int + ) { + self.dictionaryDetailAPIRepository = dictionaryDetailAPIRepository + self.checkLoginUseCase = checkLoginUseCase + self.setBookmarkUseCase = setBookmarkUseCase + self.initialState = State( + npcDetailInfo: DictionaryDetailNpcResponse( + npcId: 0, nameKr: "", nameEn: "", iconUrlDetail: nil, bookmarkId: nil + ), + maps: [], + quests: [], + id: id + ) + } + + // MARK: - Mutate + public func mutate(action: Action) -> Observable { + switch action { + case .filterButtonTapped: + return .just(.navigatTo(.filter(currentState.questFilter))) + case .viewWillAppear: + return .merge([ + checkLoginUseCase.execute().map { .setLoginState($0) }, + dictionaryDetailAPIRepository.fetchNpcDetail(id: currentState.id).map { .setDetailData($0) }, + dictionaryDetailAPIRepository.fetchNpcDetailMap(id: currentState.id).map { .setDetailMaps($0) }, + dictionaryDetailAPIRepository.fetchNpcDetailQuest(id: currentState.id, sort: nil).map { .setDetailQuests($0) } + ]) + case let .selectFilter(type): + return dictionaryDetailAPIRepository.fetchNpcDetailQuest(id: currentState.id, sort: type.sortParameter).map { .setDetailQuests($0) } + + case .toggleBookmark: + return handleToggleBookmark() + + case .undoLastDeletedBookmark: + return handleUndoLastDeletedBookmark() + + case .mapTapped(index: let index): + return .just(.navigatTo(.detail(type: .map, id: currentState.maps[index].mapId))) + + case .questTapped(index: let index): + return .just(.navigatTo(.detail(type: .quest, id: currentState.quests[index].questId))) + } + } + + // MARK: - Reduce + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case .navigatTo(let route): + newState.route = route + case let .setDetailData(data): + newState.npcDetailInfo = data + case let .setDetailMaps(data): + newState.maps = data + case let .setDetailQuests(data): + newState.quests = data + case let .setLoginState(isLogin): + newState.isLogin = isLogin + case let .setLastDeletedBookmark(map): + newState.lastDeletedBookmark = map + case let .setEvent(event): + newState.event = event + } + return newState + } +} + +private extension NpcDictionaryDetailReactor { + func handleToggleBookmark() -> Observable { + var npc = currentState.npcDetailInfo + let isSelected = npc.bookmarkId != nil + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: isSelected ? npc.bookmarkId ?? npc.npcId : npc.npcId, + isBookmark: isSelected ? .delete : .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + npc.bookmarkId = newBookmarkId + let event: UIEvent = isSelected ? .delete(npc) : .add(npc) + let eventMutation = Observable.just(Mutation.setEvent(event)) + + let refresh = self.dictionaryDetailAPIRepository.fetchNpcDetail(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } + + func handleUndoLastDeletedBookmark() -> Observable { + var npc = currentState.npcDetailInfo + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: npc.npcId, + isBookmark: .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + npc.bookmarkId = newBookmarkId + let eventMutation = Observable.just(Mutation.setEvent(.add(npc))) + let refresh = self.dictionaryDetailAPIRepository.fetchNpcDetail(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift new file mode 100644 index 00000000..a17bbe6e --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift @@ -0,0 +1,202 @@ +import UIKit + +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift + +final class NpcDictionaryDetailViewController: DictionaryDetailBaseViewController, View { + public typealias Reactor = NpcDictionaryDetailReactor + + // MARK: - Componenets + private var appearMapView = DetailStackCardView() + private var questView = DetailStackCardView() + private let sortedFactory: SortedBottomSheetFactory = SortedBottomSheetFactoryImpl() + + override func toggleBookmark() { + reactor?.action.onNext(.toggleBookmark) + } + + override func checkLogin() -> Bool { + guard let reactor = reactor else { return false } + return reactor.currentState.isLogin + } + + override func undoBookmark() { + reactor?.action.onNext(.undoLastDeletedBookmark) + } +} + +// MARK: - SetUp +private extension NpcDictionaryDetailViewController { + // 매개변수로 넘겨주는 것과 + func setUpMainInfo(name: String, imageUrl: String?) { + // 상세정보(메인) + inject( + input: DictionaryDetailBaseViewController + .Input( + imageUrl: imageUrl, + backgroundColor: type.backgroundColor, + name: name, + subText: "" + ) + ) + } + + // 내부에서 리액터 사용해서 하는 것 + func setUpMapView() { + guard let reactor = reactor else { return } + let maps = reactor.currentState.maps + + appearMapView.reset() + contentViews.append(appearMapView) + if maps.isEmpty { + // 출현맵 + contentViews[0] = DetailEmptyView(type: .appearMap) + } else { + contentViews[0] = appearMapView + for map in maps { + appearMapView.inject(input: DetailStackCardView.Input( + type: .appearMap, + imageUrl: map.iconUrl, + mainText: map.mapName, + subText: map.detailName + )) + } + } + } + + func setUpQuestView() { + guard let reactor = reactor, + let filter = reactor.currentState.questFilter.first else { return } + + questView.initFilter(firstFilter: filter) + + let quests = reactor.currentState.quests + questView.reset() + contentViews.append(questView) + if quests.isEmpty { + // 퀘스트 + contentViews[1] = DetailEmptyView(type: .quest) + } else { + contentViews[1] = questView + for quest in quests { + questView.inject(input: DetailStackCardView.Input( + type: .quest, + imageUrl: quest.questIconUrl, + mainText: quest.questNameKr + )) + } + } + } +} + +// MARK: - Bind +extension NpcDictionaryDetailViewController { + func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + bindReportButton(providerId: reactor.state.map { $0.id }, itemName: reactor.state.map { $0.npcDetailInfo.nameKr }) + } + + private func bindUserActions(reactor: Reactor) { + rx.viewWillAppear + .map { Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + questView.filterButton.rx.tap + .map { Reactor.Action.filterButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + questView.tap + .map { Reactor.Action.questTapped(index: $0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + appearMapView.tap + .map { Reactor.Action.mapTapped(index: $0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + private 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 .filter(let type): + let viewController = owner.sortedFactory.make(sortedOptions: type, selectedIndex: owner.selectedIndex) { index in + owner.selectedIndex = index + let selectedFilter = type[index] + owner.questView.selectFilter(selectedType: selectedFilter) + reactor.action.onNext(.selectFilter(selectedFilter)) + } + owner.tabBarController?.presentModal(viewController, hideTabBar: true) + case .detail(type: let type, id: let id): + let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) + owner.navigationController?.pushViewController(viewController, animated: true) + case .bookmarkError: + ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") + default: + break + } + } + .disposed(by: disposeBag) + + reactor.state.map(\.npcDetailInfo) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, map in + owner.setUpMainInfo(name: map.nameKr, imageUrl: map.iconUrlDetail) + owner.mainView.setBookmark(isBookmarked: map.bookmarkId != nil) + }) + .disposed(by: disposeBag) + + reactor.state.map(\.maps) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, _ in + owner.setUpMapView() + }) + .disposed(by: disposeBag) + + reactor.state.map(\.quests) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, _ in + owner.setUpQuestView() + }) + .disposed(by: disposeBag) + + reactor.pulse(\.$event) + .bind(onNext: { [weak self] event in + switch event { + case let .add(item): + self?.presentAddSnackBar( + bookmarkId: item.bookmarkId, + imageUrl: item.iconUrlDetail, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.npcId, item.bookmarkId)) + case let .delete(item): + self?.presentDeleteSnackBar( + imageUrl: item.iconUrlDetail, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.npcId, item.bookmarkId)) + default: break + } + }) + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingFactoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingFactoryImpl.swift new file mode 100644 index 00000000..3adab8e7 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingFactoryImpl.swift @@ -0,0 +1,14 @@ +import MLSCore +import MLSDictionaryFeatureInterface + +public final class DetailOnBoardingFactoryImpl: DetailOnBoardingFactory { + public init() {} + + public func make() -> BaseViewController { + let reactor = DetailOnBoardingReactor() + let viewController = DetailOnBoardingViewController() + viewController.reactor = reactor + viewController.isBottomTabbarHidden = true + return viewController + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingReactor.swift new file mode 100644 index 00000000..29a2c8ae --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingReactor.swift @@ -0,0 +1,52 @@ +import MLSDictionaryFeatureInterface + +import ReactorKit + +public final class DetailOnBoardingReactor: Reactor { + // MARK: - Route + public enum Route { + case none + case dismiss + } + + // MARK: - Action + public enum Action { + case closeButtonTapped + } + + // MARK: - Mutation + public enum Mutation { + case toNavigate(Route) + } + + // MARK: - State + public struct State { + @Pulse var route: Route = .none + } + + public var initialState: State + private let disposeBag = DisposeBag() + + // MARK: - Init + public init() { + self.initialState = State(route: .none) + } + + // MARK: - Mutate + public func mutate(action: Action) -> Observable { + switch action { + case .closeButtonTapped: + .just(.toNavigate(.dismiss)) + } + } + + // MARK: - Reduce + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case let .toNavigate(route): + newState.route = route + } + return newState + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingView.swift new file mode 100644 index 00000000..803d0b89 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingView.swift @@ -0,0 +1,191 @@ +import UIKit + +import MLSDesignSystem + +import SnapKit + +class DetailOnBoardingView: UIView { + // MARK: - Type + public enum Constant { + static let margin: CGFloat = 48 + static let trailingMargin: CGFloat = 8 + static let iconSize: CGFloat = 36 + static let contentViewSize: CGFloat = 44 + static let arrowSize: CGFloat = 48 + static let arrowTrailing: CGFloat = 28 + static let arrowMargin: CGFloat = 6 + static let alertHeight: CGFloat = 220 + static let alertWidth: CGFloat = 328 + static let buttonWitdh: CGFloat = 96 + static let radius: CGFloat = 8 + } + + // MARK: - Components + private lazy var iconContentView: UIView = { + let view = UIView() + view.backgroundColor = .whiteMLS + view.layer.cornerRadius = Constant.radius + + view.addSubview(iconView) + + iconView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(Constant.iconSize) + } + + return view + }() + + private let iconView: UIImageView = { + let view = UIImageView(image: DesignSystemAsset.image(named: "guideIcon")) + return view + }() + + private let firstArrow: UIImageView = { + let view = UIImageView(image: DesignSystemAsset.image(named: "guideArrow1")) + return view + }() + + private let secondArrow: UIImageView = { + let view = UIImageView(image: DesignSystemAsset.image(named: "guideArrow2")) + return view + }() + + private let firstLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + + let text = "해당 내용에 잘못된 정보가 있다면\n아이콘을 눌러 제보할 수 있어요." + guard let font = UIFont.korFont(style: .bold, size: 16) else { return UILabel() } + let attributedString = NSMutableAttributedString( + string: text, + attributes: [ + .foregroundColor: UIColor.whiteMLS, + .font: font + ] + ) + + let highlights = ["잘못된 정보", "아이콘을 눌러 제보"] + + highlights.forEach { keyword in + if let range = text.range(of: keyword) { + attributedString.addAttribute( + .foregroundColor, + value: UIColor.secondary, + range: NSRange(range, in: text) + ) + } + } + + label.attributedText = attributedString + return label + }() + + private let secondLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + + let text = "제보해주시면 빠르게 반영 할게요!" + guard let font = UIFont.korFont(style: .bold, size: 16) else { return UILabel() } + let attributedString = NSMutableAttributedString( + string: text, + attributes: [ + .foregroundColor: UIColor.whiteMLS, + .font: font + ] + ) + + if let range = text.range(of: "빠르게 반영") { + let nsRange = NSRange(range, in: text) + attributedString.addAttribute(.foregroundColor, value: UIColor.secondary, range: nsRange) + } + + label.attributedText = attributedString + return label + }() + + private let alertView: UIImageView = { + let view = UIImageView(image: DesignSystemAsset.image(named: "guideAlert")) + return view + }() + + public let closeButton: CommonButton = { + let button = CommonButton(style: .border, title: "닫기", disabledTitle: nil) + button.updateTitleColor(color: .whiteMLS) + return button + }() + + // MARK: - Init + 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 DetailOnBoardingView { + func addViews() { + addSubview(iconContentView) + addSubview(firstArrow) + addSubview(secondArrow) + addSubview(firstLabel) + addSubview(secondLabel) + addSubview(alertView) + addSubview(closeButton) + } + + func setupConstraints() { + iconContentView.snp.makeConstraints { make in + make.top.equalToSuperview() + make.leading.equalTo(firstArrow.snp.trailing) + make.trailing.equalToSuperview().inset(Constant.trailingMargin) + make.size.equalTo(Constant.contentViewSize) + } + + firstArrow.snp.makeConstraints { make in + make.top.equalTo(iconContentView.snp.centerY) + make.size.equalTo(Constant.arrowSize) + } + + firstLabel.snp.makeConstraints { make in + make.top.equalTo(firstArrow.snp.bottom) + make.trailing.equalToSuperview().inset(Constant.trailingMargin) + } + + alertView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.equalTo(Constant.alertWidth) + make.height.equalTo(Constant.alertHeight) + } + + secondArrow.snp.makeConstraints { make in + make.top.equalTo(alertView.snp.bottom).offset(Constant.arrowMargin) + make.centerX.equalTo(firstLabel) + make.size.equalTo(Constant.arrowSize) + } + + secondLabel.snp.makeConstraints { make in + make.top.equalTo(secondArrow.snp.bottom).offset(Constant.arrowMargin) + make.trailing.equalTo(secondArrow.snp.trailing).offset(Constant.arrowTrailing) + } + + closeButton.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalToSuperview().inset(Constant.margin) + make.width.equalTo(Constant.buttonWitdh) + } + } + + func configureUI() { + backgroundColor = .clearMLS + } +} + +extension DetailOnBoardingView {} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingViewController.swift new file mode 100644 index 00000000..909e1b92 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/OnBoarding/DetailOnBoardingViewController.swift @@ -0,0 +1,83 @@ +import UIKit + +import MLSCore +import MLSDictionaryFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift + +class DetailOnBoardingViewController: BaseViewController, View { + public typealias Reactor = DetailOnBoardingReactor + + // MARK: - Properties + public var disposeBag = DisposeBag() + + // MARK: - Components + public var mainView = DetailOnBoardingView() + + public override init() { + super.init() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension DetailOnBoardingViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.horizontalEdges.bottom.equalToSuperview() + } + } + + func configureUI() { + view.backgroundColor = .textColor.withAlphaComponent(0.9) + } +} + +extension DetailOnBoardingViewController { + public func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) { + mainView.closeButton.rx.tap + .map { Reactor.Action.closeButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + func bindViewState(reactor: Reactor) { + rx.viewDidLoad + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .subscribe { owner, route in + switch route { + case .dismiss: + owner.dismiss(animated: true) + default: + break + } + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailReactor.swift new file mode 100644 index 00000000..2f5b732b --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailReactor.swift @@ -0,0 +1,235 @@ +import MLSDictionaryFeatureInterface + +import ReactorKit + +public final class QuestDictionaryDetailReactor: Reactor { + enum QuestType { + case previous + case current + case next + } + + struct QuestInfo: Equatable { + let quest: Quest + let type: QuestType + } + + public enum Route { + case none + case filter(DictionaryType) + case detail(type: DictionaryType, id: Int) + case bookmarkError + } + + public enum UIEvent { + case none + case add(DictionaryDetailQuestResponse) + case delete(DictionaryDetailQuestResponse) + case undo + } + + public enum Action { + case viewWillAppear + case toggleBookmark + case undoLastDeletedBookmark + case questTapped(index: Int) + case infoTapped(type: DictionaryType, id: Int) + } + + public enum Mutation { + case navigatTo(Route) + case setDetailData(DictionaryDetailQuestResponse) + case setLinkedQuests(DictionaryDetailQuestLinkedQuestsResponse) + case setLoginState(Bool) + case setLastDeletedBookmark(DictionaryDetailQuestResponse?) + case setEvent(UIEvent) + } + + public struct State { + @Pulse var event: UIEvent = .none + @Pulse var route: Route = .none + var type: DictionaryType = .quest + var id: Int + var detailInfo: DictionaryDetailQuestResponse + var linkedQuestInfo: DictionaryDetailQuestLinkedQuestsResponse + var totalQuest: [QuestInfo] + var isLogin = false + var lastDeletedBookmark: DictionaryDetailQuestResponse? + } + + private let dictionaryDetailAPIRepository: DictionaryDetailAPIRepository + private let checkLoginUseCase: CheckLoginUseCase + private let setBookmarkUseCase: SetBookmarkUseCase + + public var initialState: State + private let disposeBag = DisposeBag() + + public init( + dictionaryDetailAPIRepository: DictionaryDetailAPIRepository, + checkLoginUseCase: CheckLoginUseCase, + setBookmarkUseCase: SetBookmarkUseCase, + id: Int + ) { + self.dictionaryDetailAPIRepository = dictionaryDetailAPIRepository + self.checkLoginUseCase = checkLoginUseCase + self.setBookmarkUseCase = setBookmarkUseCase + self.initialState = .init( + id: id, + detailInfo: .init( + questId: 0, + titlePrefix: nil, + nameKr: nil, + nameEn: nil, + iconUrl: nil, + questType: nil, + minLevel: nil, + maxLevel: nil, + requiredMesoStart: nil, + startNpcId: nil, + startNpcName: nil, + endNpcId: nil, + endNpcName: nil, + reward: nil, + rewardItems: nil, + requirements: nil, + allowedJobs: nil, + bookmarkId: nil + ), + linkedQuestInfo: .init(previousQuests: nil, nextQuests: nil), totalQuest: [] + ) + } + + public func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear: + return .merge([ + checkLoginUseCase.execute().map { .setLoginState($0) }, + dictionaryDetailAPIRepository.fetchQuestDetail(id: currentState.id).map { .setDetailData($0) }, + dictionaryDetailAPIRepository.fetchQuestDetailLinkedQuestsDetail(id: currentState.id).map { .setLinkedQuests($0) } + ]) + + case .toggleBookmark: + return handleToggleBookmark() + + case .undoLastDeletedBookmark: + return handleUndoLastDeletedBookmark() + + case let .questTapped(index): + let tappedQuestInfo = currentState.totalQuest[index] + guard let id = tappedQuestInfo.quest.questId, + tappedQuestInfo.type != .current else { return .empty() } + return .just(.navigatTo(.detail(type: .quest, id: id))) + + case let .infoTapped(type: type, id: id): + return .just(.navigatTo(.detail(type: type, id: id))) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case let .setDetailData(data): + newState.detailInfo = data + newState.totalQuest = mergeTotalQuests( + detailInfo: data, + linkedInfo: state.linkedQuestInfo + ) + case let .setLinkedQuests(data): + newState.linkedQuestInfo = data + newState.totalQuest = mergeTotalQuests( + detailInfo: state.detailInfo, + linkedInfo: data + ) + case let .setLoginState(isLogin): + newState.isLogin = isLogin + case let .setLastDeletedBookmark(data): + newState.lastDeletedBookmark = data + case let .navigatTo(route): + newState.route = route + case let .setEvent(event): + newState.event = event + } + return newState + } +} + +extension QuestDictionaryDetailReactor { + private func mergeTotalQuests( + detailInfo: DictionaryDetailQuestResponse, + linkedInfo: DictionaryDetailQuestLinkedQuestsResponse + ) -> [QuestInfo] { + var quests: [QuestInfo] = [] + + if let previous = linkedInfo.previousQuests { + let mapped = previous.map { QuestInfo(quest: $0, type: .previous) } + quests.append(contentsOf: mapped) + } + + let currentQuest = Quest( + questId: detailInfo.questId, + name: detailInfo.nameKr ?? "", + minLevel: detailInfo.minLevel, + maxLevel: detailInfo.maxLevel, + iconUrl: detailInfo.iconUrl + ) + quests.append(QuestInfo(quest: currentQuest, type: .current)) + + if let next = linkedInfo.nextQuests { + let mapped = next.map { QuestInfo(quest: $0, type: .next) } + quests.append(contentsOf: mapped) + } + + return quests + } +} + +private extension QuestDictionaryDetailReactor { + func handleToggleBookmark() -> Observable { + var quest = currentState.detailInfo + let isSelected = quest.bookmarkId != nil + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: isSelected ? quest.bookmarkId ?? quest.questId : quest.questId, + isBookmark: isSelected ? .delete : .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + quest.bookmarkId = newBookmarkId + let event: UIEvent = isSelected ? .delete(quest) : .add(quest) + let eventMutation = Observable.just(Mutation.setEvent(event)) + + let refresh = self.dictionaryDetailAPIRepository.fetchQuestDetail(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } + + func handleUndoLastDeletedBookmark() -> Observable { + var quest = currentState.detailInfo + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: quest.questId, + isBookmark: .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + quest.bookmarkId = newBookmarkId + let eventMutation = Observable.just(Mutation.setEvent(.add(quest))) + let refresh = self.dictionaryDetailAPIRepository.fetchQuestDetail(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift new file mode 100644 index 00000000..95d183b2 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift @@ -0,0 +1,233 @@ +import UIKit + +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift + +final class QuestDictionaryDetailViewController: DictionaryDetailBaseViewController, View { + public typealias Reactor = QuestDictionaryDetailReactor + // MARK: - Components + private var detailInfoView = DetailStackInfoView(type: .quest) + private var linkedQuestView = DetailStackCardView() + + override func toggleBookmark() { + reactor?.action.onNext(.toggleBookmark) + } + + override func checkLogin() -> Bool { + guard let reactor = reactor else { return false } + return reactor.currentState.isLogin + } + + override func undoBookmark() { + reactor?.action.onNext(.undoLastDeletedBookmark) + } +} + +// MARK: - Populate Data +private extension QuestDictionaryDetailViewController { + func setUpMainInfo() { + // 상세 정보(메인?) + inject(input: DictionaryDetailBaseViewController.Input( + imageUrl: reactor?.currentState.detailInfo.iconUrl, + backgroundColor: type.backgroundColor, + name: reactor?.currentState.detailInfo.nameKr ?? "이름 없음", + subText: "수락 Lv.\(reactor?.currentState.detailInfo.minLevel ?? 0)" + )) + } + + func setUpInfoStackView() { + guard let reactor = reactor else { return } + let detailInfos = reactor.currentState.detailInfo + + let rewardInfos = reactor.currentState.detailInfo.reward + let rewardItemInfos = reactor.currentState.detailInfo.rewardItems + + contentViews.append(detailInfoView) + // 뭘로 빈페이지 보여줄지 정하지.. + detailInfoView.reset() + if !(detailInfos.startNpcName == nil) { + contentViews.append(detailInfoView) + // 완료조건 추가 + if let requirements = detailInfos.requirements { + for requirement in requirements { + if let quantity = requirement.quantity { + if let name = requirement.itemName ?? requirement.monsterName, + let type = DictionaryType(rawValue: requirement.requirementType ?? "") { + + if let id = type == .item ? requirement.itemId : requirement.monsterId { + detailInfoView.addCondition( + mainText: name, + subText: "\(quantity)", + clickable: true, + onTap: { [weak reactor] in + reactor?.action.onNext(.infoTapped(type: type, id: id)) + } + ) + } else { + detailInfoView.addCondition( + mainText: name, + subText: "\(quantity)", + clickable: false, + onTap: {} + ) + } + } + } + } + } + + if let minLevel = detailInfos.minLevel { + detailInfoView.addDetailInfo(mainText: DictionaryDetailText.minLevel, subText: "\(minLevel)") + } + if let maxLevel = detailInfos.maxLevel { + detailInfoView.addDetailInfo(mainText: DictionaryDetailText.maxLevel, subText: "\(maxLevel)") + } + if let startNpc = detailInfos.startNpcName { + detailInfoView.addDetailInfo(mainText: DictionaryDetailText.startNpc, subText: startNpc) + } + if let endNpc = detailInfos.endNpcName { + detailInfoView.addDetailInfo(mainText: DictionaryDetailText.endNpc, subText: endNpc) + } + if let jobs = detailInfos.allowedJobs { + let jobNames = jobs.compactMap { $0.jobName } // nil 제거 + jobName만 추출 + let jobText = jobNames.joined(separator: ", ") + if !jobText.isEmpty { + detailInfoView.addDetailInfo(mainText: DictionaryDetailText.job, subText: jobText) + } + } + + // 보상 추가 - 메소,경험치, 인기도 + if let meso = rewardInfos?.meso { + detailInfoView.addReward(mainText: DictionaryDetailText.meso, subText: "\(meso.formatted())") + } + if let exp = rewardInfos?.exp { + detailInfoView.addReward(mainText: DictionaryDetailText.exp, subText: "\(exp.formatted())") + } + if let pop = rewardInfos?.popularity { + detailInfoView.addReward(mainText: DictionaryDetailText.pop, subText: "\(pop.formatted())") + } + if let rewardItems = rewardItemInfos { + for info in rewardItems { + guard let name = info.itemName else { continue } + guard let quantity = info.quantity else { continue } + detailInfoView.addReward(mainText: name, subText: "\(quantity)") + } + } + + } else { + contentViews.append(DetailEmptyView(type: .normal)) + } + } + + func setUpQuestView() { + guard let reactor = reactor else { return } + let quests = reactor.currentState.totalQuest + + linkedQuestView.reset() + contentViews.append(linkedQuestView) + + if quests.isEmpty { + contentViews[1] = DetailEmptyView(type: .quest) + } else { + contentViews[1] = linkedQuestView + + for data in quests { + linkedQuestView.inject( + input: DetailStackCardView.Input( + type: .linkedQuest, + imageUrl: data.quest.iconUrl ?? "", + mainText: data.quest.name, + subText: "수락 Lv.\(data.quest.minLevel ?? 0)", + questType: data.type + ) + ) + } + } + } +} + +// MARK: - Bind +extension QuestDictionaryDetailViewController { + public func bind(reactor: Reactor) { + bindUserAction(reactor: reactor) + bindViewState(reactor: reactor) + bindReportButton(providerId: reactor.state.map { $0.detailInfo.questId }, itemName: reactor.state.map { $0.detailInfo.nameKr ?? "" }) + } + + private func bindUserAction(reactor: Reactor) { + rx.viewWillAppear + .map { Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + linkedQuestView.tap + .map { Reactor.Action.questTapped(index: $0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + private func bindViewState(reactor: Reactor) { + reactor.state.map(\.detailInfo) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, map in + owner.setUpMainInfo() + owner.setUpInfoStackView() + owner.mainView.setBookmark(isBookmarked: map.bookmarkId != nil) + }) + .disposed(by: disposeBag) + + reactor.state.map(\.totalQuest) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, _ in + owner.setUpQuestView() + }) + .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 let .detail(type, id): + let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) + owner.navigationController?.pushViewController(viewController, animated: true) + case .bookmarkError: + ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") + default: + break + } + } + .disposed(by: disposeBag) + + reactor.pulse(\.$event) + .bind(onNext: { [weak self] event in + switch event { + case let .add(item): + self?.presentAddSnackBar( + bookmarkId: item.bookmarkId, + imageUrl: item.iconUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.questId, item.bookmarkId)) + case let .delete(item): + self?.presentDeleteSnackBar( + imageUrl: item.iconUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.questId, item.bookmarkId)) + default: break + } + }) + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailEmptyView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailEmptyView.swift new file mode 100644 index 00000000..eba14097 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailEmptyView.swift @@ -0,0 +1,53 @@ +import UIKit + +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import SnapKit + +final class DetailEmptyView: UIView { + // MARK: - Type + private enum Constant { + static let topSpacing: CGFloat = 20 + static let filterSpacing: CGFloat = 12 + static let cardHorizontalInset: CGFloat = 16 + static let filterContainerHeight: CGFloat = 28 + static let filterContainerTopMargin: CGFloat = 12 + static let filterButtonTrailing: CGFloat = 8 + static let viewSpacing: CGFloat = 10 + } + + // MARK: - Components + let textLabel = UILabel() + + // MARK: - Init + init(type: DetailType) { + super.init(frame: .zero) + addViews() + setUpConstraints() + setTextLabel(type: type) + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension DetailEmptyView { + func addViews() { + addSubview(textLabel) + } + + func setUpConstraints() { + textLabel.snp.makeConstraints { make in + make.top.equalToSuperview().inset(36) + make.horizontalEdges.equalToSuperview().inset(16) + } + } + + func setTextLabel(type: DetailType) { + textLabel.attributedText = .makeStyledString(font: .b_s_r, text: "\(type.description) 정보가 존재하지 않습니다.", color: .neutral600) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailStackCardView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailStackCardView.swift new file mode 100644 index 00000000..9f89211e --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailStackCardView.swift @@ -0,0 +1,270 @@ +import UIKit + +import MLSCore +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import RxCocoa +import RxGesture +import RxSwift +import SnapKit + +final class DetailStackCardView: UIStackView { + // MARK: - Type + private enum Constant { + static let topSpacing: CGFloat = 20 + static let filterSpacing: CGFloat = 12 + static let cardHorizontalInset: CGFloat = 16 + static let filterContainerHeight: CGFloat = 28 + static let filterContainerTopMargin: CGFloat = 12 + static let filterButtonTrailing: CGFloat = 8 + static let viewSpacing: CGFloat = 10 + static let stackViewInset: UIEdgeInsets = .init( + top: 12, + left: 16, + bottom: 0, + right: 16 + ) + } + + // MARK: - Properties + private let disposeBag = DisposeBag() + + private let tapSubject = PublishSubject() + var tap: Observable { tapSubject.asObservable() } + + // MARK: - Components + private var cardViews: [CardList] = [] + + private let filterContainerView = UIView() + // 몬스터 순서 필터 버튼 + public let filterButton: UIButton = { + let button = UIButton() + button.setAttributedTitle( + .makeStyledString( + font: .b_s_r, + text: "드롭률 높은 순", + color: .textColor + ), + for: .normal + ) + button.setImage( + DesignSystemAsset.image(named: "dropDown").withRenderingMode( + .alwaysTemplate + ), + for: .normal + ) + + button.tintColor = .textColor + button.semanticContentAttribute = .forceRightToLeft + return button + }() + + private let spacer = UIView() + + // MARK: - Init + init() { + super.init(frame: .zero) + self.isLayoutMarginsRelativeArrangement = true + self.layoutMargins = Constant.stackViewInset + + addViews() + setUpConstraints() + configureUI() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +extension DetailStackCardView { + fileprivate func addViews() { + addArrangedSubview(filterContainerView) + filterContainerView.addSubview(filterButton) + addArrangedSubview(spacer) + } + + fileprivate func setUpConstraints() { + filterContainerView.snp.makeConstraints { make in + make.height.equalTo(Constant.filterContainerHeight) + } + + filterButton.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset( + Constant.filterButtonTrailing + ) + } + + spacer.snp.makeConstraints { make in + make.height.equalTo(Constant.filterSpacing) + } + } + + fileprivate func configureUI() { + axis = .vertical + alignment = .fill + distribution = .fill + } +} + +// MARK: - Methods +extension DetailStackCardView { + struct Input { + let type: DetailType + let imageUrl: String + // 왼쪽 텍스트 + let mainText: String? + var subText: String? + // 오른쪽 텍스트 + var additionalText: String? + var questType: QuestDictionaryDetailReactor.QuestType? + + init( + type: DetailType, + imageUrl: String, + mainText: String?, + subText: String? = nil, + additionalText: String? = nil, + questType: QuestDictionaryDetailReactor.QuestType? = nil + ) { + self.type = type + self.imageUrl = imageUrl + self.mainText = mainText + self.subText = subText + self.additionalText = additionalText + self.questType = questType + } + } + + func inject(input: Input) { + // type별 필터 유무 + setFilter(isHidden: input.type.sortFilter.isEmpty) + let cardView = CardList() + cardViews.append(cardView) + let spacer = UIView() + + addArrangedSubview(cardView) + addArrangedSubview(spacer) + + spacer.snp.makeConstraints { make in + make.height.equalTo(Constant.viewSpacing) + } + + cardView.setType(type: .detailStackText) + ImageLoader.shared.loadImage(stringURL: input.imageUrl) { image in + guard let image = image else { return } + if input.type == .appearMap || input.type == .appearMapWithText { + cardView.setMapImage( + image: image, + backgroundColor: input.type.backgroundColor + ) + } else { + cardView.setImage( + image: image, + backgroundColor: input.type.backgroundColor + ) + } + } + cardView.mainText = input.mainText + cardView.subText = input.subText + + switch input.type { + case .dropItemWithText, .dropMonsterWithText: + cardView.setType(type: .detailStackText) + cardView.setDropInfoText(title: "드롭률", value: input.additionalText) + case .appearMonsterWithText, .appearMapWithText: + cardView.setType(type: .detailStackText) + cardView.setDropInfoText(title: "출현수", value: input.additionalText) + case .appearMap, .appearNPC, .quest: + cardView.setType(type: .detailStack) + case .linkedQuest: + switch input.questType { + case .previous: + cardView.setType(type: .detailStackBadge(.preQuest)) + case .current: + cardView.setType(type: .detailStackBadge(.currentQuest)) + default: + cardView.setType(type: .detailStackBadge(.nextQuest)) + } + default: + break + } + + cardView.rx.tapGesture() + .when(.recognized) + .map { [weak self] _ -> Int in + guard let self = self else { return 0 } + return self.cardViews.firstIndex(of: cardView) ?? 0 + } + .bind(to: tapSubject) + .disposed(by: disposeBag) + } + + func setFilter(isHidden: Bool) { + filterContainerView.isHidden = isHidden + + spacer.snp.remakeConstraints { make in + make.height.equalTo( + isHidden ? Constant.topSpacing : Constant.filterSpacing + ) + } + } + + func selectFilter(selectedType: SortType) { + filterButton.setAttributedTitle( + .makeStyledString( + font: .b_s_r, + text: selectedType.rawValue, + color: .primary700 + ), + for: .normal + ) + filterButton.tintColor = .primary700 + } + + func initFilter(firstFilter: SortType) { + filterButton.setAttributedTitle( + .makeStyledString( + font: .b_s_r, + text: firstFilter.rawValue, + color: .textColor + ), + for: .normal + ) + } + + func reset() { + cardViews.removeAll() + // 필터 뷰를 제외한 arrangedSubview만 제거 + for subview in arrangedSubviews { + if subview == filterContainerView { continue } + if subview == spacer { continue } + + removeArrangedSubview(subview) + subview.removeFromSuperview() + } + } +} + +extension DetailType { + var backgroundColor: UIColor { + switch self { + case .appearMap, .appearMapWithText: + .listMap + case .appearMonsterWithText, .dropMonsterWithText: + .listMonster + case .appearNPC: + .listNPC + case .dropItemWithText: + .listItem + case .linkedQuest, .quest: + .listQuest + case .normal, .mapInfo: + .clearMLS + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailStackInfoView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailStackInfoView.swift new file mode 100644 index 00000000..fff25013 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailStackInfoView.swift @@ -0,0 +1,296 @@ +import UIKit + +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import RxGesture +import RxSwift +import SnapKit + +final class DetailStackInfoView: UIStackView { + // MARK: - Type + private enum Constant { + static let descriptionCornerRadius: CGFloat = 16 + static let descriptionStackViewInset: UIEdgeInsets = .init(top: 14, left: 16, bottom: 14, right: 16) + static let detailStackViewInset: UIEdgeInsets = .init(top: 20, left: 16, bottom: 20, right: 16) + static let detailInfoStackViewInset: UIEdgeInsets = .init(top: 10, left: 0, bottom: 10, right: 0) + static let height: CGFloat = 50 + static let dividerHeight: CGFloat = 1 + static let horizontalInset: CGFloat = 10 + static let detailInfoStackViewSpacing: CGFloat = 20 + static let titleLeadingInset: CGFloat = 16 + } + + // MARK: - Properties + private let disposeBag = DisposeBag() + + // MARK: - Components + // 상세정보 스택 뷰 속 설명 글 + var descriptionLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.attributedText = .makeStyledString(font: .b_s_r, text: "강철로 만든 수리검이다. 여러개가 들어있으며 모두 사용했다면 다시 충전해야 한다.강철로 만든 수리검이다. 여러개가 들어있으며 모두 사용했다면 다시 충전해야 한다.강철로 만든 수리검이다. 여러개가 들어있으며 모두 사용했다면 다시 충전해야 한다.", color: .neutral700) + label.textAlignment = .left + return label + }() + + // 상세정보 스택 뷰 속 아이템 정보 보여주는 스택뷰(물공 - 2, 판매가 - 1메소) + private let infoStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .center + stackView.backgroundColor = .whiteMLS + stackView.distribution = .fill + stackView.layer.cornerRadius = Constant.descriptionCornerRadius + return stackView + }() + + // 퀘스트 상세정보 스택뷰 속 상세정보 스택뷰 + private let detailInfoStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .center + stackView.backgroundColor = .whiteMLS + stackView.distribution = .fill + stackView.layer.cornerRadius = Constant.descriptionCornerRadius + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = Constant.detailInfoStackViewInset + return stackView + }() + + // 타이틀 뷰 + private let detailInfoTitleLabelView = UIView() + // 타이틀 라벨 + private let detailInfoTitleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .sub_m_b, text: "상세 정보", color: .textColor) + return label + }() + + // 퀘스트 완료조건 스택뷰 + private let completeConditionStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .center + stackView.backgroundColor = .whiteMLS + stackView.distribution = .fill + stackView.layer.cornerRadius = Constant.descriptionCornerRadius + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = Constant.detailInfoStackViewInset + return stackView + }() + + private let completeConditionTitleLabelView = UIView() + private let completeConditionTitleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .sub_m_b, text: "완료 조건", color: .textColor) + return label + }() + + // 퀘스트 보상 스택뷰 + private let rewardStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .center + stackView.backgroundColor = .whiteMLS + stackView.distribution = .fill + stackView.layer.cornerRadius = Constant.descriptionCornerRadius + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = Constant.detailInfoStackViewInset + return stackView + }() + + private let rewardTitleLabelView = UIView() + private let rewardTitleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .sub_m_b, text: "보상", color: .textColor) + return label + }() + + // 어떤뷰 타입의 상세정보인지 구분하기 위한 변수 + private let type: DictionaryItemType + + // MARK: - Init + init(type: DictionaryItemType) { + self.type = type + super.init(frame: .zero) + configureUI() + addViews() + // 퀘스트일때만 제약 잡아줌 + if type == .quest { + setConstraints() + } + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension DetailStackInfoView { + func addViews() { + // DetailStackInfoView는 3가지(item, monster, quest)에서만 사용할 듯? + if type == .item { + // 아이템 타입일 경우만 설명라벨추가 + addArrangedSubview(descriptionLabel) + addArrangedSubview(infoStackView) + } else if type == .monster { + addArrangedSubview(infoStackView) + } else if type == .quest { + addArrangedSubview(detailInfoStackView) + detailInfoStackView.addArrangedSubview(detailInfoTitleLabelView) + detailInfoTitleLabelView.addSubview(detailInfoTitleLabel) + + addArrangedSubview(completeConditionStackView) + completeConditionStackView.addArrangedSubview(completeConditionTitleLabelView) + completeConditionTitleLabelView.addSubview(completeConditionTitleLabel) + + addArrangedSubview(rewardStackView) + rewardStackView.addArrangedSubview(rewardTitleLabelView) + rewardTitleLabelView.addSubview(rewardTitleLabel) + } + } + + func setConstraints() { + detailInfoTitleLabelView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.height.equalTo(Constant.height) + } + detailInfoTitleLabel.snp.makeConstraints { make in + make.leading.equalTo(Constant.titleLeadingInset) + make.centerY.equalToSuperview() + } + completeConditionTitleLabelView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.height.equalTo(Constant.height) + } + completeConditionTitleLabel.snp.makeConstraints { make in + make.leading.equalTo(Constant.titleLeadingInset) + make.centerY.equalToSuperview() + } + rewardTitleLabelView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.height.equalTo(Constant.height) + } + rewardTitleLabel.snp.makeConstraints { make in + make.leading.equalTo(Constant.titleLeadingInset) + make.centerY.equalToSuperview() + } + } + + func configureUI() { + axis = .vertical + alignment = .fill + distribution = .fill + spacing = Constant.detailInfoStackViewSpacing + isLayoutMarginsRelativeArrangement = true + layoutMargins = Constant.detailStackViewInset + } +} + +// MARK: - Methods +extension DetailStackInfoView { + /// 퀘스트 상세정보 한 줄 추가 + func addDetailInfo(mainText: String, subText: String) { + addInfoRow(to: detailInfoStackView, mainText: mainText, subText: subText) + } + + /// 완료조건 상세정보 한 줄 추가 + func addCondition(mainText: String, subText: String, clickable: Bool, onTap: (() -> Void)? = nil) { + addInfoRow(to: completeConditionStackView, mainText: mainText, subText: subText, clickable: true, onTap: onTap) + } + + /// 보상 상세정보 한 줄 추가 + func addReward(mainText: String, subText: String) { + addInfoRow(to: rewardStackView, mainText: mainText, subText: subText) + } + + /// 아이템 상세정보 한 줄 추가 + func addInfo(mainText: String, subText: String) { + addInfoRow(to: infoStackView, mainText: mainText, subText: subText) + } + + /// 현재 표시 중인 모든 스택뷰 내용을 초기화 + func reset() { + infoStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + detailInfoStackView.arrangedSubviews + .filter { $0 !== detailInfoTitleLabelView } + .forEach { $0.removeFromSuperview() } + + completeConditionStackView.arrangedSubviews + .filter { $0 !== completeConditionTitleLabelView } + .forEach { $0.removeFromSuperview() } + + rewardStackView.arrangedSubviews + .filter { $0 !== rewardTitleLabelView } + .forEach { $0.removeFromSuperview() } + } + + private func addInfoRow( + to stackView: UIStackView, + mainText: String, + subText: String, + clickable: Bool = false, + onTap: (() -> Void)? = nil + ) { + let rowStackView = UIStackView() + let dividerView = DividerView() + + rowStackView.axis = .horizontal + rowStackView.distribution = .equalSpacing + rowStackView.alignment = .center + rowStackView.isLayoutMarginsRelativeArrangement = true + rowStackView.layoutMargins = Constant.descriptionStackViewInset + + let mainLabel = UILabel() + let subLabel = UILabel() + subLabel.attributedText = .makeStyledString(font: .b_s_r, text: subText) + + if clickable { + let imageView = UIImageView(image: DesignSystemAsset.image(named: "rightArrow")) + let clickableStack = UIStackView(arrangedSubviews: [mainLabel, imageView]) + clickableStack.axis = .horizontal + clickableStack.spacing = 4 + clickableStack.alignment = .center + + mainLabel.attributedText = .makeStyledUnderlinedString(font: .sub_m_sb, text: mainText) + mainLabel.isUserInteractionEnabled = true + + mainLabel.rx.tapGesture() + .when(.recognized) + .bind { _ in + onTap?() + } + .disposed(by: disposeBag) + + rowStackView.addArrangedSubview(clickableStack) + } else { + mainLabel.attributedText = .makeStyledString(font: .sub_m_sb, text: mainText) + rowStackView.addArrangedSubview(mainLabel) + } + + rowStackView.addArrangedSubview(subLabel) + + if let lastDivider = stackView.arrangedSubviews.last as? DividerView { + lastDivider.isHidden = false + } + + stackView.addArrangedSubview(rowStackView) + stackView.addArrangedSubview(dividerView) + + dividerView.isHidden = true + + rowStackView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.height.equalTo(Constant.height) + } + + dividerView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.height.equalTo(Constant.dividerHeight) + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailStackMapView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailStackMapView.swift new file mode 100644 index 00000000..55b310a0 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/DetailStackMapView.swift @@ -0,0 +1,70 @@ +import UIKit + +import MLSCore +import MLSDesignSystem + +final class DetailStackMapView: UIStackView { + // MARK: - Type + private enum Constant { + static let mapCornerRadius: CGFloat = 16 + static let mapLayoutMargin: UIEdgeInsets = .init(top: 20, left: 16, bottom: 0, right: 16) + } + + /// 상세설명 메뉴에서 보여줄 상세 설명 스택 뷰 3가지 + public let mapImageView: UIImageView = { + let view = UIImageView() + view.backgroundColor = .whiteMLS + view.layer.cornerRadius = Constant.mapCornerRadius + view.contentMode = .scaleAspectFit + view.clipsToBounds = true + return view + }() + + init(imageUrl: String) { + super.init(frame: .zero) + addViews() + setUpConstraints() + configureUI() + setUpMapView(imageUrl: imageUrl) + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension DetailStackMapView { + func addViews() { + addArrangedSubview(mapImageView) + } + + func setUpConstraints() { + mapImageView.snp.makeConstraints { make in + make.height.equalTo(mapImageView.snp.width) + } + } + + func configureUI() { + isLayoutMarginsRelativeArrangement = true + layoutMargins = Constant.mapLayoutMargin + } +} + +extension DetailStackMapView { + func setUpMapView(imageUrl: String?) { + ImageLoader.shared.loadImage(stringURL: imageUrl) { [weak self] image in + if image == DesignSystemAsset.image(named: "connectionError") { + self?.mapImageView.snp.remakeConstraints { make in + make.size.equalTo(165) + } + } else { + self?.mapImageView.snp.remakeConstraints { make in + make.height.equalTo(self?.mapImageView.snp.width ?? 0) + } + } + self?.mapImageView.image = image + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/PinchMapView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/PinchMapView.swift new file mode 100644 index 00000000..fba6614c --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/PinchMapView.swift @@ -0,0 +1,94 @@ +import UIKit + +import MLSCore +import MLSDesignSystem + +final class PinchMapView: UIView { + // MARK: - Type + private enum Constant { + static let iconInset: CGFloat = 10 + static let navHeight: CGFloat = 44 + static let buttonSize: CGFloat = 44 + } + + // MARK: - Components + public let scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.minimumZoomScale = 1.0 + scrollView.maximumZoomScale = 4.0 + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.bouncesZoom = true + scrollView.backgroundColor = .clearMLS + return scrollView + }() + + public let imageView: UIImageView = { + let view = UIImageView() + view.clipsToBounds = true + view.contentMode = .scaleAspectFit + view.backgroundColor = .clearMLS + view.layer.opacity = 0.9 + return view + }() + + let backButton: UIButton = { + let button = UIButton() + button.setImage(DesignSystemAsset.image(named: "largeX").withRenderingMode(.alwaysTemplate), for: .normal) + button.tintColor = .whiteMLS + return button + }() + + // MARK: - Init + 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 PinchMapView { + func addViews() { + addSubview(scrollView) + scrollView.addSubview(imageView) + addSubview(backButton) + } + + func setupConstraints() { + scrollView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalToSuperview() + make.height.equalToSuperview() + } + + backButton.snp.makeConstraints { make in + make.top.equalToSuperview().inset(54) + make.leading.equalToSuperview().inset(16) + make.size.equalTo(24) + } + } + + func configureUI() { + backgroundColor = .textColor.withAlphaComponent(0.9) + } +} + +// MARK: - Methods +extension PinchMapView { + func setImage(imageUrl: String) { + ImageLoader.shared.loadImage(stringURL: imageUrl) { [weak self] image in + self?.imageView.image = image + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/PinchMapViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/PinchMapViewController.swift new file mode 100644 index 00000000..baa60757 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/SectionStackView/PinchMapViewController.swift @@ -0,0 +1,64 @@ +import UIKit + +import MLSCore + +import RxCocoa +import RxSwift + +class PinchMapViewController: BaseViewController { + // MARK: - Properties + public var disposeBag = DisposeBag() + + // MARK: - Components + private var mainView = PinchMapView() + + public init(imageUrl: String) { + super.init() + mainView.setImage(imageUrl: imageUrl) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension PinchMapViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + func configureUI() { + view.backgroundColor = .clearMLS + + mainView.scrollView.delegate = self + + mainView.backButton.rx.tap + .withUnretained(self) + .subscribe { owner, _ in + owner.dismiss(animated: true) + } + .disposed(by: disposeBag) + } +} + +extension PinchMapViewController: UIScrollViewDelegate { + public func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return mainView.imageView + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListFactoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListFactoryImpl.swift new file mode 100644 index 00000000..894b0a39 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListFactoryImpl.swift @@ -0,0 +1,66 @@ +import MLSCore +import MLSAuthFeatureInterface +import MLSDictionaryFeatureInterface + +public final class DictionaryListFactoryImpl: DictionaryMainListFactory { + private let checkLoginUseCase: CheckLoginUseCase + private let setBookmarkUseCase: SetBookmarkUseCase + private let parseItemFilterResultUseCase: ParseItemFilterResultUseCase + + private let dictionaryListAPIRepository: DictionaryListAPIRepository + + private let itemFilterFactory: ItemFilterBottomSheetFactory + private let monsterFilterFactory: MonsterFilterBottomSheetFactory + private let sortedFactory: SortedBottomSheetFactory + private let bookmarkModalFactory: BookmarkModalFactory + private let detailFactory: DictionaryDetailFactory + private let loginFactory: () -> LoginFactory + + public init( + checkLoginUseCase: CheckLoginUseCase, + setBookmarkUseCase: SetBookmarkUseCase, + parseItemFilterResultUseCase: ParseItemFilterResultUseCase, + dictionaryListAPIRepository: DictionaryListAPIRepository, + itemFilterFactory: ItemFilterBottomSheetFactory, + monsterFilterFactory: MonsterFilterBottomSheetFactory, + sortedFactory: SortedBottomSheetFactory, + bookmarkModalFactory: BookmarkModalFactory, + detailFactory: DictionaryDetailFactory, + loginFactory: @escaping () -> LoginFactory + ) { + self.checkLoginUseCase = checkLoginUseCase + self.setBookmarkUseCase = setBookmarkUseCase + self.parseItemFilterResultUseCase = parseItemFilterResultUseCase + self.dictionaryListAPIRepository = dictionaryListAPIRepository + self.itemFilterFactory = itemFilterFactory + self.monsterFilterFactory = monsterFilterFactory + self.sortedFactory = sortedFactory + self.bookmarkModalFactory = bookmarkModalFactory + self.detailFactory = detailFactory + self.loginFactory = loginFactory + } + + public func make(type: DictionaryType, listType: DictionaryMainViewType, keyword: String? = "") -> BaseViewController { + let reactor = DictionaryListReactor( + type: type, + keyword: keyword, + dictionaryListAPIRepository: dictionaryListAPIRepository, + checkLoginUseCase: checkLoginUseCase, + setBookmarkUseCase: setBookmarkUseCase, + parseItemFilterResultUseCase: parseItemFilterResultUseCase + ) + let viewController = DictionaryListViewController( + reactor: reactor, + itemFilterFactory: itemFilterFactory, + monsterFilterFactory: monsterFilterFactory, + sortedFactory: sortedFactory, + bookmarkModalFactory: bookmarkModalFactory, + detailFactory: detailFactory, + loginFactory: loginFactory() + ) + if listType == .search { + viewController.isBottomTabbarHidden = true + } + return viewController + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListReactor.swift new file mode 100644 index 00000000..04a664bd --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListReactor.swift @@ -0,0 +1,371 @@ +import MLSCore +import MLSDictionaryFeatureInterface + +import ReactorKit +import RxSwift + +public final class DictionaryListReactor: Reactor { + // MARK: - Type + public enum Route { + case none + case sort(DictionaryType) + case filter(DictionaryType) + case bookmarkError + } + + public enum UIEvent { + case none + case add(DictionaryMainItemResponse) + case delete(DictionaryMainItemResponse) + case undo + case login + } + + // MARK: - Action + public enum Action { + case viewWillAppear + case sortButtonTapped + case filterButtonTapped + case sortOptionSelected(SortType) + case filterOptionSelected(startLevel: Int, endLevel: Int) + case itemFilterOptionSelected([(String, String)]) + case setCurrentPage + case fetchList + case undoLastDeletedBookmark + case toggleBookmark(id: Int) + case showLogin + case updateBookmark(id: Int, newBookmarkId: Int?) + } + + // MARK: - Mutation + public enum Mutation { + case navigatTo(Route) + case setListItem(DictionaryMainResponse, updateBookmarkOnly: Bool = false) + case setSort(String) + case setFilter(start: Int?, end: Int?) + case setCurrentPage + case initPage + case setLoginState(Bool) + case setLastDeletedBookmark(DictionaryMainItemResponse?) + case setJobId([Int]) + case setCategoryId([Int]) + case updateBookmarkId(id: Int, newBookmarkId: Int?) + case setFirstFetch(Bool) + case setEvent(UIEvent) + } + + // MARK: - State + public struct State { + @Pulse var uiEvent: UIEvent = .none + @Pulse var route: Route + public var listItems: [DictionaryMainItemResponse] = [] + public var type: DictionaryType + + public var keyword: String? + public var jobId: [Int]? + public var categoryIds: [Int]? + public var sort: String? + public var startLevel: Int? + public var endLevel: Int? + + public var currentPage = 0 + public var totalCounts = 0 + + var isLogin: Bool + var lastDeletedBookmark: DictionaryMainItemResponse? + var isBookmarkUpdateOnly = false + var isFirstFetch = true + } + + public var initialState: State + + // MARK: - UseCases + private let dictionaryListAPIRepository: DictionaryListAPIRepository + private let checkLoginUseCase: CheckLoginUseCase + private let setBookmarkUseCase: SetBookmarkUseCase + private let parseItemFilterResultUseCase: ParseItemFilterResultUseCase + + private let disposeBag = DisposeBag() + + // MARK: - Init + public init( + type: DictionaryType, + keyword: String?, + dictionaryListAPIRepository: DictionaryListAPIRepository, + checkLoginUseCase: CheckLoginUseCase, + setBookmarkUseCase: SetBookmarkUseCase, + parseItemFilterResultUseCase: ParseItemFilterResultUseCase + ) { + self.initialState = State(route: .none, type: type, keyword: keyword, isLogin: false) + self.dictionaryListAPIRepository = dictionaryListAPIRepository + self.checkLoginUseCase = checkLoginUseCase + self.setBookmarkUseCase = setBookmarkUseCase + self.parseItemFilterResultUseCase = parseItemFilterResultUseCase + } + + // MARK: - Mutate + public func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear: + return handleViewWillAppear() + case .sortButtonTapped: + return .just(.navigatTo(.sort(currentState.type))) + case .filterButtonTapped: + return .just(.navigatTo(.filter(currentState.type))) + case let .sortOptionSelected(sort): + return handleSortOptionSelected(sort: sort) + case let .filterOptionSelected(startLevel, endLevel): + return handleFilterOptionSelected(startLevel: startLevel, endLevel: endLevel) + case .setCurrentPage: + return .just(.setCurrentPage) + case .fetchList: + return fetchList(sort: currentState.sort, startLevel: currentState.startLevel, endLevel: currentState.endLevel) + case .undoLastDeletedBookmark: + return handleUndoLastDeletedBookmark() + case let .toggleBookmark(id): + return handleToggleBookmark(id: id) + case let .itemFilterOptionSelected(results): + return handleItemFilterOptionSelected(results: results) + case .showLogin: + return .just(.setEvent(.login)) + case let .updateBookmark(id, newBookmarkId): + return handleUpdateBookmark(id: id, newBookmarkId: newBookmarkId) + } + } + + // MARK: - Reduce + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case let .navigatTo(route): + newState.route = route + case let .setListItem(items, updateBookmarkOnly): + newState.isBookmarkUpdateOnly = updateBookmarkOnly + newState.totalCounts = items.totalElements + if updateBookmarkOnly { + newState.listItems = newState.listItems.map { item in + if let updated = items.contents.first(where: { $0.id == item.id }) { + var copy = item + copy.bookmarkId = updated.bookmarkId + return copy + } else { return item } + } + } else { + if newState.currentPage == 0 { + newState.listItems = items.contents + } else { + let existingIds = Set(newState.listItems.map { $0.id }) + let newItems = items.contents.filter { !existingIds.contains($0.id) } + newState.listItems.append(contentsOf: newItems) + } + } + case let .setSort(sort): + newState.sort = sort + case let .setFilter(startLevel, endLevel): + newState.startLevel = startLevel + newState.endLevel = endLevel + case .setCurrentPage: + newState.currentPage += 1 + case .initPage: + newState.currentPage = 0 + case let .setLastDeletedBookmark(item): + newState.lastDeletedBookmark = item + case let .setLoginState(isLogin): + newState.isLogin = isLogin + case let .setJobId(id): + newState.jobId = id + case let .setCategoryId(id): + newState.categoryIds = id + case let .setFirstFetch(isFirstFetch): + newState.isFirstFetch = isFirstFetch + case let .updateBookmarkId(id, newBookmarkId): + if let index = newState.listItems.firstIndex(where: { $0.id == id }) { + newState.listItems[index].bookmarkId = newBookmarkId + } + case let .setEvent(event): + newState.uiEvent = event + } + return newState + } +} + +// MARK: - Methods +private extension DictionaryListReactor { + func fetchList(sort: String?, startLevel: Int?, endLevel: Int?, updateBookmarkOnly: Bool = false) -> Observable { + let response: Observable + + switch currentState.type { + case .monster: + response = dictionaryListAPIRepository.fetchMonsterList( + keyword: currentState.keyword ?? "", + minLevel: startLevel, + maxLevel: endLevel, + page: currentState.currentPage, + size: 20, + sort: sort + ) + case .item: + response = dictionaryListAPIRepository.fetchItemList( + keyword: currentState.keyword ?? "", + jobId: currentState.jobId, + minLevel: currentState.startLevel, + maxLevel: currentState.endLevel, + categoryIds: currentState.categoryIds, + page: currentState.currentPage, + size: 20, + sort: sort + ) + case .map: + response = dictionaryListAPIRepository.fetchMapList( + keyword: currentState.keyword ?? "", + page: currentState.currentPage, + size: 20, + sort: sort ?? "ASC" + ) + case .npc: + response = dictionaryListAPIRepository.fetchNpcList( + keyword: currentState.keyword ?? "", + page: currentState.currentPage, + size: 20, + sort: sort ?? "ASC" + ) + case .quest: + response = dictionaryListAPIRepository.fetchQuestList( + keyword: currentState.keyword ?? "", + page: currentState.currentPage, + size: 20, + sort: sort ?? "ASC" + ) + case .total: + response = dictionaryListAPIRepository.fetchAllList( + keyword: currentState.keyword ?? "", + page: currentState.currentPage + ) + default: + return .empty() + } + + return response.map { .setListItem($0, updateBookmarkOnly: updateBookmarkOnly) } + } + + func handleViewWillAppear() -> Observable { + let loginState = checkLoginUseCase.execute() + .map { Mutation.setLoginState($0) } + + let fetchMutation: Observable + + if currentState.isFirstFetch { + let firstFetch = Observable.just(Mutation.setFirstFetch(false)) + let fetch = fetchList( + sort: currentState.sort, + startLevel: currentState.startLevel, + endLevel: currentState.endLevel + ) + fetchMutation = .concat([firstFetch, fetch]) + } else { + fetchMutation = fetchList( + sort: currentState.sort, + startLevel: currentState.startLevel, + endLevel: currentState.endLevel, + updateBookmarkOnly: true + ) + } + + return .merge([loginState, fetchMutation]) + } + + func handleToggleBookmark(id: Int) -> Observable { + guard let index = currentState.listItems.firstIndex(where: { $0.id == id }) else { + return .empty() + } + + let targetItem = currentState.listItems[index] + let isSelected = targetItem.bookmarkId != nil + + return setBookmarkUseCase.execute( + bookmarkId: isSelected ? targetItem.bookmarkId ?? targetItem.id : targetItem.id, + isBookmark: isSelected ? .delete : .set(targetItem.type) + ) + .flatMap { newBookmarkId -> Observable in + let lastItem = Mutation.setLastDeletedBookmark(targetItem) + + let event: UIEvent = isSelected ? .delete(targetItem) : .add(targetItem) + let eventMutation = Mutation.setEvent(event) + + let updateMutation = Mutation.updateBookmarkId(id: id, newBookmarkId: newBookmarkId) + return .from([lastItem, updateMutation, eventMutation]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } + + func handleUpdateBookmark(id: Int, newBookmarkId: Int?) -> Observable { + guard let index = currentState.listItems.firstIndex(where: { $0.id == id }) else { + return .empty() + } + + guard currentState.listItems[index].bookmarkId != newBookmarkId else { + return .empty() + } + + return .just(.updateBookmarkId(id: id, newBookmarkId: newBookmarkId)) + } + + func handleSortOptionSelected(sort: SortType) -> Observable { + return .concat([ + .just(.setSort(sort.sortParameter)), + .just(.initPage) + ]) + .concat(Observable.deferred { [weak self] in + guard let self = self else { return .empty() } + return self.fetchList(sort: self.currentState.sort, startLevel: currentState.startLevel, endLevel: currentState.endLevel) + }) + } + + func handleFilterOptionSelected(startLevel: Int?, endLevel: Int?) -> Observable { + return .concat([ + .just(.setFilter(start: startLevel, end: endLevel)), + .just(.initPage) + ]) + .concat(Observable.deferred { [weak self] in + guard let self = self else { return .empty() } + return self.fetchList(sort: self.currentState.sort, startLevel: startLevel, endLevel: endLevel) + }) + } + + func handleUndoLastDeletedBookmark() -> Observable { + guard let lastDeleted = currentState.lastDeletedBookmark else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: lastDeleted.id, + isBookmark: .set(lastDeleted.type) + ) + .flatMap { newBookmarkId -> Observable in + let lastItem = Mutation.setLastDeletedBookmark(nil) + + let event: UIEvent = .add(lastDeleted) + let eventMutation = Mutation.setEvent(event) + + let updateMutation = Mutation.updateBookmarkId(id: lastDeleted.id, newBookmarkId: newBookmarkId) + return .from([lastItem, updateMutation, eventMutation]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } + + func handleItemFilterOptionSelected(results: [(String, String)]) -> Observable { + let criteria = parseItemFilterResultUseCase.execute(results: results) + return .concat([ + .just(.setJobId(criteria.jobIds)), + .just(.setFilter(start: criteria.startLevel, end: criteria.endLevel)), + .just(.setCategoryId(criteria.categoryIds)), + .just(.initPage) + ]) + .concat(Observable.deferred { [weak self] in + guard let self = self else { return .empty() } + return self.fetchList(sort: self.currentState.sort, startLevel: self.currentState.startLevel, endLevel: self.currentState.endLevel) + }) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListView.swift new file mode 100644 index 00000000..b133cc46 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListView.swift @@ -0,0 +1,23 @@ +import UIKit + +import MLSDesignSystem + +final class DictionaryListView: BaseListView { + // MARK: - Init + init(isFilterHidden: Bool) { + let sortButton = BaseListView.makeSortButton(title: "가나다 순", tintColor: .neutral900) + let filterButton = BaseListView.makeFilterButton(title: "필터", tintColor: .neutral900) + let emptyView = DataEmptyView(type: .dictionary) + + super.init( + editButton: nil, + sortButton: sortButton, + filterButton: filterButton, + emptyView: emptyView, + isFilterHidden: isFilterHidden + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListViewController.swift new file mode 100644 index 00000000..971af4b1 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListViewController.swift @@ -0,0 +1,438 @@ +import UIKit + +import MLSAuthFeatureInterface +import MLSCore +import MLSDesignSystem +// import MLSBookmarkFeatureInterface로 수정 필요 +import MLSDictionaryFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift + +public final class DictionaryListViewController: BaseViewController, View { + public typealias Reactor = DictionaryListReactor + + // MARK: - Properties + public var disposeBag = DisposeBag() + + private let itemFilterFactory: ItemFilterBottomSheetFactory + private let monsterFilterFactory: MonsterFilterBottomSheetFactory + private let bookmarkModalFactory: BookmarkModalFactory + private let sortedFactory: SortedBottomSheetFactory + private let detailFactory: DictionaryDetailFactory + private let loginFactory: LoginFactory + + private var selectedSortIndex = 0 + public let itemCountRelay = PublishRelay() + private let bookmarkChangeRelay = PublishRelay<(id: Int, newBookmarkId: Int?)>() + private var loginRelay = PublishRelay() + private var lastPagingTime: Date = .distantPast + + // MARK: - Components + private var mainView: DictionaryListView + + public init( + reactor: DictionaryListReactor, + itemFilterFactory: ItemFilterBottomSheetFactory, + monsterFilterFactory: MonsterFilterBottomSheetFactory, + sortedFactory: SortedBottomSheetFactory, + bookmarkModalFactory: BookmarkModalFactory, + detailFactory: DictionaryDetailFactory, loginFactory: LoginFactory + ) { + self.itemFilterFactory = itemFilterFactory + self.monsterFilterFactory = monsterFilterFactory + self.sortedFactory = sortedFactory + self.bookmarkModalFactory = bookmarkModalFactory + self.detailFactory = detailFactory + self.loginFactory = loginFactory + self.mainView = DictionaryListView(isFilterHidden: reactor.currentState.type.isSortHidden) + super.init() + self.reactor = reactor + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension DictionaryListViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.horizontalEdges.bottom.equalToSuperview() + } + } + + func configureUI() { + mainView.listCollectionView.collectionViewLayout = createListLayout() + mainView.listCollectionView.delegate = self + mainView.listCollectionView.dataSource = self + mainView.listCollectionView.register( + DictionaryListCell.self, + forCellWithReuseIdentifier: DictionaryListCell.identifier + ) + } + + func createListLayout() -> UICollectionViewLayout { + guard let isHidden = reactor?.currentState.type.isSortHidden else { return UICollectionViewLayout() } + let layout = CompositionalLayoutBuilder() + .section { _ in LayoutFactory.getDictionaryListLayout(isFilterHidden: isHidden) } + .build() + layout.register( + Neutral300DividerView.self, + forDecorationViewOfKind: Neutral300DividerView.identifier + ) + return layout + } +} + +// MARK: - Bind +extension DictionaryListViewController { + public func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) { + mainView.sortButton.rx.tap + .map { Reactor.Action.sortButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.filterButton.rx.tap + .map { Reactor.Action.filterButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + func bindViewState(reactor: Reactor) { + bindItemCount(reactor: reactor) + bindLifecycle(reactor: reactor) + bindRoute(reactor: reactor) + bindTypeChanges(reactor: reactor) + bindBookmarkChange() + bindListItems() + bindUIEvents(reactor: reactor) + } + + // MARK: - Sub-binds + + private func bindItemCount(reactor: Reactor) { + reactor.state + .map { $0.totalCounts } + .distinctUntilChanged() + .bind(to: itemCountRelay) + .disposed(by: disposeBag) + } + + private func bindLifecycle(reactor: Reactor) { + rx.viewWillAppear + .map { _ in Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + private func bindRoute(reactor: Reactor) { + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { owner, route in + switch route { + case .sort(let type): + let viewController = owner.sortedFactory.make( + sortedOptions: type.bookmarkSortedFilter, + selectedIndex: owner.selectedSortIndex + ) { [weak self, weak reactor] index in + guard let self, let reactor else { return } + self.selectedSortIndex = index + let selectedFilter = reactor.currentState.type.bookmarkSortedFilter[index] + reactor.action.onNext(.sortOptionSelected(selectedFilter)) + self.mainView.selectSort(selectedType: selectedFilter) + } + owner.tabBarController?.presentModal(viewController) + case .filter(let type): + switch type { + case .item: + let viewController = owner.itemFilterFactory.make { [weak self, weak reactor] results in + guard let self, let reactor else { return } + reactor.action.onNext(.itemFilterOptionSelected(results)) + + if results.isEmpty { + self.mainView.resetFilter() + } else { + self.mainView.selectFilter() + } + } + owner.present(viewController, animated: true) + case .monster: + let viewController = owner.monsterFilterFactory.make( + startLevel: reactor.currentState.startLevel ?? 0, + endLevel: reactor.currentState.endLevel ?? 200 + ) { [weak self, weak reactor] startLevel, endLevel in + self?.mainView.selectFilter() + reactor?.action.onNext(.filterOptionSelected(startLevel: startLevel, endLevel: endLevel)) + } + owner.tabBarController?.presentModal(viewController) + default: + break + } + case .bookmarkError: + ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") + default: + break + } + }) + .disposed(by: disposeBag) + } + + private func bindTypeChanges(reactor: Reactor) { + reactor.state + .map(\.type) + .distinctUntilChanged() + .withUnretained(self) + .bind(onNext: { owner, type in + owner.mainView.updateFilter(sortType: type.sortedFilter.first) + }) + .disposed(by: disposeBag) + } + + private func bindBookmarkChange() { + bookmarkChangeRelay + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind(onNext: { owner, bookmarkResult in + let (id, newBookmarkId) = bookmarkResult + owner.reactor?.action.onNext(.updateBookmark(id: id, newBookmarkId: newBookmarkId)) + }) + .disposed(by: disposeBag) + } + + private func bindListItems() { + reactor?.state.map(\.listItems) + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind { owner, items in + owner.updateCollectionView(items: items) + } + .disposed(by: disposeBag) + } + + private func bindUIEvents(reactor: Reactor) { + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$uiEvent) } + .withUnretained(self) + .subscribe(onNext: { owner, event in + switch event { + case .add(let item): + owner.presentAddSnackBar(item: item) + case .delete(let item): + owner.presentDeleteSnackBar(item: item) + case .login: + owner.presentLoginGuide() + default: + break + } + }) + .disposed(by: disposeBag) + } + + private func presentAddSnackBar(item: DictionaryMainItemResponse) { + ImageLoader.shared.loadImage(stringURL: item.imageUrl) { image in + guard let image = image else { return } + + SnackBarFactory.createSnackBar( + type: .normal, + image: image, + imageBackgroundColor: item.type.backgroundColor, + text: "아이템을 북마크에 추가했어요.", + buttonText: "컬렉션 추가", + buttonAction: { [weak self] in + guard let self else { return } + + self.reactor?.state.map(\.listItems) + .compactMap { list in + list.first(where: { $0.id == item.id })?.bookmarkId + } + .take(1) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] bookmarkId in + guard let self else { return } + + let vc = self.bookmarkModalFactory.make( + bookmarkIds: [bookmarkId] + ) { isAdd in + if isAdd { + ToastFactory.createToast( + message: "컬렉션에 추가되었어요. 북마크 탭에서 확인 할 수 있어요." + ) + } + } + + vc.modalPresentationStyle = .pageSheet + + if let sheet = vc.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 16 + } + + self.present(vc, animated: true) + }) + .disposed(by: self.disposeBag) + } + ) + } + } + + private func presentDeleteSnackBar(item: DictionaryMainItemResponse) { + ImageLoader.shared.loadImage(stringURL: item.imageUrl) { image in + guard let image = image else { return } + + SnackBarFactory.createSnackBar( + type: .delete, + image: image, + imageBackgroundColor: item.type.backgroundColor, + text: "아이템을 북마크에서 삭제했어요.", + buttonText: "되돌리기", + buttonAction: { [weak self] in + self?.reactor?.action.onNext(.undoLastDeletedBookmark) + } + ) + } + } + + private func presentLoginGuide() { + GuideAlertFactory.show( + mainText: "북마크를 하려면 로그인이 필요해요.", + ctaText: "로그인 하기", + cancelText: "취소", + ctaAction: { [weak self] in + guard let self else { return } + let vc = self.loginFactory.make(exitRoute: .pop) + self.navigationController?.pushViewController(vc, animated: true) + }, + cancelAction: nil + ) + } + + private func updateCollectionView(items: [DictionaryMainItemResponse]) { + let collectionView = mainView.listCollectionView + mainView.checkEmptyData(isEmpty: items.isEmpty) + + guard let reactor = reactor else { return } + if reactor.currentState.currentPage == 0, !reactor.currentState.isBookmarkUpdateOnly { + collectionView.reloadData() + } else { + let startIndex = collectionView.numberOfItems(inSection: 0) + let endIndex = items.count + if endIndex > startIndex { + let indexPaths = (startIndex ..< endIndex).map { IndexPath(item: $0, section: 0) } + collectionView.performBatchUpdates { + collectionView.insertItems(at: indexPaths) + } + } + + for cell in collectionView.visibleCells { + if let indexPath = collectionView.indexPath(for: cell), + indexPath.item < items.count, + let cell = cell as? DictionaryListCell + { + let item = items[indexPath.item] + cell.updateBookmarkState(isBookmarked: item.bookmarkId != nil) + } + } + } + } +} + +// MARK: - Delegate +extension DictionaryListViewController: UICollectionViewDelegate, UICollectionViewDataSource { + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard let state = reactor?.currentState else { return 0 } + return state.listItems.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let state = reactor?.currentState, + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: DictionaryListCell.identifier, for: indexPath) as? DictionaryListCell else { return UICollectionViewCell() } + let item = state.listItems[indexPath.row] + let subText: String? = [.item, .monster, .quest].contains(item.type) ? item.level.map { "Lv. \($0)" } : nil + + cell.inject( + type: .bookmark, + input: DictionaryListCell.Input( + type: item.type, + mainText: item.name, + subText: subText, + imageUrl: item.imageUrl ?? "", + isBookmarked: item.bookmarkId != nil + ), + indexPath: indexPath, + collectionView: collectionView, + isMap: item.type == .map, + onBookmarkTapped: { [weak self] in + guard let self else { return } + guard self.reactor?.currentState.isLogin == true else { + self.reactor?.action.onNext(.showLogin) + return + } + self.reactor?.action.onNext(.toggleBookmark(id: item.id)) + } + ) + + return cell + } + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let reactor = reactor else { return } + let item: DictionaryMainItemResponse + + item = reactor.currentState.listItems[indexPath.item] + let viewController: UIViewController + + switch reactor.currentState.type { + case .total: + guard let type = item.type.toDictionaryType else { return } + viewController = detailFactory.make(type: type, id: item.id, bookmarkRelay: bookmarkChangeRelay, loginRelay: loginRelay) + default: + // 단일 타입일 경우 리액터 타입에 따라 처리 + viewController = detailFactory.make( + type: reactor.currentState.type, id: item.id, bookmarkRelay: bookmarkChangeRelay, loginRelay: loginRelay + ) + } + navigationController?.pushViewController(viewController, animated: true) + } + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + let now = Date() + guard now.timeIntervalSince(lastPagingTime) > 0.5 else { return } + + let offsetY = scrollView.contentOffset.y + let contentHeight = scrollView.contentSize.height + let height = scrollView.frame.size.height + + if offsetY > contentHeight - height - 100 { + lastPagingTime = now + reactor?.action.onNext(.setCurrentPage) + reactor?.action.onNext(.fetchList) + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryListCell.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryListCell.swift new file mode 100644 index 00000000..a08f74fd --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryListCell.swift @@ -0,0 +1,105 @@ +import UIKit + +import MLSCore +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +public final class DictionaryListCell: UICollectionViewCell { + // MARK: - Properties + private var onBookmarkTapped: (() -> Void)? + + // MARK: - Components + public let cellView = CardList() + + // MARK: - init + override init(frame: CGRect) { + super.init(frame: frame) + + addViews() + setupContstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } + + override public func prepareForReuse() { + super.prepareForReuse() + + onBookmarkTapped = nil + cellView.onIconTapped = nil + cellView.setMainText(text: "") + cellView.setSubText(text: nil) + cellView.setSelected(isSelected: false) + } +} + +// MARK: - SetUp +private extension DictionaryListCell { + func addViews() { + contentView.addSubview(cellView) + } + + func setupContstraints() { + cellView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } +} + +public extension DictionaryListCell { + struct Input { + public let type: DictionaryItemType + public let mainText: String + public let subText: String? + public let imageUrl: String + public let isBookmarked: Bool + + public init(type: DictionaryItemType, mainText: String, subText: String?, imageUrl: String, isBookmarked: Bool) { + self.type = type + self.mainText = mainText + self.subText = subText + self.imageUrl = imageUrl + self.isBookmarked = isBookmarked + } + } + + func inject( + type: CardList.CardListType, + input: Input, + indexPath: IndexPath, + collectionView: UICollectionView, + isMap: Bool = false, + onBookmarkTapped: @escaping () -> Void + ) { + cellView.setType(type: type) + cellView.setImage(image: UIImage(), backgroundColor: input.type.backgroundColor) // 초기화 + + if let url = URL(string: input.imageUrl) { + ImageLoader.shared.loadImage(url: url) { [weak self] image in + guard let self = self else { return } + // 셀이 재사용된 경우, indexPath가 다르면 무시 + if let currentIndex = collectionView.indexPath(for: self), + currentIndex == indexPath { + if isMap { + self.cellView.setMapImage(image: image ?? UIImage(), backgroundColor: input.type.backgroundColor) + } else { + self.cellView.setImage(image: image ?? UIImage(), backgroundColor: input.type.backgroundColor) + } + } + } + } + + cellView.setMainText(text: input.mainText) + cellView.setSubText(text: input.subText) + cellView.setSelected(isSelected: input.isBookmarked) + self.onBookmarkTapped = onBookmarkTapped + cellView.onIconTapped = { [weak self] in + self?.onBookmarkTapped?() + } + } + func updateBookmarkState(isBookmarked: Bool) { + cellView.setSelected(isSelected: isBookmarked) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainReactor.swift new file mode 100644 index 00000000..84ed32f2 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainReactor.swift @@ -0,0 +1,84 @@ +import ReactorKit + +import MLSDictionaryFeatureInterface +import MLSMyPageFeatureInterface + + +public final class DictionaryMainReactor: Reactor { + // MARK: - Reactor + public enum Route { + case none + case search + case notification + case login + } + + public enum Action { + case viewWillAppear + case searchButtonTapped + case notificationButtonTapped + case changeTab(Int) + } + + public enum Mutation { + case navigateTo(Route) + case setLogin(Bool) + case setCurrentTab(oldIndex: Int, newIndex: Int) + } + + public struct State { + @Pulse var route: Route = .none + let type = DictionaryMainViewType.main + var sections: [String] { + return type.pageTabList.map { $0.title } + } + var isLogin = false + var currentPageIndex = 0 + var oldPageIndex = 0 + } + + // MARK: - properties + public var initialState: State + var disposeBag = DisposeBag() + + private let fetchProfileUseCase: FetchProfileUseCase + + // MARK: - init + public init(fetchProfileUseCase: FetchProfileUseCase) { + self.initialState = State() + self.fetchProfileUseCase = fetchProfileUseCase + } + + // MARK: - Reactor Methods + public func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear: + return fetchProfileUseCase.execute() + .map { .setLogin($0 != nil) } + .catchAndReturn(.setLogin(false)) + case .searchButtonTapped: + return .just(.navigateTo(.search)) + case .notificationButtonTapped: + return .just(.navigateTo(currentState.isLogin ? .notification : .login)) + case let .changeTab(index): + let oldIndex = currentState.currentPageIndex + return .just(.setCurrentTab(oldIndex: oldIndex, newIndex: index)) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .navigateTo(let route): + newState.route = route + case let .setLogin(isLogin): + newState.isLogin = isLogin + case let .setCurrentTab(oldIndex, newIndex): + newState.oldPageIndex = oldIndex + newState.currentPageIndex = newIndex + } + + return newState + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainView.swift new file mode 100644 index 00000000..edc88343 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainView.swift @@ -0,0 +1,96 @@ +import UIKit + +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import SnapKit + +final class DictionaryMainView: UIView { + + enum Constant { + static let topMargin: CGFloat = 20 + static let pageTabHeight: CGFloat = 40 + static let bottomTabHeight: CGFloat = 64 + } + + // MARK: - Components + public let headerView = Header(style: .main, title: "도감") + + public let searchBar = SearchBar() + + public let tabCollectionView: UICollectionView = { + let layout = UICollectionViewLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.isScrollEnabled = false + return collectionView + }() + + public let pageViewController = UIPageViewController( + transitionStyle: .scroll, + navigationOrientation: .horizontal + ) + + // MARK: - Init + public init(type: DictionaryMainViewType) { + super.init(frame: .zero) + addViews(type: type) + setupConstraints(type: type) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension DictionaryMainView { + func addViews(type: DictionaryMainViewType) { + switch type { + case .search: + addSubview(searchBar) + default: + addSubview(headerView) + } + addSubview(tabCollectionView) + addSubview(pageViewController.view) + } + + func setupConstraints(type: DictionaryMainViewType) { + switch type { + case .search: + searchBar.snp.makeConstraints { make in + make.top.equalTo(safeAreaLayoutGuide) + make.horizontalEdges.equalToSuperview() + } + + tabCollectionView.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom).offset(Constant.topMargin) + make.horizontalEdges.equalToSuperview() + make.height.equalTo(Constant.pageTabHeight) + } + + pageViewController.view.snp.makeConstraints { make in + make.top.equalTo(tabCollectionView.snp.bottom) + make.horizontalEdges.equalTo(safeAreaLayoutGuide) + make.bottom.equalToSuperview() + } + default: + headerView.snp.makeConstraints { make in + make.top.equalTo(safeAreaLayoutGuide) + make.horizontalEdges.equalToSuperview() + } + + tabCollectionView.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom).offset(Constant.topMargin) + make.horizontalEdges.equalToSuperview() + make.height.equalTo(Constant.pageTabHeight) + } + + pageViewController.view.snp.makeConstraints { make in + make.top.equalTo(tabCollectionView.snp.bottom) + make.horizontalEdges.equalTo(safeAreaLayoutGuide) + make.bottom.equalToSuperview().inset(Constant.bottomTabHeight) + } + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainViewController.swift new file mode 100644 index 00000000..75c8db9e --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainViewController.swift @@ -0,0 +1,251 @@ +import UIKit + +import MLSAuthFeatureInterface +import MLSCore +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift + +public final class DictionaryMainViewController: BaseViewController, View, DictionaryTabControllable { + public typealias Reactor = DictionaryMainReactor + + // MARK: - Properties + public var disposeBag = DisposeBag() + + private let initialIndex: Int + + private let searchFactory: DictionarySearchFactory + private let notificationFactory: DictionaryNotificationFactory + private let loginFactory: LoginFactory + + private var viewControllers: [UIViewController] + + private var mainView: DictionaryMainView + let underLineController = TabBarUnderlineController() + + public init( + initialIndex: Int = 0, + dictionaryMainListFactory: DictionaryMainListFactory, + searchFactory: DictionarySearchFactory, + notificationFactory: DictionaryNotificationFactory, + loginFactory: LoginFactory, + reactor: DictionaryMainReactor + ) { + let type = reactor.currentState.type + self.mainView = DictionaryMainView(type: type) + self.viewControllers = type.pageTabList.map { dictionaryMainListFactory.make(type: $0, listType: type, keyword: "") } + self.searchFactory = searchFactory + self.notificationFactory = notificationFactory + self.loginFactory = loginFactory + self.initialIndex = initialIndex + super.init() + self.reactor = reactor + } + + @available(*, unavailable) + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +public extension DictionaryMainViewController { + override func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstraints() + configureUI() + setInitialIndex() + DictionaryTabRegistry.register(controller: self) + } +} + +// MARK: - SetUp +private extension DictionaryMainViewController { + func addViews() { + addChild(mainView.pageViewController) + mainView.pageViewController.didMove(toParent: self) + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } + + func configureUI() { + mainView.pageViewController.delegate = self + mainView.pageViewController.dataSource = self + configureTabCollectionView() + } + + func configureTabCollectionView() { + mainView.tabCollectionView.collectionViewLayout = createTabLayout() + mainView.tabCollectionView.delegate = self + mainView.tabCollectionView.dataSource = self + mainView.tabCollectionView.register(PageTabbarCell.self, forCellWithReuseIdentifier: PageTabbarCell.identifier) + underLineController.configure(with: mainView.tabCollectionView) + } + + func createTabLayout() -> UICollectionViewLayout { + let layout = CompositionalLayoutBuilder() + .section { _ in LayoutFactory.getPageTabbarLayout(underLineController: underLineController) } + .build() + return layout + } + + func setInitialIndex() { + let indexPath = IndexPath(item: initialIndex, section: 0) + + mainView.pageViewController.setViewControllers( + [viewControllers[initialIndex]], + direction: .forward, + animated: false, + completion: nil + ) + + mainView.tabCollectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally) + DispatchQueue.main.async { [weak self] in + self?.underLineController.setInitialIndicator() + } + } + + func moveToTab(oldIndex: Int, newIndex: Int) { + guard newIndex < viewControllers.count else { return } + let direction: UIPageViewController.NavigationDirection = newIndex > oldIndex ? .forward : .reverse + + mainView.pageViewController.setViewControllers( + [viewControllers[newIndex]], + direction: direction, + animated: true, + completion: nil + ) + + mainView.tabCollectionView.selectItem( + at: IndexPath(item: newIndex, section: 0), + animated: true, + scrollPosition: .centeredHorizontally + ) + + underLineController.animateIndicatorToSelectedItem() + } +} + +// MARK: - Bind +public extension DictionaryMainViewController { + func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) { + rx.viewWillAppear + .map { .viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.headerView.firstIconButton.rx.tap + .map { Reactor.Action.searchButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.headerView.secondIconButton.rx.tap + .map { Reactor.Action.notificationButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + func bindViewState(reactor: Reactor) { + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .observe(on: MainScheduler.instance) + .withUnretained(self) + .subscribe { owner, route in + switch route { + case .search: + let controller = owner.searchFactory.make() + owner.navigationController?.pushViewController(controller, animated: true) + case .notification: + let controller = owner.notificationFactory.make() + owner.navigationController?.pushViewController(controller, animated: true) + case .login: + let controller = owner.loginFactory.make(exitRoute: .pop, onLoginCompleted: nil) + owner.navigationController?.pushViewController(controller, animated: true) + default: + break + } + } + .disposed(by: disposeBag) + + reactor.state + .map(\.currentPageIndex) + .distinctUntilChanged() + .skip(1) + .withUnretained(self) + .subscribe(onNext: { owner, newIndex in + let oldIndex = reactor.currentState.oldPageIndex + owner.moveToTab(oldIndex: oldIndex, newIndex: newIndex) + }) + .disposed(by: disposeBag) + } +} + +public extension DictionaryMainViewController { + func changeTab(index: Int) { + reactor?.action.onNext(.changeTab(index)) + } +} + +// MARK: - UIPageViewController DataSource & Delegate +extension DictionaryMainViewController: UIPageViewControllerDataSource, UIPageViewControllerDelegate { + public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let index = viewControllers.firstIndex(of: viewController) else { return nil } + let previousIndex = index - 1 + return previousIndex >= 0 ? viewControllers[previousIndex] : nil + } + + public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let index = viewControllers.firstIndex(of: viewController) else { return nil } + let nextIndex = index + 1 + return nextIndex < viewControllers.count ? viewControllers[nextIndex] : nil + } + + public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { + if completed, let visibleViewController = pageViewController.viewControllers?.first, + let newIndex = viewControllers.firstIndex(of: visibleViewController) { + reactor?.action.onNext(.changeTab(newIndex)) + } + } +} + +// MARK: - UICollectionView DataSource & Delegate +extension DictionaryMainViewController: UICollectionViewDataSource, UICollectionViewDelegate { + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard let reactor = reactor else { return 0 } + return reactor.currentState.sections.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let reactor = reactor else { return UICollectionViewCell() } + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PageTabbarCell.identifier, for: indexPath) as? PageTabbarCell else { + return UICollectionViewCell() + } + let title = reactor.currentState.sections[indexPath.row] + cell.inject(title: title) + cell.isSelected = indexPath.row == reactor.currentState.currentPageIndex + return cell + } + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let newIndex = indexPath.row + guard let oldIndex = reactor?.currentState.currentPageIndex else { return } + guard newIndex != oldIndex else { return } + + reactor?.action.onNext(.changeTab(newIndex)) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainViewFactoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainViewFactoryImpl.swift new file mode 100644 index 00000000..6be090e8 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainViewFactoryImpl.swift @@ -0,0 +1,28 @@ +import MLSAuthFeatureInterface +import MLSCore +import MLSDictionaryFeatureInterface +import MLSMyPageFeatureInterface + +public final class DictionaryMainViewFactoryImpl: DictionaryMainViewFactory { + private let dictionaryMainListFactory: DictionaryMainListFactory + private let searchFactory: DictionarySearchFactory + private let notificationFactory: DictionaryNotificationFactory + private let loginFactory: LoginFactory + private let fetchProfileUseCase: FetchProfileUseCase + + public init(dictionaryMainListFactory: DictionaryMainListFactory, searchFactory: DictionarySearchFactory, notificationFactory: DictionaryNotificationFactory, loginFactory: LoginFactory, fetchProfileUseCase: FetchProfileUseCase) { + self.dictionaryMainListFactory = dictionaryMainListFactory + self.searchFactory = searchFactory + self.notificationFactory = notificationFactory + self.loginFactory = loginFactory + self.fetchProfileUseCase = fetchProfileUseCase + } + + public func make() -> BaseViewController { + let reactor = DictionaryMainReactor(fetchProfileUseCase: fetchProfileUseCase) + let viewController = DictionaryMainViewController(dictionaryMainListFactory: dictionaryMainListFactory, searchFactory: searchFactory, notificationFactory: notificationFactory, loginFactory: loginFactory, reactor: reactor, ) + viewController.isBottomTabbarHidden = false + viewController.reactor = reactor + return viewController + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationFactoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationFactoryImpl.swift new file mode 100644 index 00000000..e0943f2d --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationFactoryImpl.swift @@ -0,0 +1,30 @@ +import MLSCore +import MLSDictionaryFeatureInterface +import MLSMyPageFeatureInterface + +public final class DictionaryNotificationFactoryImpl: DictionaryNotificationFactory { + private let notificationSettingFactory: NotificationSettingFactory + + private let fetchProfileUseCase: FetchProfileUseCase + private let checkNotificationPermissionUseCase: CheckNotificationPermissionUseCase + private let alarmRepository: AlarmRepository + + public init( + notificationSettingFactory: NotificationSettingFactory, + fetchProfileUseCase: FetchProfileUseCase, + checkNotificationPermissionUseCase: CheckNotificationPermissionUseCase, + alarmRepository: AlarmRepository + ) { + self.notificationSettingFactory = notificationSettingFactory + self.fetchProfileUseCase = fetchProfileUseCase + self.checkNotificationPermissionUseCase = checkNotificationPermissionUseCase + self.alarmRepository = alarmRepository + } + + public func make() -> BaseViewController { + let reactor = DictionaryNotificationReactor(fetchProfileUseCase: fetchProfileUseCase, checkNotificationPermissionUseCase: checkNotificationPermissionUseCase, alarmRepository: alarmRepository) + let viewController = DictionaryNotificationViewController(notificationSettingFactory: notificationSettingFactory) + viewController.reactor = reactor + return viewController + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationReactor.swift new file mode 100644 index 00000000..16e7dbde --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationReactor.swift @@ -0,0 +1,143 @@ +import MLSDictionaryFeatureInterface +import MLSMyPageFeatureInterface + +import ReactorKit +import RxSwift + +public final class DictionaryNotificationReactor: Reactor { + // MARK: - Reactor + public enum Route { + case none + case dismiss + case setting + case notification(url: String) + } + + public enum Action { + case viewWillAppear + case loadMore + case backButtonTapped + case settingButtonTapped + case notificationTapped(index: Int) + } + + public enum Mutation { + case setNotifications([AllAlarmResponse], hasMore: Bool, reset: Bool) + case setLoading(Bool) + case setProfile(MyPageResponse?) + case navigateTo(Route) + case setPermission(Bool) + case checkAlarm(link: String) + } + + public struct State { + @Pulse var route: Route = .none + var notifications: [AllAlarmResponse] = [] + var profile: MyPageResponse? + var hasMore: Bool = false + var isLoading: Bool = false + var permission = false + } + + // MARK: - Properties + public var initialState: State + private let disposeBag = DisposeBag() + private let fetchProfileUseCase: FetchProfileUseCase + private let checkNotificationPermissionUseCase: CheckNotificationPermissionUseCase + private let alarmRepository: AlarmRepository + + // MARK: - Init + public init(fetchProfileUseCase: FetchProfileUseCase, checkNotificationPermissionUseCase: CheckNotificationPermissionUseCase, alarmRepository: AlarmRepository) { + self.initialState = State() + self.fetchProfileUseCase = fetchProfileUseCase + self.checkNotificationPermissionUseCase = checkNotificationPermissionUseCase + self.alarmRepository = alarmRepository + } + + // MARK: - Mutate + public func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear: + let profileStream: Observable = fetchProfileUseCase.execute() + .map { Mutation.setProfile($0) } + + let notificationStream: Observable = Observable.concat([ + Observable.just(.setLoading(true)), + alarmRepository.fetchAll(cursor: nil, pageSize: 20) + .map { paged in + Mutation.setNotifications(paged.items, hasMore: paged.hasMore, reset: true) + }, + Observable.just(.setLoading(false)) + ]) + + let permissionStream: Observable = checkNotificationPermissionUseCase.execute() + .asObservable() + .map { Mutation.setPermission($0) } + + return Observable.merge(profileStream, notificationStream, permissionStream) + + case .loadMore: + guard currentState.hasMore, !currentState.isLoading else { return .empty() } + let cursor = currentState.notifications.last?.id + + return Observable.concat([ + Observable.just(.setLoading(true)), + alarmRepository.fetchAll(cursor: cursor, pageSize: 20) + .map { paged in + Mutation.setNotifications(paged.items, hasMore: paged.hasMore, reset: false) + }, + Observable.just(.setLoading(false)) + ]) + + case .backButtonTapped: + return .just(.navigateTo(.dismiss)) + + case .settingButtonTapped: + return .just(.navigateTo(.setting)) + + case let .notificationTapped(index): + let notification = currentState.notifications[index] + + return alarmRepository.setRead(alarmLink: notification.link) + .andThen( + Observable.concat([ + .just(.checkAlarm(link: notification.link)), + .just(.navigateTo(.notification(url: notification.link))) + ]) + ) + } + } + + // MARK: - Reduce + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case let .setNotifications(newItems, hasMore, reset): + if reset { + newState.notifications = newItems + } else { + newState.notifications.append(contentsOf: newItems) + } + newState.hasMore = hasMore + + case let .setLoading(isLoading): + newState.isLoading = isLoading + + case let .setProfile(profile): + newState.profile = profile + + case let .navigateTo(route): + newState.route = route + + case let .setPermission(granted): + newState.permission = granted + case let .checkAlarm(link): + if let index = newState.notifications.firstIndex(where: { $0.link == link }) { + newState.notifications[index].alreadyRead = true + } + } + + return newState + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationView.swift new file mode 100644 index 00000000..fa0d0c8e --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationView.swift @@ -0,0 +1,91 @@ +import UIKit + +import MLSDesignSystem + +import SnapKit + +public final class DictionaryNotificationView: UIView { + // MARK: - Type + enum Constant { + static let emptyViewTopMargin: CGFloat = 40 + static let titleTopMargin: CGFloat = 20 + static let titleBottomMargin: CGFloat = 18 + static let titleHorizontalInset: CGFloat = 16 + static let titleHeight: CGFloat = 34 + static let titleWidth: CGFloat = 42 + } + + // MARK: - Components + private let emptyView = NotificationEmptyView() + public let header = NavigationBar(type: .withString("설정")) + public let titleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .h_xxxl_sb, text: "알림", alignment: .left) + return label + }() + + public let notificationCollectionView: UICollectionView = { + let layout = UICollectionViewLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + return collectionView + }() + + // MARK: - Init + 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 DictionaryNotificationView { + func addViews() { + addSubview(emptyView) + + addSubview(header) + addSubview(titleLabel) + addSubview(notificationCollectionView) + } + + func setupConstraints() { + header.snp.makeConstraints { make in + make.top.equalToSuperview() + make.horizontalEdges.equalToSuperview() + } + + emptyView.snp.makeConstraints { make in + make.centerY.equalToSuperview().multipliedBy(0.315) + make.horizontalEdges.equalToSuperview() + } + + titleLabel.snp.makeConstraints { make in + make.top.equalTo(header.snp.bottom).offset(Constant.titleTopMargin) + make.horizontalEdges.equalToSuperview().inset(Constant.titleHorizontalInset) + } + + notificationCollectionView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Constant.titleBottomMargin) + make.horizontalEdges.bottom.equalToSuperview() + } + } + + func configureUI() { + backgroundColor = .clearMLS + header.rightButton.isHidden = true + } +} + +public extension DictionaryNotificationView { + func setEmpty(hasPermission: Bool) { + emptyView.isHidden = hasPermission + titleLabel.isHidden = !hasPermission + notificationCollectionView.isHidden = !hasPermission + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationViewController.swift new file mode 100644 index 00000000..3cb17c37 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationViewController.swift @@ -0,0 +1,181 @@ +import UIKit + +import MLSCore +import MLSDesignSystem +import MLSDictionaryFeatureInterface +import MLSMyPageFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift +import SnapKit + +public final class DictionaryNotificationViewController: BaseViewController, View { + public typealias Reactor = DictionaryNotificationReactor + + // MARK: - Properties + public var disposeBag = DisposeBag() + + private var lastPagingTime: Date = .distantPast + + private var notificationSettingFactory: NotificationSettingFactory + + // MARK: - Components + private let mainView = DictionaryNotificationView() + + public init(notificationSettingFactory: NotificationSettingFactory) { + self.notificationSettingFactory = notificationSettingFactory + super.init() + } + + @available(*, unavailable) + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +public extension DictionaryNotificationViewController { + override func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension DictionaryNotificationViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.top.horizontalEdges.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalToSuperview() + } + } + + func configureUI() { + isBottomTabbarHidden = true + + mainView.notificationCollectionView.delegate = self + mainView.notificationCollectionView.dataSource = self + mainView.notificationCollectionView.register(DictionaryNotificationCell.self, forCellWithReuseIdentifier: DictionaryNotificationCell.identifier) + mainView.notificationCollectionView.collectionViewLayout = createTabLayout() + } + + func createTabLayout() -> UICollectionViewLayout { + let layout = CompositionalLayoutBuilder() + .section { _ in LayoutFactory.getNotificationLayout() } + .build() + return layout + } +} + +// MARK: - Bind +public extension DictionaryNotificationViewController { + func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) { + rx.viewWillAppear + .map { _ in Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.header.leftButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.header.boldTextButton.rx.tap + .map { Reactor.Action.settingButtonTapped } + .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.navigationController?.popViewController(animated: true) + case .setting: + 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 let .notification(url): + if let webViewController = WebViewController.make(urlString: url) { + owner.present(webViewController, animated: true) + } + default: + break + } + } + .disposed(by: disposeBag) + + reactor.state.map { $0.notifications } + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .subscribe { owner, _ in + owner.mainView.notificationCollectionView.reloadData() + } + .disposed(by: disposeBag) + + reactor.state + .map { $0.permission } + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .subscribe { owner, permission in + owner.mainView.setEmpty(hasPermission: permission) + } + .disposed(by: disposeBag) + } +} + +// MARK: - Delegate +extension DictionaryNotificationViewController: UICollectionViewDelegate, UICollectionViewDataSource { + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard let reactor = reactor else { return 0 } + return reactor.currentState.notifications.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let reactor = reactor else { return UICollectionViewCell() } + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: DictionaryNotificationCell.identifier, for: indexPath) as? DictionaryNotificationCell else { return UICollectionViewCell() } + let item = reactor.currentState.notifications[indexPath.row] + cell.inject(input: DictionaryNotificationCell.Input(title: item.title, subTitle: item.date.toDisplayDateString(), isChecked: item.alreadyRead)) + return cell + } + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let reactor = reactor else { return } + reactor.action.onNext(.notificationTapped(index: indexPath.row)) + } + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + let now = Date() + guard now.timeIntervalSince(lastPagingTime) > 0.5 else { return } + + guard let reactor = reactor else { return } + + let offsetY = scrollView.contentOffset.y + let contentHeight = scrollView.contentSize.height + let frameHeight = scrollView.frame.size.height + + if offsetY > contentHeight - frameHeight - 100 { + reactor.action.onNext(.loadMore) + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/NotificationEmptyView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/NotificationEmptyView.swift new file mode 100644 index 00000000..c7dd036d --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/NotificationEmptyView.swift @@ -0,0 +1,104 @@ +import UIKit + +import MLSDesignSystem + +import SnapKit + +final class NotificationEmptyView: UIView { + // MARK: - Type + enum Constant { + static let imageViewSize: CGFloat = 220 + static let spacing: CGFloat = 12 + } + + // MARK: - Components + private let imageView: UIImageView = { + let view = UIImageView() + view.image = DesignSystemAsset.image(named: "settingsHint") + return view + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .h_xl_b, text: "알림이 꺼져있어요", alignment: .center) + return label + }() + + private let subLabel: UILabel = { + let label = UILabel() + + let fullText = "오른쪽 상단 설정을 눌러 알림을 켜면\n업데이트, 이벤트 소식을 바로 받아볼 수 있어요!" + let keyword = "설정" + + guard let baseFont = UIFont.cp_s_r, + let specialFont = UIFont.cp_s_r else { return UILabel() } + let specialColor = UIColor.textColor + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + paragraphStyle.maximumLineHeight = (baseFont.lineHeight) * 1.17 + + let attributedText = NSMutableAttributedString( + string: fullText, + attributes: [ + .font: baseFont, + .foregroundColor: UIColor.neutral600, + .paragraphStyle: paragraphStyle + ] + ) + + if let range = fullText.range(of: keyword) { + let nsRange = NSRange(range, in: fullText) + attributedText.addAttributes([ +// .font: specialFont, + .foregroundColor: specialColor + ], range: nsRange) + } + + label.attributedText = attributedText + label.numberOfLines = 0 + return label + }() + + // MARK: - Init + 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 NotificationEmptyView { + func addViews() { + addSubview(imageView) + addSubview(titleLabel) + addSubview(subLabel) + } + + func setupConstraints() { + imageView.snp.makeConstraints { make in + make.top.centerX.equalToSuperview() + make.size.equalTo(Constant.imageViewSize) + } + + titleLabel.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom).offset(Constant.spacing) + make.centerX.equalToSuperview() + } + + subLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Constant.spacing) + make.centerX.equalToSuperview() + } + } + + func configureUI() { + backgroundColor = .clearMLS + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotificationCell.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotificationCell.swift new file mode 100644 index 00000000..2327dafa --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotificationCell.swift @@ -0,0 +1,89 @@ +import UIKit + +import MLSDesignSystem + +import RxSwift +import SnapKit + +public class DictionaryNotificationCell: UICollectionViewCell { + // MARK: - Type + private enum Constant { + static let inset: CGFloat = 20 + static let spacing: CGFloat = 4 + static let radius: CGFloat = 8 + static let iconSize: CGFloat = 8 + static let iconMargin: CGFloat = 12 + } + + // MARK: - Components + private let titleLabel = UILabel() + private let subTitleLabel = UILabel() + private let checkIcon: UIView = { + let view = UIView() + view.backgroundColor = .primary700 + view.layer.cornerRadius = Constant.iconSize / 2 + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addViews() + setupConstraints() + } + + public var disposeBag = DisposeBag() + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension DictionaryNotificationCell { + func addViews() { + contentView.addSubview(titleLabel) + contentView.addSubview(subTitleLabel) + contentView.addSubview(checkIcon) + } + + func setupConstraints() { + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.inset) + make.horizontalEdges.equalToSuperview().inset(Constant.inset) + } + + subTitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Constant.spacing) + make.horizontalEdges.equalToSuperview().inset(Constant.inset) + make.bottom.equalToSuperview().inset(Constant.inset) + } + + checkIcon.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(Constant.iconMargin) + make.centerY.equalToSuperview() + make.size.equalTo(Constant.iconSize) + } + } +} + +public extension DictionaryNotificationCell { + struct Input { + let title: String + let subTitle: String + let isChecked: Bool + + public init(title: String, subTitle: String, isChecked: Bool) { + self.title = title + self.subTitle = subTitle + self.isChecked = isChecked + } + } + + func inject(input: Input) { + titleLabel.attributedText = .makeStyledString(font: .sub_m_sb, text: input.title, color: input.isChecked ? .neutral500 : .textColor, alignment: .left) + subTitleLabel.attributedText = .makeStyledString(font: .b_s_r, text: input.subTitle, color: input.isChecked ? .neutral500 : .neutral700, alignment: .left) + backgroundColor = input.isChecked ? .neutral100 : .clearMLS + layer.cornerRadius = input.isChecked ? Constant.radius : 0 + checkIcon.isHidden = input.isChecked + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchFactoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchFactoryImpl.swift new file mode 100644 index 00000000..5aaf9d8c --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchFactoryImpl.swift @@ -0,0 +1,19 @@ +import MLSCore +import MLSDictionaryFeatureInterface + +public final class DictionarySearchFactoryImpl: DictionarySearchFactory { + private let recentSearchRepository: RecentSearchRepository + private let searchResultFactory: DictionarySearchResultFactory + + public init(recentSearchRepository: RecentSearchRepository, searchResultFactory: DictionarySearchResultFactory) { + self.recentSearchRepository = recentSearchRepository + self.searchResultFactory = searchResultFactory + } + + public func make() -> BaseViewController { + let reactor = DictionarySearchReactor(recentSearchRepository: recentSearchRepository) + let viewController = DictionarySearchViewController(searchResultFactory: searchResultFactory) + viewController.reactor = reactor + return viewController + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchReactor.swift new file mode 100644 index 00000000..ff9be5f9 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchReactor.swift @@ -0,0 +1,133 @@ +import Foundation + +import MLSDictionaryFeatureInterface + +import ReactorKit + +struct PopularItem { + let rank: Int + let name: String +} + +public final class DictionarySearchReactor: Reactor { + // MARK: - Reactor + public enum Route { + case none + case dismiss + case search(String) + } + + public enum Action { + case viewWillAppear + case backButtonTapped + case searchButtonTapped(String) + case cancelRecentButtonTapped(String) + case deleteAllButtonTapped + case recentButtonTapped(String) + } + + public enum Mutation { + case navigateTo(Route) + case deleteItem(String) + case deleteAllItems + case addRecentItem(String) + case setRecentList([String]) + } + + public struct State { + @Pulse var route: Route + var recentResult: [String] + let popularResult: [PopularItem] + } + + public let recentSearchRepository: RecentSearchRepository + + // MARK: - properties + public var initialState: State + var disposeBag = DisposeBag() + + // MARK: - init + public init(recentSearchRepository: RecentSearchRepository) { + // TODO: 인기 검색어 추후 개발 + let items = [ + PopularItem(rank: 1, name: "주니어 예티"), + PopularItem(rank: 2, name: "주니어 페페"), + PopularItem(rank: 3, name: "주니어 네키"), + PopularItem(rank: 4, name: "주니어 버섯"), + PopularItem(rank: 5, name: "주니어 달팽이"), + PopularItem(rank: 6, name: "주니어 유림"), + PopularItem(rank: 7, name: "주니어 채령"), + PopularItem(rank: 8, name: "주니어 진훈"), + PopularItem(rank: 9, name: "주니어 여송"), + PopularItem(rank: 10, name: "주니어 명범"), + PopularItem(rank: 11, name: "주니어 재혁") + ] + let numberOfRows = Int(ceil(Double(items.count) / Double(2))) + var grid = [[PopularItem?]](repeating: [PopularItem?](repeating: nil, count: 2), count: numberOfRows) + + for (index, item) in items.enumerated() { + let row = index % numberOfRows + let column = index / numberOfRows + grid[row][column] = item + } + + let newItems = grid.flatMap { $0.compactMap { $0 } } + + self.recentSearchRepository = recentSearchRepository + + let savedRecentResult: [String] = [] + + self.initialState = State( + route: .none, + recentResult: savedRecentResult, + popularResult: newItems + ) + } + + // MARK: - Reactor Methods + public func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear: + return recentSearchRepository.fetchRecentSearch().map { Mutation.setRecentList($0) } + case .backButtonTapped: + return Observable.just(.navigateTo(.dismiss)) + case .searchButtonTapped(let keyword): + return recentSearchRepository.addRecentSearch(keyword: keyword) + .andThen( + currentState.recentResult.contains(keyword) + ? .just(.navigateTo(.search(keyword))) + : .concat([ + .just(.addRecentItem(keyword)), + .just(.navigateTo(.search(keyword))) + ]) + ) + case .cancelRecentButtonTapped(let keyword): + return recentSearchRepository.removeRecentSearch(keyword: keyword) + .andThen(.just(.deleteItem(keyword))) + case .recentButtonTapped(let keyword): + return Observable.just(.navigateTo(.search(keyword))) + case .deleteAllButtonTapped: + return recentSearchRepository.removeAllSearch() + .andThen(.just(.deleteAllItems)) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .setRecentList(let list): + newState.recentResult = list + case .navigateTo(let route): + newState.route = route + case .addRecentItem(let name): + newState.recentResult.insert(name, at: 0) // 맨 앞에 최근 검색어 추가 + case .deleteItem(let name): + newState.recentResult = state.recentResult.filter { $0 != name } + case .deleteAllItems: + newState.recentResult = [] + } + + return newState + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchView.swift new file mode 100644 index 00000000..86022b03 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchView.swift @@ -0,0 +1,78 @@ +import UIKit + +import MLSDesignSystem + +import RxSwift +import SnapKit + +final class DictionarySearchView: UIView { + // MARK: - Type + enum Constant { + static let searchBarTopMargin: CGFloat = 12 + static let collectionViewTopMargin: CGFloat = 20 + static let horizontalMargin: CGFloat = 16 + static let collectionViewSpacing: CGFloat = 10 + } + + // MARK: - Properties + private let disposeBag = DisposeBag() + + // MARK: - Components + public let searchBar = SearchBar() + + public let searchCollectionView: UICollectionView = { + let layout = UICollectionViewLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + return collectionView + }() + + // MARK: - Init + init() { + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI() + setupKeyboard() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - SetUp +private extension DictionarySearchView { + func addViews() { + addSubview(searchBar) + addSubview(searchCollectionView) + } + + func setupConstraints() { + searchBar.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.searchBarTopMargin) + make.horizontalEdges.equalToSuperview() + } + + searchCollectionView.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom).offset(Constant.collectionViewTopMargin) + make.horizontalEdges.bottom.equalToSuperview() + } + } + + func configureUI() { + backgroundColor = .clearMLS + } + + func setupKeyboard() { + let tapGesture = UITapGestureRecognizer() + tapGesture.cancelsTouchesInView = false + addGestureRecognizer(tapGesture) + + tapGesture.rx.event + .bind(onNext: { [weak self] _ in + self?.endEditing(true) + }) + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchViewController.swift new file mode 100644 index 00000000..9f515a9a --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/DictionarySearchViewController.swift @@ -0,0 +1,275 @@ +import UIKit + +import MLSCore +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift + +public final class DictionarySearchViewController: BaseViewController, View { + public typealias Reactor = DictionarySearchReactor + + // MARK: - Properties + public var disposeBag = DisposeBag() + + private var searchResultFactory: DictionarySearchResultFactory + + private let chipTapRelay = PublishRelay() + private let chipCancelRelay = PublishRelay() + + // MARK: - Components + private let mainView = DictionarySearchView() + + public init(searchResultFactory: DictionarySearchResultFactory) { + self.searchResultFactory = searchResultFactory + super.init() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + } +} + +// MARK: - SetUp +private extension DictionarySearchViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } + + func configureUI() { + isBottomTabbarHidden = true + + mainView.searchBar.searchDelegate = self + mainView.searchBar.textField.becomeFirstResponder() + + mainView.searchCollectionView.collectionViewLayout = createLayout() + mainView.searchCollectionView.delegate = self + mainView.searchCollectionView.dataSource = self + mainView.searchCollectionView.register(EmptyRecentCell.self, forCellWithReuseIdentifier: EmptyRecentCell.identifier) + mainView.searchCollectionView.register(TagChipCell.self, forCellWithReuseIdentifier: TagChipCell.identifier) + mainView.searchCollectionView.register(PopularResultCell.self, forCellWithReuseIdentifier: PopularResultCell.identifier) + mainView.searchCollectionView.register( + RecentSearchHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: RecentSearchHeaderView.identifier + ) + mainView.searchCollectionView.register( + PopularSearchHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: PopularSearchHeaderView.identifier + ) + } + + func createLayout() -> UICollectionViewLayout { + let layout = UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard self != nil else { + return NSCollectionLayoutSection( + group: NSCollectionLayoutGroup.vertical( + layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(1)), + subitems: [] + ) + ) + } + + switch sectionIndex { + case 0: + return LayoutFactory.getTagChipLayout().build() + case 1: + return LayoutFactory.getDecorationSection().build() + case 2: + return LayoutFactory.getPopularResultLayout().build() + default: + return nil + } + } + + layout.register(SearchDividerView.self, forDecorationViewOfKind: SearchDividerView.identifier) + return layout + } +} + +// MARK: - Bind +extension DictionarySearchViewController { + public 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.searchBar.backButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.searchBar.searchButton.rx.tap + .withUnretained(self) + .map { $0.0.mainView.searchBar.textField.text ?? "" } + .map(Reactor.Action.searchButtonTapped) + .bind(to: reactor.action) + .disposed(by: disposeBag) + + chipTapRelay + .map { Reactor.Action.recentButtonTapped($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + chipCancelRelay + .map { Reactor.Action.cancelRecentButtonTapped($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + func bindViewState(reactor: Reactor) { + reactor.state.map { $0.recentResult } + .withUnretained(self) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { owner, _ in + owner.mainView.searchCollectionView.reloadData() + }) + .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .subscribe { owner, route in + switch route { + case .dismiss: + owner.navigationController?.popViewController(animated: true) + case .search(let keyword): + if !keyword.isOnlyKorean() { + GuideAlertFactory.show(mainText: "초성은 검색할 수 없습니다.", ctaText: "확인", ctaAction: {}) + } else { + owner.mainView.searchBar.textField.text = "" + let viewController = owner.searchResultFactory.make(keyword: keyword) + owner.navigationController?.pushViewController(viewController, animated: true) + } + default: + break + } + } + .disposed(by: disposeBag) + } +} + +// MARK: - Delegate +extension DictionarySearchViewController: UICollectionViewDelegate, UICollectionViewDataSource { + public func numberOfSections(in collectionView: UICollectionView) -> Int { + return 3 + } + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard let reactor = reactor else { return 0 } + + switch section { + case 0: + let recentResult = reactor.currentState.recentResult + return recentResult.count == 0 ? 1 : recentResult.count + case 2: + return true ? 0 : reactor.currentState.popularResult.count // TODO: 인기검색어는 추후에 + default: + return 0 + } + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let reactor = reactor else { return UICollectionViewCell() } + + let section = indexPath.section + + switch section { + case 0: + if reactor.currentState.recentResult.isEmpty { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: EmptyRecentCell.identifier, for: indexPath) as? EmptyRecentCell else { return UICollectionViewCell() } + return cell + } else { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TagChipCell.identifier, for: indexPath) as? TagChipCell else { return UICollectionViewCell() } + let item = reactor.currentState.recentResult[indexPath.row] + cell.inject(title: item, style: .search) + + cell.buttonTapSubject + .map { item } + .bind(to: chipTapRelay) + .disposed(by: cell.disposeBag) + + cell.cancelButtonTapSubject + .map { item } + .bind(to: chipCancelRelay) + .disposed(by: cell.disposeBag) + + return cell + } + case 2: + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PopularResultCell.identifier, for: indexPath) as! PopularResultCell + let item = reactor.currentState.popularResult[indexPath.item] + cell.inject(input: .init(text: item.name, rank: item.rank)) + return cell + + default: + return UICollectionViewCell() + } + } + + public func collectionView( + _ collectionView: UICollectionView, + viewForSupplementaryElementOfKind kind: String, + at indexPath: IndexPath + ) -> UICollectionReusableView { + switch indexPath.section { + case 0: + guard let view = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: RecentSearchHeaderView.identifier, + for: indexPath + ) as? RecentSearchHeaderView else { return UICollectionViewCell() } + + guard let reactor = reactor else { return UICollectionViewCell() } + + view.deleteButton.rx.tap + .map { Reactor.Action.deleteAllButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + return view + + case 2: + let view = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: PopularSearchHeaderView.identifier, + for: indexPath + ) as! PopularSearchHeaderView + // TODO: 인기검색어 추후에 + return view + default: + return UICollectionReusableView() + } + } +} + +extension DictionarySearchViewController: SearchBarDelegate { + public func searchBarDidReturn(_ searchBar: SearchBar, text: String) { + reactor?.action.onNext(.searchButtonTapped(text)) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/EmptyRecentCell.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/EmptyRecentCell.swift new file mode 100644 index 00000000..cfc2ab71 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/EmptyRecentCell.swift @@ -0,0 +1,35 @@ +import UIKit + +import SnapKit + +final class EmptyRecentCell: UICollectionViewCell { + private let label: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .b_s_r, text: "최근 검색어 내역이 없습니다", color: .neutral600) + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addViews() + setUpConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} + +// MARK: SetUp +private extension EmptyRecentCell { + func addViews() { + contentView.addSubview(label) + } + + func setUpConstraints() { + label.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.height.equalTo(32) + } + + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/PopularResultCell.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/PopularResultCell.swift new file mode 100644 index 00000000..d7fbf5d0 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/PopularResultCell.swift @@ -0,0 +1,63 @@ +import UIKit + +import MLSDesignSystem + +final class PopularResultCell: UICollectionViewCell { + // MARK: - Type + private enum Constant { + static let verticalInset: CGFloat = 8 + static let spacing: CGFloat = 8 + static let indexLabelWidth: CGFloat = 18 + } + + // MARK: - Components + public let indexLabel = UILabel() + public let textLabel = UILabel() + + // 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 PopularResultCell { + func addViews() { + contentView.addSubview(indexLabel) + contentView.addSubview(textLabel) + } + + func setupContstraints() { + indexLabel.snp.makeConstraints { make in + make.verticalEdges.equalToSuperview().inset(Constant.verticalInset) + make.leading.equalToSuperview() + make.width.equalTo(Constant.indexLabelWidth) + } + + textLabel.snp.makeConstraints { make in + make.verticalEdges.equalToSuperview().inset(Constant.verticalInset) + make.leading.equalTo(indexLabel.snp.trailing).offset(Constant.spacing).priority(.high) + make.trailing.equalToSuperview().priority(.low) + } + } +} + +extension PopularResultCell { + struct Input { + let text: String + let rank: Int + } + + func inject(input: Input) { + indexLabel.attributedText = .makeStyledString(font: input.rank < 4 ? .cp_s_m : .cp_s_r, text: "\(input.rank)", color: input.rank < 4 ? .primary700 : .neutral700, alignment: .center) + textLabel.attributedText = .makeStyledString(font: input.rank < 4 ? .cp_s_m : .cp_s_r, text: input.text, color: input.rank < 4 ? .primary700 : .neutral700, alignment: .left) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/TagChipCell.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/TagChipCell.swift new file mode 100644 index 00000000..b1e31a1a --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearch/TagChipCell.swift @@ -0,0 +1,68 @@ +import UIKit + +import MLSDesignSystem + +import RxSwift +import SnapKit + +public final class TagChipCell: UICollectionViewCell { + // MARK: - Properties + public let buttonTapSubject = PublishSubject() + public let cancelButtonTapSubject = PublishSubject() + + // MARK: - Components + public let button: TagChip = { + let button = TagChip(style: .normal, text: "") + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addViews() + setupConstraints() + } + + public var disposeBag = DisposeBag() + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func prepareForReuse() { + super.prepareForReuse() + disposeBag = DisposeBag() + } +} + +// MARK: - SetUp +private extension TagChipCell { + func addViews() { + contentView.addSubview(button) + } + + func setupConstraints() { + button.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + func bind() { + button.rx.tap + .bind(to: buttonTapSubject) + .disposed(by: disposeBag) + + button.cancelButton.rx.tap + .bind(to: cancelButtonTapSubject) + .disposed(by: disposeBag) + } +} + +public extension TagChipCell { + func inject(title: String?, style: TagChip.TagChipStyle) { + bind() + button.style = style + button.text = title ?? "" + button.titleLabel?.numberOfLines = 1 + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultFactoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultFactoryImpl.swift new file mode 100644 index 00000000..22b1aede --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultFactoryImpl.swift @@ -0,0 +1,20 @@ +import MLSCore +import MLSDictionaryFeatureInterface + +public final class DictionarySearchResultFactoryImpl: DictionarySearchResultFactory { + private let dictionaryListAPIRepository: DictionaryListAPIRepository + private let recentSearchRepository: RecentSearchRepository + private let dictionaryMainListFactory: DictionaryMainListFactory + + public init(dictionaryListAPIRepository: DictionaryListAPIRepository, recentSearchRepository: RecentSearchRepository, dictionaryMainListFactory: DictionaryMainListFactory) { + self.dictionaryListAPIRepository = dictionaryListAPIRepository + self.recentSearchRepository = recentSearchRepository + self.dictionaryMainListFactory = dictionaryMainListFactory + } + + public func make(keyword: String?) -> BaseViewController { + let reactor = DictionarySearchResultReactor(keyword: keyword, dictionaryListAPIRepository: dictionaryListAPIRepository, recentSearchRepository: recentSearchRepository) + let viewController = DictionarySearchResultViewController(dictionaryListFactory: dictionaryMainListFactory, reactor: reactor) + return viewController + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultReactor.swift new file mode 100644 index 00000000..23451aa3 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultReactor.swift @@ -0,0 +1,112 @@ +import ReactorKit + +import MLSDictionaryFeatureInterface + +public final class DictionarySearchResultReactor: Reactor { + // MARK: - Reactor + public enum Route { + case none + case dismiss + } + + public enum Action { + case backbuttonTapped + case updateKeyword(String) + case viewWillAppear + case searchButtonTapped(String?) + } + + public enum Mutation { + case navigateTo(Route) + case setKeyword(String) + case setCounts([Int]) + } + + public struct State { + @Pulse var route: Route = .none + let type = DictionaryMainViewType.search + var sections: [String] { + return type.pageTabList.map { $0.title } + } + + var keyword: String? + + var counts: [Int] = [0, 0, 0, 0, 0, 0] + } + + // MARK: - properties + public var initialState: State + var disposeBag = DisposeBag() + + // MARK: - UseCases + private let dictionaryListAPIRepository: DictionaryListAPIRepository + private let recentSearchRepository: RecentSearchRepository + + // MARK: - init + public init(keyword: String?, dictionaryListAPIRepository: DictionaryListAPIRepository, recentSearchRepository: RecentSearchRepository) { + self.initialState = State(keyword: keyword) + self.dictionaryListAPIRepository = dictionaryListAPIRepository + self.recentSearchRepository = recentSearchRepository + } + + // MARK: - Reactor Methods + public func mutate(action: Action) -> Observable { + switch action { + case .backbuttonTapped: + return Observable.just(.navigateTo(.dismiss)) + case .updateKeyword(let keyword): + return Observable.just(.setKeyword(keyword)) + case .viewWillAppear: + // 초기 검색 시, state.keyword를 그대로 사용 + // transform에서 keyword 변화 감지 후 호출됨 + if let keyword = currentState.keyword { + return Observable.just(.setKeyword(keyword)) + } else { + return .empty() + } + // 검색 결과 화면에서 재검색 시 + case .searchButtonTapped(let keyword): + let keyword = keyword ?? "" + return recentSearchRepository.addRecentSearch(keyword: keyword) + .andThen(.just(.setKeyword(keyword))) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .navigateTo(let route): + newState.route = route + case .setKeyword(let keyword): + newState.keyword = keyword + case .setCounts(let counts): + newState.counts = counts + } + + return newState + } + + public func transform(mutation: Observable) -> Observable { + let keywordChanges = mutation + .compactMap { mutation -> String? in + if case .setKeyword(let keyword) = mutation { return keyword } + return nil + } + .distinctUntilChanged() // 중복 keyword 방지 + .flatMap { [weak self] keyword -> Observable in + guard let self = self else { return .empty() } + let types = ["search", "monsters", "items", "maps", "npcs", "quests"] + let countObservables = types.map { type in + self.dictionaryListAPIRepository.fetchSearchListCount(type: type, keyword: keyword) + .map { $0.count ?? 0 } + } + return Observable.zip(countObservables) + .map { counts in + Mutation.setCounts(counts) + } + } + + return Observable.merge(mutation, keywordChanges) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultViewController.swift new file mode 100644 index 00000000..ee6471bf --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionarySearchResult/DictionarySearchResultViewController.swift @@ -0,0 +1,300 @@ +import UIKit + +import MLSCore +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift +import SnapKit + +public final class DictionarySearchResultViewController: BaseViewController, View { + public typealias Reactor = DictionarySearchResultReactor + + // MARK: - Properties + public var disposeBag = DisposeBag() + + private let initialIndex: Int + private lazy var currentPageIndex = BehaviorRelay(value: initialIndex) + + private var viewControllers: [UIViewController] + + private var mainView: DictionaryMainView + private let underLineController = TabBarUnderlineController() + private let dictionaryListFactory: DictionaryMainListFactory + + private var didSetInitialIndex = false + + public init( + dictionaryListFactory: DictionaryMainListFactory, + initialIndex: Int = 0, + reactor: DictionarySearchResultReactor + ) { + let type = reactor.currentState.type + self.mainView = DictionaryMainView(type: type) + self.viewControllers = type.pageTabList.map { dictionaryListFactory.make(type: $0, listType: type, keyword: reactor.currentState.keyword) } + self.initialIndex = initialIndex + self.dictionaryListFactory = dictionaryListFactory + super.init() + self.reactor = reactor + } + + @available(*, unavailable) + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +public extension DictionarySearchResultViewController { + override func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstraints() + configureUI() + } + + override func viewDidAppear(_ animated: Bool) { + guard !didSetInitialIndex else { return } + didSetInitialIndex = true + setInitialIndex() + } + + private func updateViewControllers(keyword: String) { + guard let reactor = reactor else { return } + let type = reactor.currentState.type + + // 기존 viewControllers 제거 + for viewController in viewControllers { + viewController.removeFromParent() + viewController.view.removeFromSuperview() + } + + // 새로운 viewControllers 생성 + viewControllers = type.pageTabList.map { dictionaryListFactory.make(type: $0, listType: type, keyword: keyword) } + + // PageViewController에 첫 번째 뷰컨트롤러 설정 + if !viewControllers.isEmpty { + mainView.pageViewController.setViewControllers( + [viewControllers[0]], + direction: .forward, + animated: false, + completion: nil + ) + } + + // TabCollectionView 갱신 + mainView.tabCollectionView.reloadData() + + // Tab 선택 초기화 + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + let indexPath = IndexPath(item: 0, section: 0) + if self.mainView.tabCollectionView.numberOfItems(inSection: 0) > 0 { + self.mainView.tabCollectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally) + self.currentPageIndex.accept(0) + self.underLineController.setInitialIndicator() + } + } + } +} + +// MARK: - SetUp +private extension DictionarySearchResultViewController { + func addViews() { + addChild(mainView.pageViewController) + mainView.pageViewController.didMove(toParent: self) + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.top.horizontalEdges.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalToSuperview() + } + } + + func configureUI() { + mainView.searchBar.searchDelegate = self + mainView.searchBar.textField.becomeFirstResponder() + + mainView.pageViewController.delegate = self + mainView.pageViewController.dataSource = self + configureTabCollectionView() + isBottomTabbarHidden = true + mainView.searchBar.textField.resignFirstResponder() + } + + func configureTabCollectionView() { + mainView.tabCollectionView.collectionViewLayout = createTabLayout() + mainView.tabCollectionView.delegate = self + mainView.tabCollectionView.dataSource = self + mainView.tabCollectionView.register(PageTabbarCell.self, forCellWithReuseIdentifier: PageTabbarCell.identifier) + underLineController.configure(with: mainView.tabCollectionView) + } + + func createTabLayout() -> UICollectionViewLayout { + let layout = CompositionalLayoutBuilder() + .section { _ in LayoutFactory.getPageTabbarLayout(underLineController: underLineController) } + .build() + return layout + } + + func setInitialIndex() { + let indexPath = IndexPath(item: initialIndex, section: 0) + + mainView.pageViewController.setViewControllers( + [viewControllers[initialIndex]], + direction: .forward, + animated: false, + completion: nil + ) + + mainView.tabCollectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally) + + // 전체 레이아웃 강제 갱신 + mainView.tabCollectionView.collectionViewLayout.invalidateLayout() + mainView.tabCollectionView.layoutIfNeeded() + + DispatchQueue.main.async { [weak self] in + self?.underLineController.setInitialIndicator() + } + } +} + +// MARK: - Bind +public extension DictionarySearchResultViewController { + func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) { + mainView.searchBar.backButton.rx.tap + .map { Reactor.Action.backbuttonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.searchBar.searchButton.rx.tap + .withLatestFrom(mainView.searchBar.textField.rx.text.orEmpty) + .map { text in Reactor.Action.searchButtonTapped(text) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + func bindViewState(reactor: Reactor) { + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .subscribe { owner, route in + switch route { + case .dismiss: + owner.navigationController?.popViewController(animated: true) + default: + break + } + } + .disposed(by: disposeBag) + + reactor.state + .map(\.keyword) + .filter { $0 != nil } + .map { $0! } + .distinctUntilChanged() + .skip(1) + .observe(on: MainScheduler.instance) + .bind(with: self) { owner, newKeyword in + if !newKeyword.isOnlyKorean() { + GuideAlertFactory.show(mainText: "초성은 검색할 수 없습니다.", ctaText: "확인", ctaAction: {}) + } else { + owner.updateViewControllers(keyword: newKeyword) + } + } + .disposed(by: disposeBag) + + rx.viewWillAppear + .map { _ in Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + reactor.state + .map(\.counts) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .bind(with: self) { owner, _ in + owner.mainView.tabCollectionView.reloadData() + } + .disposed(by: disposeBag) + } +} + +// MARK: - UIPageViewController DataSource & Delegate +extension DictionarySearchResultViewController: UIPageViewControllerDataSource, UIPageViewControllerDelegate { + public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let index = viewControllers.firstIndex(of: viewController) else { return nil } + let previousIndex = index - 1 + return previousIndex >= 0 ? viewControllers[previousIndex] : nil + } + + public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let index = viewControllers.firstIndex(of: viewController) else { return nil } + let nextIndex = index + 1 + return nextIndex < viewControllers.count ? viewControllers[nextIndex] : nil + } + + public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { + if completed, let visibleViewController = pageViewController.viewControllers?.first, + let newIndex = viewControllers.firstIndex(of: visibleViewController) { + currentPageIndex.accept(newIndex) + mainView.tabCollectionView.selectItem(at: IndexPath(item: newIndex, section: 0), animated: true, scrollPosition: .centeredHorizontally) + underLineController.animateIndicatorToSelectedItem() + } + } +} + +// MARK: - UICollectionView DataSource & Delegate +extension DictionarySearchResultViewController: UICollectionViewDataSource, UICollectionViewDelegate { + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard let reactor = reactor else { return 0 } + return reactor.currentState.sections.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let reactor = reactor else { return UICollectionViewCell() } + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PageTabbarCell.identifier, for: indexPath) as? PageTabbarCell else { + return UICollectionViewCell() + } + let title = reactor.currentState.sections[indexPath.row] + let count = reactor.currentState.counts[indexPath.row] + cell.inject(title: "\(title)(\(count))") + cell.isSelected = indexPath.row == currentPageIndex.value + return cell + } + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let newIndex = indexPath.row + let oldIndex = currentPageIndex.value + + guard newIndex != oldIndex else { return } + + let direction: UIPageViewController.NavigationDirection = newIndex > oldIndex ? .forward : .reverse + + mainView.pageViewController.setViewControllers( + [viewControllers[newIndex]], + direction: direction, + animated: true, + completion: nil + ) + + currentPageIndex.accept(newIndex) + underLineController.animateIndicatorToSelectedItem() + } +} + +extension DictionarySearchResultViewController: SearchBarDelegate { + public func searchBarDidReturn(_ searchBar: SearchBar, text: String) { + reactor?.action.onNext(.searchButtonTapped(text)) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/ItemFilterBottomSheetFactoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/ItemFilterBottomSheetFactoryImpl.swift new file mode 100644 index 00000000..ae2c535a --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/ItemFilterBottomSheetFactoryImpl.swift @@ -0,0 +1,15 @@ +import MLSCore +import MLSDictionaryFeatureInterface + +public struct ItemFilterBottomSheetFactoryImpl: ItemFilterBottomSheetFactory { + private let sharedReactor = ItemFilterBottomSheetReactor() + public init() {} + + public func make(onFilterSelected: @escaping ([(String, String)]) -> Void) -> BaseViewController { + // let reactor = ItemFilterBottomSheetReactor() // 매번 새로 리액터를 생성하기 때문에 초기화 + let viewController = ItemFilterBottomSheetViewController() + viewController.reactor = sharedReactor + viewController.onFilterSelected = onFilterSelected + return viewController + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/ItemFilterBottomSheetReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/ItemFilterBottomSheetReactor.swift new file mode 100644 index 00000000..7882922b --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/ItemFilterBottomSheetReactor.swift @@ -0,0 +1,162 @@ +import Foundation + +import ReactorKit +import RxSwift + +public final class ItemFilterBottomSheetReactor: Reactor { + public enum Route { + case none + case dismiss + case dismissWithSelection([(String, String)]) + case resetFilters + } + + // MARK: - Reactor + public enum Action { + case closeButtonTapped + case filterSelected(indexPath: IndexPath) + case filterDeselected(indexPath: IndexPath) + case changeLevelRange(low: Int?, high: Int?) + case applyButtonTapped([(String, String)]) + case resetFilters + } + + public enum Mutation { + case navigateTo(route: Route) + case setScrolls(selectedIndex: Int?) + case appendSelectedItem(indexPath: IndexPath) + case removeSelectedItem(indexPath: IndexPath) + case setLevelRange(low: Int?, high: Int?) + case resetFilters + } + + public struct State { + var sections: [String] = ["직업/레벨", "무기", "발사체", "방어구", "장신구", "주문서", "기타"] + var jobs: [String] = ["없음", "공용", "마법사", "전사", "궁수", "도적"] + var weapons: [String] = ["한손검", "한손도끼", "한손둔기", "창", "단검", "두손검", "두손도끼", "두손둔기", "폴암", "활", "석궁", "완드", "스태프", "아대"] + var projectiles: [String] = ["화살", "표창"] // 불렛은 제거 + var armors: [String] = ["모자", "상의", "하의", "장갑", "신발", "방패", "전신갑옷"] // 전신 = 전신갑옷? + var accessories: [String] = ["귀고리", "망토", "훈장", "눈장식", "얼굴장식", "팬던트", "벨트", "반지", "어깨장식"] // 귀장식 데이터 안줌 -> 귀장식 = 귀고리, 훈장은 서버에서 데이터 안줌. + @Pulse var scrollCategories: [String] = ["무기 주문서", "방어구 주문서", "기타 주문서"] + var originWeaponScrolls: [String] = ["한손검", "한손도끼", "한손둔기", "단검", "완드", "스태프", "두손검", "두손도끼", "두손둔기", "창", "폴암", "활", "석궁", "아대"] + var originArmorScrolls: [String] = ["투구", "상의", "하의", "전신갑옷", "신발", "장갑", "망토", "방패", "귀장식"] + var originEtcScrolls: [String] = ["펫장비", "연성서", "귀환주문서"] + @Pulse var weaponScrolls: [String] = [] + @Pulse var armorScrolls: [String] = [] + @Pulse var etcScrolls: [String] = [] + var etcItems: [String] = ["마스터리북", "스킬북", "소비", "설치", "이동수단"] + var selectedScrollIndexes: Int? + var selectedItemIndexes: [IndexPath] = [] + var levelRange: (low: Int?, high: Int?) = (nil, nil) + @Pulse var route: Route = .none + } + + // MARK: - properties + public var initialState: State + var disposeBag = DisposeBag() + + // MARK: - init + public init() { + self.initialState = State() + } + + // MARK: - Reactor Methods + public func mutate(action: Action) -> Observable { + switch action { + case .closeButtonTapped: + // 닫기버튼 누르면 선택했던 필터 초기화 해줘야함 + return .concat([Observable.just(.navigateTo(route: .dismiss)), Observable.just(.resetFilters)]) + case .filterSelected(let indexPath): + let section = ItemFilterBottomSheetViewController.FilterSection(rawValue: indexPath.section) + switch section { + case .scrollCategories: + return Observable.just(.setScrolls(selectedIndex: indexPath.row)) + default: + return Observable.just(.appendSelectedItem(indexPath: indexPath)) + } + case .filterDeselected(let indexPath): + let section = ItemFilterBottomSheetViewController.FilterSection(rawValue: indexPath.section) + switch section { + case .scrollCategories: + return Observable.just(.setScrolls(selectedIndex: nil)) + default: + return Observable.just(.removeSelectedItem(indexPath: indexPath)) + } + case .changeLevelRange(low: let low, high: let high): + return Observable.just(.setLevelRange(low: low, high: high)) + case .applyButtonTapped(let selectedItems): + return .just(.navigateTo(route: .dismissWithSelection(selectedItems))) + case .resetFilters: + return Observable.just(.resetFilters) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .navigateTo(let route): + newState.route = route + case .appendSelectedItem(let indexPath): + newState.selectedItemIndexes.insert(indexPath, at: 0) + let selectedWeaponScrollCount = newState.selectedItemIndexes.filter { ItemFilterBottomSheetViewController.FilterSection(rawValue: $0.section) == .weaponScrolls }.count + let selectedArmorScrollCount = newState.selectedItemIndexes.filter { ItemFilterBottomSheetViewController.FilterSection(rawValue: $0.section) == .armorsScrolls }.count + let selectedEtcScrollCount = newState.selectedItemIndexes.filter { ItemFilterBottomSheetViewController.FilterSection(rawValue: $0.section) == .etcScrolls }.count + newState.scrollCategories = [ + "무기 주문서\(selectedWeaponScrollCount == 0 ? "" : " \(selectedWeaponScrollCount)")", + "방어구 주문서\(selectedArmorScrollCount == 0 ? "" : " \(selectedArmorScrollCount)")", + "기타 주문서\(selectedEtcScrollCount == 0 ? "" : " \(selectedEtcScrollCount)")" + ] + case .removeSelectedItem(let indexPath): + if let removeIndex = newState.selectedItemIndexes.firstIndex(of: indexPath) { + newState.selectedItemIndexes.remove(at: removeIndex) + } + let selectedWeaponScrollCount = newState.selectedItemIndexes.filter { ItemFilterBottomSheetViewController.FilterSection(rawValue: $0.section) == .weaponScrolls }.count + let selectedArmorScrollCount = newState.selectedItemIndexes.filter { ItemFilterBottomSheetViewController.FilterSection(rawValue: $0.section) == .armorsScrolls }.count + let selectedEtcScrollCount = newState.selectedItemIndexes.filter { ItemFilterBottomSheetViewController.FilterSection(rawValue: $0.section) == .etcScrolls }.count + newState.scrollCategories = [ + "무기 주문서\(selectedWeaponScrollCount == 0 ? "" : " \(selectedWeaponScrollCount)")", + "방어구 주문서\(selectedArmorScrollCount == 0 ? "" : " \(selectedArmorScrollCount)")", + "기타 주문서\(selectedEtcScrollCount == 0 ? "" : " \(selectedEtcScrollCount)")" + ] + case .setScrolls(let selectedIndex): + newState.selectedScrollIndexes = selectedIndex + switch selectedIndex { + case 0: + newState.weaponScrolls = newState.originWeaponScrolls + newState.armorScrolls = [] + newState.etcScrolls = [] + case 1: + newState.weaponScrolls = [] + newState.armorScrolls = newState.originArmorScrolls + newState.etcScrolls = [] + + case 2: + newState.weaponScrolls = [] + newState.armorScrolls = [] + newState.etcScrolls = newState.originEtcScrolls + default: + newState.weaponScrolls = [] + newState.armorScrolls = [] + newState.etcScrolls = [] + } + case .setLevelRange(low: let low, high: let high): + let levelSection: IndexPath = .init(row: 0, section: ItemFilterBottomSheetViewController.FilterSection.level.rawValue) + if low == 0 && high == 200 { + if let removeIndex = newState.selectedItemIndexes.firstIndex(of: levelSection) { newState.selectedItemIndexes.remove(at: removeIndex) } + } else { + if !newState.selectedItemIndexes.contains(levelSection) { newState.selectedItemIndexes.insert(levelSection, at: 0) } + } + newState.levelRange = (low, high) + case .resetFilters: + newState.selectedItemIndexes = [] + newState.levelRange = (nil, nil) + newState.scrollCategories = ["무기 주문서", "방어구 주문서", "기타 주문서"] + newState.weaponScrolls = [] + newState.armorScrolls = [] + newState.etcScrolls = [] + newState.selectedScrollIndexes = nil + } + return newState + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/ItemFilterBottomSheetViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/ItemFilterBottomSheetViewController.swift new file mode 100644 index 00000000..7b6a0273 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/ItemFilterBottomSheetViewController.swift @@ -0,0 +1,621 @@ +// swiftlint:disable all + +import UIKit + +import MLSCore +import MLSDesignSystem + +import ReactorKit +import RxCocoa +import RxSwift +import SnapKit + +public final class ItemFilterBottomSheetViewController: BaseViewController, View { + public typealias Reactor = ItemFilterBottomSheetReactor + + enum FilterSection: Int, CaseIterable { + case job + case level + case weapons + case projectiles + case armors + case accessories + case scrollCategories + case weaponScrolls + case armorsScrolls + case etcScrolls + case etcItems + + var headerTitle: String? { + switch self { + case .job: return "직업" + case .level: return "레벨" + case .weapons: return "무기" + case .projectiles: return "발사체" + case .armors: return "방어구" + case .accessories: return "장신구" + case .scrollCategories: return "주문서" + case .etcItems: return "기타" + default: return nil + } + } + + @MainActor + var layout: CompositionalSectionBuilder { + switch self { + case .level: return LayoutFactory.getLevelRangeSection() + case .weaponScrolls, .armorsScrolls, .etcScrolls: + return CompositionalSectionBuilder() + .item(width: .fractionalWidth(0.5), height: .absolute(32)) + .group(.horizontal, width: .fractionalWidth(1), height: .estimated(300)) + .interItemSpacing(.fixed(3)) + .buildSection() + .interGroupSpacing(16) + .contentInsets(.init(top: 0, leading: 16, bottom: 32, trailing: 16)) + default: + return LayoutFactory.getItemTagListSection() + } + } + } + + enum FilterItem: Hashable { + case job(String) + case level(low: Int?, upper: Int?) + case weapons(String) + case projectiles(String) + case armors(String) + case accessories(String) + case scrollCategories(String) + case weaponScrolls(String) + case armorScrolls(String) + case etcScrolls(String) + case etcItems(String) + } + + // Diffable Data Source 타입 정의 + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + // MARK: - Properties + public var disposeBag = DisposeBag() + private var isUserScrollDragging: Bool = false + private var dataSource: DataSource! = nil + private var mainView = ItemFilterBottomSheetView() + private let underLineController = TabBarUnderlineController() + + public var onFilterSelected: (([(String, String)]) -> Void)? + + override public init() { + super.init() + } + + @available(*, unavailable) + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +public extension ItemFilterBottomSheetViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // Modal Gesture 제거 + navigationController?.presentationController?.presentedView?.gestureRecognizers?.forEach { $0.isEnabled = false } + presentationController?.presentedView?.gestureRecognizers?.forEach { $0.isEnabled = false } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + guard let reactor = reactor else { return } + + reactor.currentState.selectedItemIndexes.forEach { indexPath in + let sectionCount = mainView.contentCollectionView.numberOfItems(inSection: indexPath.section) + if indexPath.row < sectionCount { + mainView.contentCollectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) + } + + } + guard let selectedIndex = reactor.currentState.selectedScrollIndexes else { return } + // 스크롤 탭(무기/방어구/기타 주문서) UI 선택 상태 복원 + let scrollCategoryIndexPath = IndexPath(row: selectedIndex, section: FilterSection.scrollCategories.rawValue) + mainView.contentCollectionView.selectItem(at: scrollCategoryIndexPath, animated: false, scrollPosition: .centeredHorizontally) + + } +} + +// MARK: - SetUp +private extension ItemFilterBottomSheetViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } + + func configureUI() { + configureCategoryCollectionView() + configureContentCollectionView() + configureSelectedItemCollectionView() + configureDataSource() + applyInitialSnapshot() + } + + func configureCategoryCollectionView() { + mainView.categoryCollectionView.collectionViewLayout = createCategoryLayout() + mainView.categoryCollectionView.dataSource = self + mainView.categoryCollectionView.register(PageTabbarCell.self, forCellWithReuseIdentifier: PageTabbarCell.identifier) + underLineController.configure(with: mainView.categoryCollectionView) + } + + func configureContentCollectionView() { + mainView.contentCollectionView.collectionViewLayout = createContentLayout() + mainView.contentCollectionView.register(TapButtonCell.self, forCellWithReuseIdentifier: TapButtonCell.identifier) + mainView.contentCollectionView.register(FilterLevelSectionCell.self, forCellWithReuseIdentifier: FilterLevelSectionCell.identifier) + mainView.contentCollectionView.register(CheckBoxButtonListSmallCell.self, forCellWithReuseIdentifier: CheckBoxButtonListSmallCell.identifier) + mainView.contentCollectionView.register( + SubTitleBoldHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: SubTitleBoldHeaderView.identifier + ) + } + + func configureSelectedItemCollectionView() { + mainView.selectedItemCollectionView.collectionViewLayout = createSelectedItemLayout() + mainView.selectedItemCollectionView.dataSource = self + mainView.selectedItemCollectionView.register(TagChipCell.self, forCellWithReuseIdentifier: TagChipCell.identifier) + } + + func createCategoryLayout() -> UICollectionViewLayout { + let layout = CompositionalLayoutBuilder() + .section { _ in LayoutFactory.getPageTabbarLayout(underLineController: underLineController) } + .build() + return layout + } + + func createContentLayout() -> UICollectionViewLayout { + let layoutAry = FilterSection.allCases.compactMap { $0.layout.build() } + let layout = CompositionalLayoutBuilder() + .setSections(layoutAry) + .build() + layout.register(Neutral200DividerView.self, forDecorationViewOfKind: Neutral200DividerView.identifier) + return layout + } + + func createSelectedItemLayout() -> UICollectionViewLayout { + let layout = CompositionalLayoutBuilder() + .section { builder in + builder + .item(width: .estimated(100), height: .absolute(32)) + .group(.horizontal, width: .estimated(100), height: .absolute(32)) + .buildSection() + .interGroupSpacing(8) + .contentInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16)) + .orthogonalScrolling(.continuous) + } + .build() + return layout + } + + func configureDataSource() { + dataSource = DataSource(collectionView: mainView.contentCollectionView) { collectionView, indexPath, item in + switch item { + case .job(let title), .weapons(let title), .projectiles(let title), .armors(let title), + .accessories(let title), .scrollCategories(let title), .etcItems(let title): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TapButtonCell.identifier, for: indexPath) as! TapButtonCell + cell.inject(title: title) + return cell + case .weaponScrolls(let title), .armorScrolls(let title), .etcScrolls(let title): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CheckBoxButtonListSmallCell.identifier, for: indexPath) as! CheckBoxButtonListSmallCell + cell.inject(title: title) + return cell + case .level(let low, let upper): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: FilterLevelSectionCell.identifier, for: indexPath) as! FilterLevelSectionCell + cell.inject(input: .init(lowValue: low, highValue: upper)) + guard let reactor = self.reactor else { return UICollectionViewCell() } + let lowValue = cell.levelSectionView.slider.lowerValueObservable + let highValue = cell.levelSectionView.slider.upperValueObservable + Observable.combineLatest(lowValue, highValue) + .map { low, high in + return Reactor.Action.changeLevelRange(low: low.map { Int($0)}, high: high.map { Int($0)}) + } + .bind(to: reactor.action) + .disposed(by: cell.disposeBag) + + // Slider Thumb 동작 시 Scroll 비활성화 + cell.levelSectionView.slider.isThumbTracking + .distinctUntilChanged() + .withUnretained(self) + .subscribe { owner, isTracking in + owner.mainView.contentCollectionView.isScrollEnabled = !isTracking + } + .disposed(by: cell.disposeBag) + return cell + } + } + + dataSource.supplementaryViewProvider = { collectionView, _, indexPath in + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: SubTitleBoldHeaderView.identifier, + for: indexPath + ) as? SubTitleBoldHeaderView + + let section = FilterSection.allCases[indexPath.section] + header?.inject(title: section.headerTitle) + return header + } + } + + private func applyInitialSnapshot() { + var snapshot = Snapshot() + + guard let reactor = reactor else { return } + // 모든 섹션 추가 + snapshot.appendSections(FilterSection.allCases) + + // 섹션별 아이템 추가 + snapshot.appendItems(reactor.currentState.jobs.map { .job($0) }, toSection: .job) + snapshot.appendItems( + [.level( + low: reactor.currentState.levelRange.low, + upper: reactor.currentState.levelRange.high + )], + toSection: .level + ) + snapshot.appendItems(reactor.currentState.weapons.map { .weapons($0) }, toSection: .weapons) + snapshot.appendItems(reactor.currentState.projectiles.map { .projectiles($0) }, toSection: .projectiles) + snapshot.appendItems(reactor.currentState.armors.map { .armors($0) }, toSection: .armors) + snapshot.appendItems(reactor.currentState.accessories.map { .accessories($0) }, toSection: .accessories) + snapshot.appendItems(reactor.currentState.scrollCategories.map { .scrollCategories($0) }, toSection: .scrollCategories) + snapshot.appendItems(reactor.currentState.weaponScrolls.map { .weaponScrolls($0) }, toSection: .weaponScrolls) + snapshot.appendItems(reactor.currentState.armorScrolls.map { .armorScrolls($0) }, toSection: .armorsScrolls) + snapshot.appendItems(reactor.currentState.etcScrolls.map { .etcScrolls($0) }, toSection: .etcScrolls) + snapshot.appendItems(reactor.currentState.etcItems.map { .etcItems($0) }, toSection: .etcItems) + + dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in + self?.mainView.categoryCollectionView.selectItem(at: .init(row: 0, section: 0), animated: false, scrollPosition: .centeredHorizontally) + self?.underLineController.setInitialIndicator() + } + } +} + +// MARK: - Methods +private extension ItemFilterBottomSheetViewController { + func getSelectedScrollCategoryIndexPath() -> IndexPath? { + let selectedScrollCategoryIndexPaths = getSelectedScrollCategoryIndexPaths() + guard let selectedScrollCategoryIndexPath = selectedScrollCategoryIndexPaths.first else { return nil } + return selectedScrollCategoryIndexPath + } + + func getSelectedScrollCategoryIndexPaths() -> [IndexPath] { + let indexPathsForSelectedItems = mainView.contentCollectionView.indexPathsForSelectedItems ?? [] + let selectedScrollCategoryIndexPaths = indexPathsForSelectedItems.filter { $0.section == FilterSection.scrollCategories.rawValue } + return selectedScrollCategoryIndexPaths + } +} + +extension ItemFilterBottomSheetViewController { + public func bind(reactor: Reactor) { + bindUserActions(reactor: reactor) + bindViewState(reactor: reactor) + } + + func bindUserActions(reactor: Reactor) { + mainView.headerView.firstIconButton.rx.tap + .map { Reactor.Action.closeButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.contentCollectionView.rx.itemSelected + .withUnretained(self) + .subscribe { owner, indexPath in + guard let section = FilterSection(rawValue: indexPath.section) else { return } + owner.mainView.contentCollectionView.isScrollEnabled = true + switch section { + case .scrollCategories: + let selectedItems = owner.getSelectedScrollCategoryIndexPaths() + for selectedIndexPath in selectedItems { + if indexPath != selectedIndexPath { owner.mainView.contentCollectionView.deselectItem(at: selectedIndexPath, animated: true) } + } + owner.reactor?.action.onNext(.filterSelected(indexPath: indexPath)) + case .weaponScrolls, .armorsScrolls, .etcScrolls: + let selectedItem = owner.getSelectedScrollCategoryIndexPath() + owner.reactor?.action.onNext(.filterSelected(indexPath: indexPath)) + owner.mainView.contentCollectionView.selectItem(at: selectedItem, animated: false, scrollPosition: .left) + case .level: + owner.mainView.contentCollectionView.deselectItem(at: indexPath, animated: true) + default: + owner.reactor?.action.onNext(.filterSelected(indexPath: indexPath)) + } + owner.view.endEditing(true) + } + .disposed(by: disposeBag) + + mainView.contentCollectionView.rx.itemDeselected + .withUnretained(self) + .subscribe(onNext: { owner, indexPath in + guard let section = FilterSection(rawValue: indexPath.section) else { return } + switch section { + case .weaponScrolls, .armorsScrolls, .etcScrolls: + let selectedItem = owner.getSelectedScrollCategoryIndexPath() + owner.reactor?.action.onNext(.filterDeselected(indexPath: indexPath)) + owner.mainView.contentCollectionView.selectItem(at: selectedItem, animated: false, scrollPosition: .left) + default: + reactor.action.onNext(.filterDeselected(indexPath: indexPath)) + } + }) + .disposed(by: disposeBag) + + mainView.contentCollectionView.rx.didScroll + .withUnretained(self) + .subscribe { owner, _ in + guard owner.isUserScrollDragging else { return } + let visibleIndexPaths = owner.mainView.contentCollectionView.indexPathsForVisibleItems + guard let topIndexPath = visibleIndexPaths.sorted(by: { $0.section < $1.section }).first, + let sectionMaxCount = owner.reactor?.currentState.sections.count else { return } + var currentSection: Int = sectionMaxCount - 1 + if topIndexPath.section < sectionMaxCount { currentSection = topIndexPath.section == 0 ? 0 : topIndexPath.section - 1 } + owner.mainView.categoryCollectionView.selectItem( + at: .init(row: currentSection, section: 0), + animated: true, + scrollPosition: .centeredHorizontally + ) + owner.underLineController.animateIndicatorToSelectedItem() + } + .disposed(by: disposeBag) + + mainView.contentCollectionView.rx.willBeginDragging + .withUnretained(self) + .subscribe { owner, _ in + owner.isUserScrollDragging = true + } + .disposed(by: disposeBag) + + mainView.contentCollectionView.rx.didEndDecelerating + .withUnretained(self) + .subscribe { owner, _ in + owner.isUserScrollDragging = false + } + .disposed(by: disposeBag) + + mainView.categoryCollectionView.rx.itemSelected + .withUnretained(self) + .subscribe { owner, indexPath in + owner.mainView.categoryCollectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) + let section = indexPath.row == 6 ? 10 : indexPath.row == 0 ? 0 : indexPath.row + 1 + owner.underLineController.animateIndicatorToSelectedItem() + owner.mainView.contentCollectionView.scrollToItem(at: .init(row: 0, section: section), at: .top, animated: true) + } + .disposed(by: disposeBag) + + let tapGesture = UITapGestureRecognizer() + tapGesture.cancelsTouchesInView = false + mainView.contentCollectionView.addGestureRecognizer(tapGesture) + + tapGesture.rx.event + .bind { [weak self] _ in + self?.view.endEditing(true) + } + .disposed(by: disposeBag) + + mainView.clearButton.rx.tap + .withUnretained(self) + .subscribe(onNext: { owner, _ in + // Reactor에 액션 전달 + owner.reactor?.action.onNext(.resetFilters) + + if let cell = owner.mainView.contentCollectionView.cellForItem(at: IndexPath(row: 0, section: 1)) as? FilterLevelSectionCell { + cell.levelSectionView.slider.lowerValue = 0 + cell.levelSectionView.slider.upperValue = 200 + } + // UI에서 직접 deselect + owner.mainView.contentCollectionView.indexPathsForSelectedItems?.forEach { + owner.mainView.contentCollectionView.deselectItem(at: $0, animated: false) + } + }) + .disposed(by: disposeBag) + + mainView.headerView.firstIconButton.rx.tap + .map { Reactor.Action.closeButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.applyButton.rx.tap + .withUnretained(self) + .compactMap { _, _ in + + let state = reactor.currentState + + var selectedItems: [(String, String)] = [] + + for indexPath in state.selectedItemIndexes { + guard let section = ItemFilterBottomSheetViewController.FilterSection(rawValue: indexPath.section) else { continue } + + switch section { + case .level: + selectedItems.append(("레벨", "레벨 \(state.levelRange.low) ~ \(state.levelRange.high)")) + case .job: + selectedItems.append(("직업", state.jobs[indexPath.row])) + case .weapons: + selectedItems.append(("무기", state.weapons[indexPath.row])) + case .projectiles: + selectedItems.append(("발사체", state.projectiles[indexPath.row])) + case .armors: + selectedItems.append(("방어구", state.armors[indexPath.row])) + case .accessories: + selectedItems.append(("장신구", state.accessories[indexPath.row])) + case .scrollCategories: + selectedItems.append(("주문서", state.scrollCategories[indexPath.row])) + case .weaponScrolls: + // 기존 방식대로 하게 되면 주문서 탭 변경시 각 scrolls가 빈배열 처리되서 오류 + selectedItems.append(("무기주문서", state.originWeaponScrolls[indexPath.row])) + case .armorsScrolls: + selectedItems.append(("방어구주문서", state.originArmorScrolls[indexPath.row])) + case .etcScrolls: + selectedItems.append(("기타주문서", state.originEtcScrolls[indexPath.row])) + case .etcItems: + selectedItems.append(("기타아이템", state.etcItems[indexPath.row])) + } + } + return Reactor.Action.applyButtonTapped(selectedItems) + } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + func bindViewState(reactor: Reactor) { + reactor.state + .map { (scrollTypes: $0.scrollCategories, weaponScrolls: $0.weaponScrolls, armorScrolls: $0.armorScrolls, etcScrolls: $0.etcScrolls) } + .distinctUntilChanged { $0 == $1 } + .skip(1) + .withUnretained(self) + .subscribe { owner, scrolls in + guard let dataSource = owner.dataSource else { return } + var snapshot = dataSource.snapshot() + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .scrollCategories)) + snapshot.appendItems(scrolls.scrollTypes.map { .scrollCategories($0) }, toSection: .scrollCategories) + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .weaponScrolls)) + snapshot.appendItems(scrolls.weaponScrolls.map { .weaponScrolls($0) }, toSection: .weaponScrolls) + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .armorsScrolls)) + snapshot.appendItems(scrolls.armorScrolls.map { .armorScrolls($0) }, toSection: .armorsScrolls) + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .etcScrolls)) + snapshot.appendItems(scrolls.etcScrolls.map { .etcScrolls($0) }, toSection: .etcScrolls) + owner.dataSource.apply(snapshot, animatingDifferences: false) { + + guard let selectedItem = owner.getSelectedScrollCategoryIndexPath() else { return } + var targetIndexPath: [IndexPath] = [] + switch selectedItem.row { + case 0: + targetIndexPath = owner.reactor?.currentState.selectedItemIndexes.filter { FilterSection(rawValue: $0.section) == .weaponScrolls } ?? [] + case 1: + targetIndexPath = owner.reactor?.currentState.selectedItemIndexes.filter { FilterSection(rawValue: $0.section) == .armorsScrolls } ?? [] + case 2: + targetIndexPath = owner.reactor?.currentState.selectedItemIndexes.filter { FilterSection(rawValue: $0.section) == .etcScrolls } ?? [] + default: + break + } + for target in targetIndexPath { + let count = owner.mainView.contentCollectionView.numberOfItems(inSection: target.section) + if count > target.row { + owner.mainView.contentCollectionView.selectItem(at: target, animated: true, scrollPosition: .left) + } + } + + } + } + .disposed(by: disposeBag) + + reactor.state + .map { $0.selectedItemIndexes } + .withUnretained(self) + .subscribe { owner, indexPaths in + owner.mainView.selectedItemCollectionView.isHidden = indexPaths.isEmpty + owner.mainView.selectedItemCollectionView.reloadData() + } + .disposed(by: disposeBag) + rx.viewDidLoad + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .subscribe { [weak self] route in + guard let self = self else { return } + switch route { + case .dismiss: + self.dismiss(animated: true) + case .dismissWithSelection(let selectedItems): + self.onFilterSelected?(selectedItems) + self.dismiss(animated: true) + default: + break + } + } + .disposed(by: disposeBag) + } +} + +extension ItemFilterBottomSheetViewController: UICollectionViewDataSource { + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard let reactor = reactor else { return 0 } + if collectionView == mainView.categoryCollectionView { + return reactor.currentState.sections.count + } else { + return reactor.currentState.selectedItemIndexes.count + } + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let reactor = reactor else { return UICollectionViewCell() } + if collectionView == mainView.categoryCollectionView { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PageTabbarCell.identifier, for: indexPath) as! PageTabbarCell + let title = reactor.currentState.sections[indexPath.row] + cell.inject(title: title) + return cell + } else { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TagChipCell.identifier, for: indexPath) as! TagChipCell + let titles = reactor.currentState.selectedItemIndexes.map { indexPath -> String in + guard let section = ItemFilterBottomSheetViewController.FilterSection(rawValue: indexPath.section) else { return "" } + switch section { + case .job: + return reactor.currentState.jobs[indexPath.row] + case .weapons: + return reactor.currentState.weapons[indexPath.row] + case .projectiles: + return reactor.currentState.projectiles[indexPath.row] + case .armors: + return reactor.currentState.armors[indexPath.row] + case .accessories: + return reactor.currentState.accessories[indexPath.row] + case .weaponScrolls: + return reactor.currentState.originWeaponScrolls[indexPath.row] + case .armorsScrolls: + return reactor.currentState.originArmorScrolls[indexPath.row] + case .etcScrolls: + return reactor.currentState.originEtcScrolls[indexPath.row] + case .etcItems: + return reactor.currentState.etcItems[indexPath.row] + case .level: + let range = reactor.currentState.levelRange + return "\(range.low ?? 0) ~ \(range.high ?? 200)" + default: + return "" + } + } + cell.inject(title: titles[indexPath.row], style: .normal) + cell.button.cancelButton.rx.tap + .withUnretained(self) + .subscribe { owner, _ in + let deselectedIndex = reactor.currentState.selectedItemIndexes[indexPath.row] + let section = FilterSection(rawValue: deselectedIndex.section) + switch section { + case .level: + if let cell = owner.mainView.contentCollectionView.cellForItem(at: deselectedIndex) as? FilterLevelSectionCell { + cell.levelSectionView.slider.lowerValue = 0 + cell.levelSectionView.slider.upperValue = 200 + } + owner.reactor?.action.onNext(.filterDeselected(indexPath: deselectedIndex)) + case .weaponScrolls, .armorsScrolls, .etcScrolls: + let selectedItem = owner.getSelectedScrollCategoryIndexPath() + owner.reactor?.action.onNext(.filterDeselected(indexPath: deselectedIndex)) + owner.mainView.contentCollectionView.deselectItem(at: deselectedIndex, animated: false) + owner.mainView.contentCollectionView.selectItem(at: selectedItem, animated: false, scrollPosition: .left) + default: + owner.mainView.contentCollectionView.deselectItem(at: deselectedIndex, animated: true) + owner.reactor?.action.onNext(.filterDeselected(indexPath: deselectedIndex)) + } + } + .disposed(by: cell.disposeBag) + return cell + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/FilterLevelSectionCell.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/FilterLevelSectionCell.swift new file mode 100644 index 00000000..02f842b0 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/FilterLevelSectionCell.swift @@ -0,0 +1,59 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +public class FilterLevelSectionCell: UICollectionViewCell { + public let levelSectionView: FilterLevelSectionView = { + let view = FilterLevelSectionView() + return view + }() + + public var disposeBag = DisposeBag() + + override init(frame: CGRect) { + super.init(frame: frame) + addViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func prepareForReuse() { + super.prepareForReuse() + disposeBag = DisposeBag() + levelSectionView.disposeBag = DisposeBag() + levelSectionView.bind() + } +} + +// MARK: - SetUp +private extension FilterLevelSectionCell { + func addViews() { + contentView.addSubview(levelSectionView) + } + + func setupConstraints() { + levelSectionView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } +} + +extension FilterLevelSectionCell { + struct Input { + let lowValue: Int? + let highValue: Int? + } + + func inject(input: Input) { + levelSectionView.slider.lowerValue = input.lowValue.map { CGFloat($0) } + levelSectionView.slider.upperValue = input.highValue.map { CGFloat($0) } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/FilterLevelSectionView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/FilterLevelSectionView.swift new file mode 100644 index 00000000..0a343338 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/FilterLevelSectionView.swift @@ -0,0 +1,193 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +public class FilterLevelSectionView: UIView { + private enum Constant { + static let inputBoxWidth: CGFloat = (UIScreen.main.bounds.width - (16 * 2) - dashWidth - (stackViewSpacing * 2)) / 2 + static let dashWidth: CGFloat = 7 + static let stackViewSpacing: CGFloat = 6 + static let sliderTopOffSet: CGFloat = 20 + static let sliderBottomMargin: CGFloat = 12 + static let sliderHeight: CGFloat = 26 + } + + private var isEdit = false + + let leftInputBox: InputBox = { + let box = InputBox(label: "범위", placeHodler: "0") + box.textField.keyboardType = .numberPad + return box + }() + + private let dashLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .b_m_r, text: "-") + return label + }() + + let rightInputBox: InputBox = { + let box = InputBox(placeHodler: "200") + box.textField.keyboardType = .numberPad + return box + }() + + private let stackView: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.alignment = .bottom + view.spacing = Constant.stackViewSpacing + return view + }() + + public let slider: FilterSlider + + private let lowerLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .b_s_r, text: "0", color: .neutral500) + return label + }() + + private let middleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .b_s_r, text: "100", color: .neutral500) + return label + }() + + private let upperLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .b_s_r, text: "200", color: .neutral500) + return label + }() + + public var disposeBag = DisposeBag() + + public init(initialLowerValue: CGFloat = 0, initialUpperValue: CGFloat = 200) { + self.slider = FilterSlider(minimumValue: 0, maximumValue: 200, initialLowerValue: initialLowerValue, initialUpperValue: initialUpperValue) + super.init(frame: .zero) + addViews() + setupConstraints() + bind() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - SetUp +private extension FilterLevelSectionView { + func addViews() { + addSubview(stackView) + + stackView.addArrangedSubview(leftInputBox) + stackView.addArrangedSubview(dashLabel) + stackView.addArrangedSubview(rightInputBox) + addSubview(slider) + addSubview(lowerLabel) + addSubview(middleLabel) + addSubview(upperLabel) + } + + func setupConstraints() { + stackView.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + } + leftInputBox.snp.makeConstraints { make in + make.width.equalTo(Constant.inputBoxWidth) + } + rightInputBox.snp.makeConstraints { make in + make.width.equalTo(Constant.inputBoxWidth) + } + dashLabel.snp.makeConstraints { make in + make.height.equalTo(rightInputBox.snp.height) + make.width.equalTo(Constant.dashWidth) + } + slider.snp.makeConstraints { make in + make.top.equalTo(stackView.snp.bottom).offset(Constant.sliderTopOffSet) + make.height.equalTo(Constant.sliderHeight) + make.horizontalEdges.equalToSuperview() + } + lowerLabel.snp.makeConstraints { make in + make.top.equalTo(slider.snp.bottom).offset(Constant.sliderBottomMargin) + make.bottom.leading.equalToSuperview() + } + middleLabel.snp.makeConstraints { make in + make.top.equalTo(slider.snp.bottom).offset(Constant.sliderBottomMargin) + make.bottom.centerX.equalToSuperview() + } + upperLabel.snp.makeConstraints { make in + make.top.equalTo(slider.snp.bottom).offset(Constant.sliderBottomMargin) + make.bottom.trailing.equalToSuperview() + } + } +} + +public extension FilterLevelSectionView { + func bind() { + slider.lowerValueObservable + .withUnretained(self) + .subscribe { owner, value in + guard !owner.isEdit else { return } + let lowValue = Int(value ?? 0) + owner.leftInputBox.textField.text = value == owner.slider.minimumValue ? nil : "\(lowValue)" + } + .disposed(by: disposeBag) + + slider.upperValueObservable + .withUnretained(self) + .subscribe { owner, value in + guard !owner.isEdit else { return } + let upperValue = Int(value ?? 200) + owner.rightInputBox.textField.text = value == owner.slider.maximumValue ? nil : "\(upperValue)" + } + .disposed(by: disposeBag) + + leftInputBox.textField.rx.text.orEmpty + .debounce(.milliseconds(100), scheduler: MainScheduler.instance) + .withUnretained(self) + .subscribe { owner, text in + guard !owner.isEdit, !text.isEmpty else { return } + if let value = Double(text) { + owner.slider.lowerValue = value + } + } + .disposed(by: disposeBag) + + rightInputBox.textField.rx.text.orEmpty + .debounce(.milliseconds(100), scheduler: MainScheduler.instance) + .withUnretained(self) + .subscribe { owner, text in + guard !owner.isEdit, !text.isEmpty else { return } + if let value = Double(text) { + owner.slider.upperValue = value + } + } + .disposed(by: disposeBag) + + let leftBoxDidEnd = leftInputBox.textField.rx.controlEvent(.editingDidEnd).asObservable() + let rightBoxDidEnd = rightInputBox.textField.rx.controlEvent(.editingDidEnd).asObservable() + + Observable.merge([leftBoxDidEnd, rightBoxDidEnd]) + .withUnretained(self) + .debounce(.seconds(1), scheduler: MainScheduler.instance) + .subscribe { owner, _ in + if let leftValue = Double(owner.leftInputBox.textField.text ?? "1"), let rightValue = Double(owner.rightInputBox.textField.text ?? "200") { + if leftValue > rightValue { + owner.isEdit = true + owner.leftInputBox.textField.text = "\(Int(rightValue))" + owner.slider.lowerValue = rightValue + owner.rightInputBox.textField.text = "\(Int(leftValue))" + owner.slider.upperValue = leftValue + owner.isEdit = false + } + } + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/FilterSlider.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/FilterSlider.swift new file mode 100644 index 00000000..c43bac3d --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/FilterSlider.swift @@ -0,0 +1,293 @@ +import UIKit + +import RxCocoa +import RxSwift +import SnapKit + +public class FilterSlider: UIControl { + // MARK: - Constants + private enum Constant { + static let thumbSize: CGFloat = 26 + static let trackHeight: CGFloat = 8 + static let trackCornerRadius: CGFloat = 4 + static let thumbCornerRadius: CGFloat = thumbSize / 2 + static let thumbBorderWidth: CGFloat = 1 + static let thumbShadowOpacity: Float = 1 + static let thumbShadowRadius: CGFloat = 10 + static let thumbShadowOffset = CGSize(width: 4, height: 4) + static let expandedTouchInset: CGFloat = -10 + static let animationDuration: TimeInterval = 0.25 + } + + // MARK: - Value Relays + private let minimumValueRelay = BehaviorRelay(value: 0) + private let maximumValueRelay = BehaviorRelay(value: 200) + private let lowerValueRelay = BehaviorRelay(value: nil) + private let upperValueRelay = BehaviorRelay(value: nil) + + // MARK: - Public properties + public var minimumValue: CGFloat { + get { minimumValueRelay.value } + set { minimumValueRelay.accept(newValue) } + } + + public var maximumValue: CGFloat { + get { maximumValueRelay.value } + set { maximumValueRelay.accept(newValue) } + } + + public var lowerValue: CGFloat? { + get { lowerValueRelay.value } + set { lowerValueRelay.accept(boundValue(newValue ?? minimumValue, lower: minimumValue, upper: maximumValue)) } + } + + public var upperValue: CGFloat? { + get { upperValueRelay.value } + set { upperValueRelay.accept(boundValue(newValue ?? maximumValue, lower: minimumValue, upper: maximumValue)) } + } + + public let isThumbTracking: BehaviorRelay = .init(value: false) + + // MARK: - Observables + public var minimumValueObservable: Observable { minimumValueRelay.asObservable() } + public var maximumValueObservable: Observable { maximumValueRelay.asObservable() } + public var lowerValueObservable: Observable { lowerValueRelay.asObservable() } + public var upperValueObservable: Observable { upperValueRelay.asObservable() } + + // MARK: - UI Elements + private let trackView = UIView() + private let selectedTrackView = UIView() + private let lowerThumb = UIView() + private let upperThumb = UIView() + + private var previousLocation = CGPoint.zero + + private var selectedTrackLeadingConstraint: Constraint? + private var selectedTrackTrailingConstraint: Constraint? + private var lowerThumbCenterX: Constraint? + private var upperThumbCenterX: Constraint? + + private enum Thumb { + case lower, upper, none + } + + private var activeThumb: Thumb = .none + + private let disposeBag = DisposeBag() + + // MARK: - Init + public init( + minimumValue: CGFloat, + maximumValue: CGFloat, + initialLowerValue: CGFloat, + initialUpperValue: CGFloat + ) { + super.init(frame: .zero) + + minimumValueRelay.accept(minimumValue) + maximumValueRelay.accept(maximumValue) + + lowerValueRelay.accept(boundValue(initialLowerValue, lower: minimumValue, upper: maximumValue)) + upperValueRelay.accept(boundValue(initialUpperValue, lower: minimumValue, upper: maximumValue)) + + setup() + bindValues() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + bindValues() + } + + // MARK: - Setup UI + private func setup() { + trackView.backgroundColor = .neutral200 + trackView.layer.cornerRadius = Constant.trackCornerRadius + addSubview(trackView) + trackView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview() + make.centerY.equalToSuperview() + make.height.equalTo(Constant.trackHeight) + } + + selectedTrackView.backgroundColor = .primary700 + selectedTrackView.layer.cornerRadius = Constant.trackCornerRadius + addSubview(selectedTrackView) + selectedTrackView.snp.makeConstraints { make in + make.centerY.equalTo(trackView) + make.height.equalTo(Constant.trackHeight) + selectedTrackLeadingConstraint = make.leading.equalToSuperview().constraint + selectedTrackTrailingConstraint = make.trailing.equalToSuperview().constraint + } + + [lowerThumb, upperThumb].forEach { + $0.backgroundColor = .whiteMLS + $0.layer.borderColor = UIColor.neutral200.cgColor + $0.layer.borderWidth = Constant.thumbBorderWidth + $0.layer.cornerRadius = Constant.thumbCornerRadius + $0.isUserInteractionEnabled = false + $0.layer.shadowColor = UIColor.black.withAlphaComponent(0.12).cgColor + $0.layer.shadowOpacity = Constant.thumbShadowOpacity + $0.layer.shadowRadius = Constant.thumbShadowRadius + $0.layer.shadowOffset = Constant.thumbShadowOffset + addSubview($0) + $0.snp.makeConstraints { make in + make.width.height.equalTo(Constant.thumbSize) + make.centerY.equalTo(trackView) + } + } + + lowerThumb.snp.makeConstraints { make in + lowerThumbCenterX = make.centerX.equalToSuperview().constraint + } + upperThumb.snp.makeConstraints { make in + upperThumbCenterX = make.centerX.equalToSuperview().constraint + } + } + + // MARK: - Bindings + private func bindValues() { + Observable.combineLatest(minimumValueRelay, maximumValueRelay) + .subscribe(onNext: { [weak self] minVal, maxVal in + guard let self = self else { return } + let clampedLower = self.boundValue(self.lowerValueRelay.value ?? self.minimumValue, lower: minVal, upper: maxVal) + let clampedUpper = self.boundValue(self.upperValueRelay.value ?? self.maximumValue, lower: minVal, upper: maxVal) + if clampedLower != self.lowerValueRelay.value { + self.lowerValueRelay.accept(clampedLower) + } + if clampedUpper != self.upperValueRelay.value { + self.upperValueRelay.accept(clampedUpper) + } + self.animateUpdate() + }) + .disposed(by: disposeBag) + + Observable.merge(lowerValueRelay.asObservable(), upperValueRelay.asObservable()) + .subscribe(onNext: { [weak self] _ in + self?.animateUpdate() + self?.sendActions(for: .valueChanged) + }) + .disposed(by: disposeBag) + } + + // MARK: - Layout + override public func layoutSubviews() { + super.layoutSubviews() + updateTrackAndThumb(animated: false) + lowerThumb.frame = lowerThumb.frame.integral + upperThumb.frame = upperThumb.frame.integral + } + + private func position(for value: CGFloat) -> CGFloat { + guard maximumValue != minimumValue else { return Constant.thumbSize / 2 } + let availableWidth = bounds.width - Constant.thumbSize + return (value - minimumValue) / (maximumValue - minimumValue) * availableWidth + Constant.thumbSize / 2 + } + + private func updateTrackAndThumb(animated: Bool) { + let lowerX = position(for: lowerValueRelay.value ?? minimumValue) + let upperX = position(for: upperValueRelay.value ?? maximumValue) + + lowerThumbCenterX?.update(offset: lowerX - bounds.midX) + upperThumbCenterX?.update(offset: upperX - bounds.midX) + + let minX = min(lowerX, upperX) + let maxX = max(lowerX, upperX) + + selectedTrackLeadingConstraint?.update(offset: minX) + selectedTrackTrailingConstraint?.update(offset: -(bounds.width - maxX)) + + if animated { + UIView.animate(withDuration: Constant.animationDuration, delay: 0, options: [.curveEaseInOut]) { + self.layoutIfNeeded() + } + } else { + layoutIfNeeded() + } + } + + private func animateUpdate() { + updateTrackAndThumb(animated: true) + } + + private func boundValue(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { + return min(max(value, lower), upper) + } + + // MARK: - Touch Handling + override public func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + isThumbTracking.accept(true) + previousLocation = touch.location(in: self) + + if lowerThumb.frame.insetBy(dx: Constant.expandedTouchInset, dy: Constant.expandedTouchInset).contains(previousLocation) { + activeThumb = .lower + } else if upperThumb.frame.insetBy(dx: Constant.expandedTouchInset, dy: Constant.expandedTouchInset).contains(previousLocation) { + activeThumb = .upper + } else { + activeThumb = .none + } + + return activeThumb != .none + } + + override public func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + isThumbTracking.accept(true) + let location = touch.location(in: self) + let deltaLocation = location.x - previousLocation.x + let deltaValue = (maximumValue - minimumValue) * deltaLocation / (bounds.width - Constant.thumbSize) + previousLocation = location + + var newLower = lowerValueRelay.value ?? minimumValue + var newUpper = upperValueRelay.value ?? maximumValue + + switch activeThumb { + case .lower: + newLower += deltaValue + case .upper: + newUpper += deltaValue + case .none: + break + } + + newLower = boundValue(newLower, lower: minimumValue, upper: maximumValue) + newUpper = boundValue(newUpper, lower: minimumValue, upper: maximumValue) + + if newLower > newUpper { + lowerValueRelay.accept(newUpper) + upperValueRelay.accept(newLower) + activeThumb = (activeThumb == .lower) ? .upper : .lower + } else { + lowerValueRelay.accept(newLower) + upperValueRelay.accept(newUpper) + } + + return true + } + + override public func endTracking(_ touch: UITouch?, with event: UIEvent?) { + isThumbTracking.accept(false) + activeThumb = .none + } + + // MARK: - Touch Hit Test + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if lowerThumb.frame.insetBy(dx: Constant.expandedTouchInset, dy: Constant.expandedTouchInset).contains(point) { + return self + } + + if upperThumb.frame.insetBy(dx: Constant.expandedTouchInset, dy: Constant.expandedTouchInset).contains(point) { + return self + } + + return super.hitTest(point, with: event) + } +} + +public extension FilterSlider { + func reset(lower: CGFloat, upper: CGFloat) { + lowerValueRelay.accept(boundValue(lower, lower: minimumValue, upper: maximumValue)) + upperValueRelay.accept(boundValue(upper, lower: minimumValue, upper: maximumValue)) + animateUpdate() + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/ItemFilterBottomSheetView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/ItemFilterBottomSheetView.swift new file mode 100644 index 00000000..7bf2fa70 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/ItemFilterBottomSheet/Views/ItemFilterBottomSheetView.swift @@ -0,0 +1,154 @@ +import UIKit + +import MLSDesignSystem + +import SnapKit + +final class ItemFilterBottomSheetView: UIView { + private enum Constant { + static let horizontalInset: CGFloat = 16 + static let buttonSpacing: CGFloat = 8 + static let buttonSuperViewSize = UIScreen.main.bounds.width - (Constant.horizontalInset * 2) - buttonSpacing + static let buttonStackViewTopMargin: CGFloat = 12 + static let buttonStackViewBottomMargin: CGFloat = 16 + static let collectionViewTopOffset = 8 + static let categoryCollectionViewHeight = 40 + static let dividerHeight = 1 + static let selectedItemCollectionViewHeight = 56 + static let contentCollectionViewTopMargin = 32 + } + + // MARK: - Properties + let headerView: Header = .init(style: .filter, title: "필터") + + private let toolBarStackView: UIStackView = { + let view = UIStackView() + view.spacing = 0 + view.axis = .vertical + return view + }() + + private let buttonStackView: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.spacing = 8 + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = .init( + top: Constant.buttonStackViewTopMargin, + left: Constant.horizontalInset, + bottom: Constant.buttonStackViewBottomMargin, + right: Constant.horizontalInset + ) + + return view + }() + + public let clearButton: CommonButton = { + let button = CommonButton(style: .border, title: "초기화", disabledTitle: nil) + return button + }() + + public let applyButton: CommonButton = { + let button = CommonButton(style: .normal, title: "적용하기", disabledTitle: nil) + return button + }() + + private let buttonStackViewDividerView: UIView = { + let view = UIView() + view.backgroundColor = .neutral200 + return view + }() + + public let categoryCollectionView: UICollectionView = { + let view = UICollectionView(frame: .zero, collectionViewLayout: .init()) + view.isScrollEnabled = false + return view + }() + + public let contentCollectionView: UICollectionView = { + let view = UICollectionView(frame: .zero, collectionViewLayout: .init()) + view.contentInset = .init(top: 0, left: 0, bottom: 40, right: 0) + view.allowsMultipleSelection = true + return view + }() + + public let selectedItemCollectionView: UICollectionView = { + let view = UICollectionView(frame: .zero, collectionViewLayout: .init()) + view.alwaysBounceVertical = false + view.isHidden = true + return view + }() + + private let selectedItemDividerView: UIView = { + let view = UIView() + view.backgroundColor = .neutral200 + return view + }() + + // MARK: - init + init() { + super.init(frame: .zero) + + addViews() + setupConstraints() + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension ItemFilterBottomSheetView { + func addViews() { + addSubview(headerView) + addSubview(toolBarStackView) + addSubview(categoryCollectionView) + addSubview(contentCollectionView) + toolBarStackView.addArrangedSubview(selectedItemCollectionView) + toolBarStackView.addArrangedSubview(buttonStackView) + buttonStackView.addArrangedSubview(clearButton) + buttonStackView.addArrangedSubview(applyButton) + buttonStackView.addSubview(buttonStackViewDividerView) + toolBarStackView.addSubview(selectedItemDividerView) + } + + func setupConstraints() { + headerView.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.horizontalInset) + make.horizontalEdges.equalToSuperview() + } + toolBarStackView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview() + make.bottom.equalToSuperview() + } + clearButton.snp.makeConstraints { make in + make.width.equalTo(Constant.buttonSuperViewSize * 0.3) + } + categoryCollectionView.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom).offset(Constant.collectionViewTopOffset) + make.horizontalEdges.equalToSuperview() + make.height.equalTo(Constant.categoryCollectionViewHeight) + } + contentCollectionView.snp.makeConstraints { make in + make.top.equalTo(categoryCollectionView.snp.bottom).offset(Constant.contentCollectionViewTopMargin) + make.horizontalEdges.equalToSuperview() + make.bottom.equalTo(toolBarStackView.snp.top) + } + buttonStackViewDividerView.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + make.height.equalTo(Constant.dividerHeight) + } + selectedItemCollectionView.snp.makeConstraints { make in + make.height.equalTo(Constant.selectedItemCollectionViewHeight) + } + selectedItemDividerView.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + make.height.equalTo(Constant.dividerHeight) + } + } + + func configureUI() {} +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetFactoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetFactoryImpl.swift new file mode 100644 index 00000000..e4ba006e --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetFactoryImpl.swift @@ -0,0 +1,19 @@ +import Foundation + +import MLSCore +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +public struct MonsterFilterBottomSheetFactoryImpl: MonsterFilterBottomSheetFactory { + public init() {} + + public func make(startLevel: Int, endLevel: Int, onFilterSelected: @escaping (Int, Int) -> Void) -> BaseViewController & ModalPresentable { + let viewController = MonsterFilterBottomSheetViewController() + viewController.startLevel = CGFloat(startLevel) + viewController.endLevel = CGFloat(endLevel) + viewController.reactor = MonsterFilterBottomSheetReactor(startLevel: startLevel, endLevel: endLevel) + viewController.onFilterSelected = onFilterSelected + + return viewController + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetReactor.swift new file mode 100644 index 00000000..a3d63887 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetReactor.swift @@ -0,0 +1,69 @@ +import ReactorKit +import RxSwift + +public final class MonsterFilterBottomSheetReactor: Reactor { + public enum Route { + case none + case dismiss + case dismissWithLevelRange(start: Int, end: Int) + case clear + } + + // MARK: - Reactor + public enum Action { + case cancelButtonTapped + case applyButtonTapped(start: Int, end: Int) + case clearButtonTapped + } + + public enum Mutation { + case navigateTo(route: Route) + } + + public struct State { + @Pulse var route: Route = .none + } + + // MARK: - properties + public var initialState: State + var disposeBag = DisposeBag() + + // MARK: - init + public init(startLevel: Int = 1, endLevel: Int = 200) { + self.initialState = State() + } + + // MARK: - Reactor Methods + public func mutate(action: Action) -> Observable { + switch action { + case .cancelButtonTapped: + return Observable.just(.navigateTo(route: .dismiss)) + case let .applyButtonTapped(start, end): + guard start <= end else { + return .empty() + } + + return .just(.navigateTo(route: .dismissWithLevelRange(start: start, end: end))) + case .clearButtonTapped: + return .just(.navigateTo(route: .clear)) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .navigateTo(let route): + switch route { + case .dismiss: + newState.route = route + case .dismissWithLevelRange: + newState.route = route + default: + newState.route = route + } + } + + return newState + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetView.swift new file mode 100644 index 00000000..e4fc57a2 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetView.swift @@ -0,0 +1,121 @@ +import UIKit + +import MLSDesignSystem + +import SnapKit + +public class MonsterFilterBottomSheetView: UIView { + private enum Constant { + static let horizontalInset: CGFloat = 16 + static let buttonSpacing: CGFloat = 8 + static let buttonSuperViewSize = UIScreen.main.bounds.width - (Constant.horizontalInset * 2) - buttonSpacing + static let buttonStackViewTopMargin: CGFloat = 12 + static let buttonStackViewBottomMargin: CGFloat = 16 + static let dividerHeight = 1 + static let itemBottomSpacing = 31 + } + + // MARK: - Properties + let tapGesture = UITapGestureRecognizer() + var lowerLevel: CGFloat + var upperLevel: CGFloat + + // MARK: - Components + let header: Header = { + let header = Header(style: .filter, title: "필터") + return header + }() + + private let sectionTitleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .sub_m_b, text: "레벨", alignment: .left) + return label + }() + + let levelRangeView: FilterLevelSectionView + + private let dividerView: UIView = { + let view = UIView() + view.backgroundColor = .neutral200 + return view + }() + + private let buttonStackView: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.spacing = 8 + return view + }() + + public let clearButton: CommonButton = { + let button = CommonButton(style: .border, title: "초기화", disabledTitle: nil) + return button + }() + + public let applyButton: CommonButton = { + let button = CommonButton(style: .normal, title: "적용하기", disabledTitle: nil) + return button + }() + + // MARK: - init + init(lowerLevel: CGFloat, upperLevel: CGFloat) { + self.lowerLevel = lowerLevel + self.upperLevel = upperLevel + self.levelRangeView = FilterLevelSectionView(initialLowerValue: lowerLevel, initialUpperValue: upperLevel) + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension MonsterFilterBottomSheetView { + func addViews() { + addSubview(header) + addSubview(sectionTitleLabel) + addSubview(levelRangeView) + addSubview(dividerView) + addSubview(buttonStackView) + buttonStackView.addArrangedSubview(clearButton) + buttonStackView.addArrangedSubview(applyButton) + } + + func setupConstraints() { + header.snp.makeConstraints { make in + make.top.equalToSuperview().inset(16) + make.horizontalEdges.equalToSuperview() + } + sectionTitleLabel.snp.makeConstraints { make in + make.top.equalTo(header.snp.bottom).offset(Constant.itemBottomSpacing) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + } + levelRangeView.snp.makeConstraints { make in + make.top.equalTo(sectionTitleLabel.snp.bottom).offset(10) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + } + dividerView.snp.makeConstraints { make in + make.top.equalTo(levelRangeView.snp.bottom).offset(Constant.itemBottomSpacing) + make.height.equalTo(Constant.dividerHeight) + make.horizontalEdges.equalToSuperview() + } + buttonStackView.snp.makeConstraints { make in + make.top.equalTo(dividerView.snp.top).offset(Constant.buttonStackViewTopMargin) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalInset) + make.bottom.equalToSuperview().inset(Constant.buttonStackViewBottomMargin) + } + clearButton.snp.makeConstraints { make in + make.width.equalTo(Constant.buttonSuperViewSize * 0.3) + } + } + + func configureUI() { + tapGesture.cancelsTouchesInView = false + addGestureRecognizer(tapGesture) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetViewController.swift new file mode 100644 index 00000000..7a664852 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/MonsterFilterBottomSheet/MonsterFilterBottomSheetViewController.swift @@ -0,0 +1,133 @@ +import UIKit + +import MLSCore +import MLSDesignSystem + +import ReactorKit +import RxKeyboard +import RxSwift +import SnapKit + +public final class MonsterFilterBottomSheetViewController: BaseViewController, ModalPresentable, View { + public var modalHeight: CGFloat? + + public typealias Reactor = MonsterFilterBottomSheetReactor + + // MARK: - Properties + public var disposeBag = DisposeBag() + + var startLevel: CGFloat = 1 + var endLevel: CGFloat = 200 + + public lazy var mainView = MonsterFilterBottomSheetView(lowerLevel: startLevel, upperLevel: endLevel) + + public var onFilterSelected: ((Int, Int) -> Void)? +} + +// MARK: - Life Cycle +public extension MonsterFilterBottomSheetViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + configureUI() + setupKeyboard() + } +} + +// MARK: - SetUp +private extension MonsterFilterBottomSheetViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + func configureUI() {} + + func setupKeyboard() { + setupKeyboard { [weak self] height in + self?.mainView.snp.remakeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + make.bottom.equalToSuperview().inset(height) + } + } + } +} + +extension MonsterFilterBottomSheetViewController { + 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.clearButton.rx.tap + .map { Reactor.Action.clearButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.applyButton.rx.tap + .withUnretained(self) + .compactMap { _, _ in + let startText = (self.mainView.levelRangeView.leftInputBox.textField.text?.isEmpty == false) + ? self.mainView.levelRangeView.leftInputBox.textField.text! + : "1" + + let endText = (self.mainView.levelRangeView.rightInputBox.textField.text?.isEmpty == false) + ? self.mainView.levelRangeView.rightInputBox.textField.text! + : "200" + guard let start = Int(startText), + let end = Int(endText) + else { + return nil + } + return Reactor.Action.applyButtonTapped(start: start, end: end) + } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + func bindViewState(reactor: Reactor) { + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .subscribe { owner, route in + switch route { + case .dismiss: + owner.dismissCurrentModal() + case .dismissWithLevelRange(let start, let end): + owner.onFilterSelected?(start, end) + owner.dismissCurrentModal() + case .clear: + owner.mainView.levelRangeView.slider.reset(lower: 1, upper: 200) + default: + break + } + } + .disposed(by: disposeBag) + + mainView.tapGesture.rx.event + .withUnretained(self) + .bind { owner, gesture in + let location = gesture.location(in: owner.mainView) + + if !owner.mainView.levelRangeView.leftInputBox.frame.contains(location), + !owner.mainView.levelRangeView.rightInputBox.frame.contains(location) { + owner.mainView.endEditing(true) + } + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetFactoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetFactoryImpl.swift new file mode 100644 index 00000000..e9b1fbae --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetFactoryImpl.swift @@ -0,0 +1,14 @@ +import MLSCore +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +public struct SortedBottomSheetFactoryImpl: SortedBottomSheetFactory { + public init() {} + + public func make(sortedOptions: [SortType], selectedIndex: Int, onSelectedIndex: @escaping (Int) -> Void) -> BaseViewController & ModalPresentable { + let viewController = SortedBottomSheetViewController() + viewController.reactor = SortedBottomSheetReactor(sortTypes: sortedOptions, selectedIndex: selectedIndex) + viewController.onSelectedIndex = onSelectedIndex + return viewController + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetReactor.swift new file mode 100644 index 00000000..162e4110 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetReactor.swift @@ -0,0 +1,66 @@ +import MLSDictionaryFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift + +public final class SortedBottomSheetReactor: Reactor { + public enum Route { + case none + case dismiss + case dismissWithSave + } + + // MARK: - Reactor + public enum Action { + case cancelButtonTapped + case sortedButtonTapped(index: Int) + case applyButtonTapped + } + + public enum Mutation { + case navigateTo(route: Route) + case setSelectedIndex(index: Int) + } + + public struct State { + var sortTypes: [SortType] + var selectedIndex: Int + var isTabbarHidden: Bool + @Pulse var route: Route = .none + } + + // MARK: - properties + public var initialState: State + var disposeBag = DisposeBag() + + // MARK: - init + public init(sortTypes: [SortType], selectedIndex: Int, isTabbarHidden: Bool = false) { + self.initialState = State(sortTypes: sortTypes, selectedIndex: selectedIndex, isTabbarHidden: isTabbarHidden) + } + + // MARK: - Reactor Methods + public func mutate(action: Action) -> Observable { + switch action { + case .cancelButtonTapped: + return Observable.just(.navigateTo(route: .dismiss)) + case .sortedButtonTapped(let index): + return Observable.just(.setSelectedIndex(index: index)) + case .applyButtonTapped: + return Observable.just(.navigateTo(route: .dismissWithSave)) + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case .navigateTo(let route): + newState.route = route + case .setSelectedIndex(let index): + newState.selectedIndex = index + } + + return newState + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetView.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetView.swift new file mode 100644 index 00000000..d7a56627 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetView.swift @@ -0,0 +1,68 @@ +import UIKit + +import MLSDesignSystem + +import SnapKit + +final class SortedBottomSheetView: UIView { + private enum Constant { + static let defaultInset: CGFloat = 16 + static let stackViewTopInset = 14 + } + + // MARK: - Properties + let header: Header = { + let header = Header(style: .filter, title: "정렬") + return header + }() + + let sortedStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + return view + }() + + let applyButton: CommonButton = { + let button = CommonButton(style: .normal, title: "적용", disabledTitle: nil) + return button + }() + + var sortedButtons: [CheckBoxButton] = [] + + // MARK: - init + init() { + super.init(frame: .zero) + + addViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension SortedBottomSheetView { + func addViews() { + addSubview(header) + addSubview(sortedStackView) + addSubview(applyButton) + } + + func setupConstraints() { + header.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.defaultInset) + make.horizontalEdges.equalToSuperview() + } + sortedStackView.snp.makeConstraints { make in + make.top.equalTo(header.snp.bottom).offset(Constant.stackViewTopInset) + make.horizontalEdges.equalToSuperview() + } + applyButton.snp.makeConstraints { make in + make.top.equalTo(sortedStackView.snp.bottom).offset(Constant.defaultInset) + make.horizontalEdges.bottom.equalToSuperview().inset(Constant.defaultInset) + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetViewController.swift new file mode 100644 index 00000000..c6a8a06d --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/SortedBottomSheet/SortedBottomSheetViewController.swift @@ -0,0 +1,128 @@ +import UIKit + +import MLSCore +import MLSDesignSystem +import MLSDictionaryFeatureInterface + +import ReactorKit +import RxCocoa +import RxSwift +import SnapKit + +public final class SortedBottomSheetViewController: BaseViewController, ModalPresentable, View { + public var modalHeight: CGFloat? + + public typealias Reactor = SortedBottomSheetReactor + + // MARK: - Properties + public var disposeBag = DisposeBag() + + public var onSelectedIndex: ((Int) -> Void)? + + // MARK: - Components + + private var mainView = SortedBottomSheetView() +} + +// MARK: - Life Cycle +public extension SortedBottomSheetViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addViews() + setupConstraints() + } +} + +// MARK: - SetUp +private extension SortedBottomSheetViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private func updateSortedButtons(types: [SortType]) { + mainView.sortedStackView.arrangedSubviews.forEach { + mainView.sortedStackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + + guard let reactor = reactor else { return } + mainView.sortedButtons = types.enumerated().map { index, type in + let button = CheckBoxButton(style: .listLarge, mainTitle: type.rawValue, subTitle: nil) + + button.rx.tap + .map { Reactor.Action.sortedButtonTapped(index: index) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.sortedStackView.addArrangedSubview(button) + return button + } + } +} + +extension SortedBottomSheetViewController { + 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) { + reactor.state + .map { $0.sortTypes } + .distinctUntilChanged() + .withUnretained(self) + .subscribe { owner, type in + owner.updateSortedButtons(types: type) + } + .disposed(by: disposeBag) + + reactor.state + .map { $0.selectedIndex } + .distinctUntilChanged() + .withUnretained(self) + .subscribe { owner, selectedIndex in + owner.mainView.sortedButtons.enumerated().forEach { index, button in + button.isSelected = selectedIndex == index ? true : false + } + } + .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .subscribe { owner, route in + switch route { + case .dismiss: + owner.isBottomTabbarHidden = reactor.currentState.isTabbarHidden + owner.dismissCurrentModal() + case .dismissWithSave: + owner.isBottomTabbarHidden = reactor.currentState.isTabbarHidden + owner.onSelectedIndex?(reactor.currentState.selectedIndex) + owner.dismissCurrentModal() + default: + break + } + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailItemDropMonsterResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailItemDropMonsterResponse.swift new file mode 100644 index 00000000..43096c9e --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailItemDropMonsterResponse.swift @@ -0,0 +1,15 @@ +public struct DictionaryDetailItemDropMonsterResponse: Equatable { + public let monsterId: Int? + public let monsterName: String? + public let level: Int? + public let dropRate: Double? + public let imageUrl: String? + + public init(monsterId: Int?, monsterName: String?, level: Int?, dropRate: Double?, imageUrl: String?) { + self.monsterId = monsterId + self.monsterName = monsterName + self.level = level + self.dropRate = dropRate + self.imageUrl = imageUrl + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailItemResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailItemResponse.swift new file mode 100644 index 00000000..bc1f2064 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailItemResponse.swift @@ -0,0 +1,118 @@ +public struct DictionaryDetailItemResponse: Equatable { + public let itemId: Int + public let nameKr: String? + public let nameEn: String? + public let descriptionText: String? + public let imgUrl: String? + public let npcPrice: Int? + public let itemType: String? + public let categoryHierachy: CategoryHierachy? + public let availableJobs: [Jobs]? + public let requiredStats: RequiredStats? // 요구 스탯 + public let equipmentStats: EquipmentStats? // 착용하면 올라가는 스탯 + public let scrollDetail: ScrollDetail? // 주문서 상세정보 + public var bookmarkId: Int? + + public init( + itemId: Int, + nameKr: String?, + nameEn: String?, + descriptionText: String?, + imgUrl: String?, + npcPrice: Int?, + itemType: String?, + categoryHierachy: CategoryHierachy?, + availableJobs: [Jobs]?, + requiredStats: RequiredStats?, + equipmentStats: EquipmentStats?, + scrollDetail: ScrollDetail?, + bookmarkId: Int? + ) { + self.itemId = itemId + self.nameKr = nameKr + self.nameEn = nameEn + self.descriptionText = descriptionText + self.imgUrl = imgUrl + self.npcPrice = npcPrice + self.itemType = itemType + self.categoryHierachy = categoryHierachy + self.availableJobs = availableJobs + self.requiredStats = requiredStats + self.equipmentStats = equipmentStats + self.scrollDetail = scrollDetail + self.bookmarkId = bookmarkId + } + +} + +public struct CategoryHierachy: Decodable, Equatable { + public let rootCategory: Category? + public let leafCategory: Category? +} + +public struct Category: Decodable, Equatable { + public let categoryId: Int? + public let name: String? + public let categoryLevel: Int? + public let description: String? +} + +public struct Jobs: Decodable, Equatable { + public let jobId: Int? + public let jobName: String? + public let jobLevel: Int? + public let parentJobId: Int? +} + +public struct RequiredStats: Decodable, Equatable { + public let level: Int? + public let str: Int? + public let dex: Int? + public let intelligence: Int? + public let luk: Int? + public let pop: Int? +} + +public struct EquipmentStats: Decodable, Equatable { + public let str: Stats? + public let dex: Stats? + public let intelligence: Stats? + public let luk: Stats? + public let hp: Stats? + public let mp: Stats? + public let weaponAttack: Stats? + public let magicAttack: Stats? + public let physicalDefense: Stats? + public let magicDefense: Stats? + public let accuracy: Stats? + public let evasion: Stats? + public let speed: Stats? + public let jump: Stats? + public let attackSpeed: Int? + public let attackSpeedDetails: String? +} + +public struct Stats: Decodable, Equatable { + public let base: Int? + public let min: Int? + public let max: Int? +} + +public struct ScrollDetail: Decodable, Equatable { + public let successRatePercent: Int? + public let targetItemTypeText: String? + public let strChange: Int? + public let dexChange: Int? + public let intelligenceChange: Int? + public let lukChange: Int? + public let hpChange: Int? + public let mpChange: Int? + public let weaponAttackChange: Int? + public let magicAttackChange: Int? + public let physicalDefenseChange: Int? + public let magicDefenseChange: Int? + public let accuracyChange: Int? + public let evasionChange: Int? + public let speedChange: Int? + public let jumpChange: Int? +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMapNpcResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMapNpcResponse.swift new file mode 100644 index 00000000..74d3afb0 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMapNpcResponse.swift @@ -0,0 +1,13 @@ +public struct DictionaryDetailMapNpcResponse: Equatable { + public let npcId: Int? + public let npcName: String? + public let npcNameEn: String? + public let iconUrl: String? + + public init(npcId: Int?, npcName: String?, npcNameEn: String?, iconUrl: String?) { + self.npcId = npcId + self.npcName = npcName + self.npcNameEn = npcNameEn + self.iconUrl = iconUrl + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMapResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMapResponse.swift new file mode 100644 index 00000000..71881911 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMapResponse.swift @@ -0,0 +1,23 @@ +public struct DictionaryDetailMapResponse: Equatable { + public let mapId: Int + public let nameKr: String? + public let nameEn: String? + public let regionName: String? + public let detailName: String? + public let topRegionName: String? + public let mapUrl: String? + public let iconUrl: String? + public var bookmarkId: Int? + + public init(mapId: Int, nameKr: String?, nameEn: String?, regionName: String?, detailName: String?, topRegionName: String?, mapUrl: String?, iconUrl: String?, bookmarkId: Int?) { + self.mapId = mapId + self.nameKr = nameKr + self.nameEn = nameEn + self.regionName = regionName + self.detailName = detailName + self.topRegionName = topRegionName + self.mapUrl = mapUrl + self.iconUrl = iconUrl + self.bookmarkId = bookmarkId + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMapSpawnMonsterResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMapSpawnMonsterResponse.swift new file mode 100644 index 00000000..404ce346 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMapSpawnMonsterResponse.swift @@ -0,0 +1,15 @@ +public struct DictionaryDetailMapSpawnMonsterResponse: Equatable { + public let monsterId: Int? + public let monsterName: String? + public let level: Int? + public let maxSpawnCount: Int? + public let imageUrl: String? + + public init(monsterId: Int?, monsterName: String?, level: Int?, maxSpawnCount: Int?, imageUrl: String?) { + self.monsterId = monsterId + self.monsterName = monsterName + self.level = level + self.maxSpawnCount = maxSpawnCount + self.imageUrl = imageUrl + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMonsterDropItemResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMonsterDropItemResponse.swift new file mode 100644 index 00000000..c5214a28 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMonsterDropItemResponse.swift @@ -0,0 +1,15 @@ +public struct DictionaryDetailMonsterDropItemResponse: Equatable { + public let itemId: Int + public let itemName: String + public let dropRate: Double + public let imageUrl: String + public let itemLevel: Int + + public init(itemId: Int, itemName: String, dropRate: Double, imageUrl: String, itemLevel: Int) { + self.itemId = itemId + self.itemName = itemName + self.dropRate = dropRate + self.imageUrl = imageUrl + self.itemLevel = itemLevel + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMonsterMapResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMonsterMapResponse.swift new file mode 100644 index 00000000..6daa29f5 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMonsterMapResponse.swift @@ -0,0 +1,19 @@ +public struct DictionaryDetailMonsterMapResponse: Equatable { + public let mapId: Int + public let mapName: String + public let regionName: String + public let detailName: String + public let topRegionName: String + public let iconUrl: String + public let maxSpawnCount: Int? + + public init(mapId: Int, mapName: String, regionName: String, detailName: String, topRegionName: String, iconUrl: String, maxSpawnCount: Int?) { + self.mapId = mapId + self.mapName = mapName + self.regionName = regionName + self.detailName = detailName + self.topRegionName = topRegionName + self.iconUrl = iconUrl + self.maxSpawnCount = maxSpawnCount + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMonsterResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMonsterResponse.swift new file mode 100644 index 00000000..90ced802 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailMonsterResponse.swift @@ -0,0 +1,111 @@ +import Foundation +import UIKit + +public struct DictionaryDetailMonsterResponse: Equatable { + + public let monsterId: Int + public let nameKr: String + public let nameEn: String + public let imageUrl: String + public let level: Int + public let exp: Int + public let hp: Int + public let mp: Int + public let physicalDefense: Int + public let magicDefense: Int + public let requiredAccuracy: Int + public let bonusAccuracyPerLevelLower: Double + public let evasionRate: Int + public let mesoDropAmount: Int? + public let mesoDropRate: Int? + public let typeEffectiveness: Effectiveness? + public var bookmarkId: Int? + + public init( + monsterId: Int, + nameKr: String, + nameEn: String, + imageUrl: String, + level: Int, + exp: Int, + hp: Int, + mp: Int, + physicalDefense: Int, + magicDefense: Int, + requiredAccuracy: Int, + bonusAccuracyPerLevelLower: Double, + evasionRate: Int, + mesoDropAmount: Int?, + mesoDropRate: Int?, + typeEffectiveness: Effectiveness?, + bookmarkId: Int? + ) { + self.monsterId = monsterId + self.nameKr = nameKr + self.nameEn = nameEn + self.imageUrl = imageUrl + self.level = level + self.exp = exp + self.hp = hp + self.mp = mp + self.physicalDefense = physicalDefense + self.magicDefense = magicDefense + self.requiredAccuracy = requiredAccuracy + self.bonusAccuracyPerLevelLower = bonusAccuracyPerLevelLower + self.evasionRate = evasionRate + self.mesoDropAmount = mesoDropAmount + self.mesoDropRate = mesoDropRate + self.typeEffectiveness = typeEffectiveness + self.bookmarkId = bookmarkId + } +} + +public struct Effectiveness: Decodable, Equatable { + public let fire: String? + public let lightning: String? + public let poison: String? + public let holy: String? + public let ice: String? + public let physical: String? + + public init(fire: String?, lightning: String?, poison: String?, holy: String?, ice: String?, physical: String?) { + self.fire = fire + self.lightning = lightning + self.poison = poison + self.holy = holy + self.ice = ice + self.physical = physical + } + // 순회하기 위해서 + public func nonNilElements() -> [(element: ElementType, value: String)] { + var result: [(ElementType, String)] = [] + + if let fire = fire { result.append((.fire, toKoreanEffect(data: fire))) } + if let lightning = lightning { result.append((.lightning, toKoreanEffect(data: lightning))) } + if let poison = poison { result.append((.poison, toKoreanEffect(data: poison))) } + if let holy = holy { result.append((.holy, toKoreanEffect(data: holy))) } + if let ice = ice { result.append((.ice, toKoreanEffect(data: ice))) } + if let physical = physical { result.append((.physical, toKoreanEffect(data: physical))) } + + return result + } + // 몬스터 약점 태그를 위한 변환 + private func toKoreanEffect(data: String) -> String { + switch data { + case "RESIST": return "저항" + case "WEAK": return "약점" + case "IMMUNE": return "면역" + default: + return "" + } + } +} + +public enum ElementType: String { + case fire = "불" + case lightning = "번개" + case poison = "독" + case holy = "빛" + case ice = "얼음" + case physical = "물리" +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailNpcQuestResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailNpcQuestResponse.swift new file mode 100644 index 00000000..93caad5f --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailNpcQuestResponse.swift @@ -0,0 +1,17 @@ +public struct DictionaryDetailNpcQuestResponse: Equatable { + public let questId: Int + public let questNameKr: String + public let questNameEn: String + public let questIconUrl: String + public let minLevel: Int? + public let maxLevel: Int? + + public init(questId: Int, questNameKr: String, questNameEn: String, questIconUrl: String, minLevel: Int?, maxLevel: Int?) { + self.questId = questId + self.questNameKr = questNameKr + self.questNameEn = questNameEn + self.questIconUrl = questIconUrl + self.minLevel = minLevel + self.maxLevel = maxLevel + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailNpcResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailNpcResponse.swift new file mode 100644 index 00000000..02962f19 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailNpcResponse.swift @@ -0,0 +1,15 @@ +public struct DictionaryDetailNpcResponse: Equatable { + public let npcId: Int + public let nameKr: String + public let nameEn: String + public let iconUrlDetail: String? + public var bookmarkId: Int? + + public init(npcId: Int, nameKr: String, nameEn: String, iconUrlDetail: String?, bookmarkId: Int?) { + self.npcId = npcId + self.nameKr = nameKr + self.nameEn = nameEn + self.iconUrlDetail = iconUrlDetail + self.bookmarkId = bookmarkId + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailQuestLinkedQuestsResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailQuestLinkedQuestsResponse.swift new file mode 100644 index 00000000..1f7c1aea --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailQuestLinkedQuestsResponse.swift @@ -0,0 +1,25 @@ +public struct DictionaryDetailQuestLinkedQuestsResponse: Equatable { + public let previousQuests: [Quest]? + public let nextQuests: [Quest]? + + public init(previousQuests: [Quest]?, nextQuests: [Quest]?) { + self.previousQuests = previousQuests + self.nextQuests = nextQuests + } +} + +public struct Quest: Decodable, Equatable { + public let questId: Int? + public let name: String? + public let minLevel: Int? + public let maxLevel: Int? + public let iconUrl: String? + + public init(questId: Int?, name: String?, minLevel: Int?, maxLevel: Int?, iconUrl: String?) { + self.questId = questId + self.name = name + self.minLevel = minLevel + self.maxLevel = maxLevel + self.iconUrl = iconUrl + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailQuestResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailQuestResponse.swift new file mode 100644 index 00000000..3dcd9fa1 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailQuestResponse.swift @@ -0,0 +1,92 @@ +public struct DictionaryDetailQuestResponse: Equatable { + public let questId: Int + public let titlePrefix: String? + public let nameKr: String? + public let nameEn: String? + public let iconUrl: String? + public let questType: String? + public let minLevel: Int? + public let maxLevel: Int? + public let requiredMesoStart: Int? + public let startNpcId: Int? + public let startNpcName: String? + public let endNpcId: Int? + public let endNpcName: String? + public let reward: Reward? + public let rewardItems: [RewardItem]? + public let requirements: [Requirements]? + public let allowedJobs: [AllowedJob]? + public var bookmarkId: Int? + + public init( + questId: Int, + titlePrefix: String?, + nameKr: String?, + nameEn: String?, + iconUrl: String?, + questType: String?, + minLevel: Int?, + maxLevel: Int?, + requiredMesoStart: Int?, + startNpcId: Int?, + startNpcName: String?, + endNpcId: Int?, + endNpcName: String?, + reward: Reward?, + rewardItems: [RewardItem]?, + requirements: [Requirements]?, + allowedJobs: [AllowedJob]?, + bookmarkId: Int? + ) { + self.questId = questId + self.titlePrefix = titlePrefix + self.nameKr = nameKr + self.nameEn = nameEn + self.iconUrl = iconUrl + self.questType = questType + self.minLevel = minLevel + self.maxLevel = maxLevel + self.requiredMesoStart = requiredMesoStart + self.startNpcId = startNpcId + self.startNpcName = startNpcName + self.endNpcId = endNpcId + self.endNpcName = endNpcName + self.reward = reward + self.rewardItems = rewardItems + self.requirements = requirements + self.allowedJobs = allowedJobs + self.bookmarkId = bookmarkId + } +} + +public struct Reward: Decodable, Equatable { + public let exp: Int? + public let meso: Int? + public let popularity: Int? + + public init(exp: Int?, meso: Int?, popularity: Int?) { + self.exp = exp + self.meso = meso + self.popularity = popularity + } +} + +public struct RewardItem: Decodable, Equatable { + public let itemId: Int? + public let itemName: String? + public let quantity: Int? +} + +public struct Requirements: Decodable, Equatable { + public let requirementType: String? + public let itemId: Int? + public let itemName: String? + public let monsterId: Int? + public let monsterName: String? + public let quantity: Int? +} + +public struct AllowedJob: Decodable, Equatable { + public let jobId: Int? + public let jobName: String? +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailText.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailText.swift new file mode 100644 index 00000000..e1e62dfe --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailText.swift @@ -0,0 +1,10 @@ +public enum DictionaryDetailText { + public static let minLevel = "시작 최소 레벨" + public static let maxLevel = "시작 최대 레벨" + public static let startNpc = "시작 NPC" + public static let endNpc = "종료 NPC" + public static let job = "직업" + public static let meso = "메소" + public static let exp = "경험치" + public static let pop = "인기도" +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryListQuery.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryListQuery.swift new file mode 100644 index 00000000..26d19bfd --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryListQuery.swift @@ -0,0 +1,25 @@ +public struct DictionaryListQuery: Encodable { + public var keyword: String? + public var page: Int + public var size: Int + public var sort: String? + + // monster일 경우 + public var minLevel: Int? + public var maxLevel: Int? + + // item일 경우 + public var jobIds: String? + public var categoryIds: String? + + public init(keyword: String? = nil, page: Int, size: Int, sort: String?, minLevel: Int? = nil, maxLevel: Int? = nil, jobIds: String? = nil, categoryIds: String? = nil) { + self.keyword = keyword + self.page = page + self.size = size + self.sort = sort ?? "ASC" + self.minLevel = minLevel + self.maxLevel = maxLevel + self.jobIds = jobIds + self.categoryIds = categoryIds + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryMainResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryMainResponse.swift new file mode 100644 index 00000000..a6e74562 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryMainResponse.swift @@ -0,0 +1,29 @@ +public struct DictionaryMainResponse { + public let totalPages: Int + public let totalElements: Int + public var contents: [DictionaryMainItemResponse] + + public init(totalPages: Int, totalElements: Int, contents: [DictionaryMainItemResponse]) { + self.totalPages = totalPages + self.totalElements = totalElements + self.contents = contents + } +} + +public struct DictionaryMainItemResponse: Equatable { + public let id: Int + public let name: String + public let imageUrl: String? + public let level: Int? + public let type: DictionaryItemType + public var bookmarkId: Int? + + public init(id: Int, name: String, imageUrl: String?, level: Int?, type: DictionaryItemType, bookmarkId: Int?) { + self.id = id + self.name = name + self.imageUrl = imageUrl + self.level = level + self.type = type + self.bookmarkId = bookmarkId + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryMainViewType.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryMainViewType.swift new file mode 100644 index 00000000..9ad16018 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryMainViewType.swift @@ -0,0 +1,16 @@ +public enum DictionaryMainViewType { + case main + case search + case bookmark + + public var pageTabList: [DictionaryType] { + switch self { + case .main: + return [.total, .monster, .item, .map, .npc, .quest] + case .search: + return [.total, .monster, .item, .map, .npc, .quest] + case .bookmark: + return [.total, .collection, .monster, .item, .map, .npc, .quest] + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryType.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryType.swift new file mode 100644 index 00000000..cb164b00 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryType.swift @@ -0,0 +1,129 @@ +public enum DictionaryType: String, CaseIterable { + case total + case collection + case item + case monster + case map + case npc + case quest + + public var title: String { + switch self { + case .total: + return "전체" + case .collection: + return "컬렉션" + case .monster: + return "몬스터" + case .item: + return "아이템" + case .map: + return "맵" + case .npc: + return "NPC" + case .quest: + return "퀘스트" + } + } + + public var sortedFilter: [SortType] { + switch self { + case .item: + return [ + .korean, .levelDESC, .levelASC + ] + case .monster: + return [ + .korean, .levelDESC, .levelASC, .expDESC, .expASC + ] + default: + return [] + } + } + + public var detailTypes: [DetailType] { + switch self { + case .item: + return [ + .dropMonsterWithText + ] + case .monster: + return [ + .appearMapWithText, .dropItemWithText + ] + case .map: + return [ + .appearMonsterWithText, .appearNPC + ] + case .npc: + return [ + .quest + ] + default: + return [] + } + } + + public var isSortHidden: Bool { + return sortedFilter.count == 0 + } + + public var bookmarkSortedFilter: [SortType] { + switch self { + case .total: + return [ + .latest, .korean + ] + case .item: + return [ + .korean, .levelDESC, .levelASC + ] + case .monster: + return [ + .korean, .levelDESC, .levelASC, .expDESC, .expASC + ] + default: + return [] + } + } + + public var isBookmarkSortHidden: Bool { + return bookmarkSortedFilter.count == 0 + } + + public var toItemType: DictionaryItemType? { + switch self { + case .item: + return .item + case .monster: + return .monster + case .map: + return .map + case .npc: + return .npc + case .quest: + return .quest + default: + return nil + } + } + + public var tabIndex: Int { + switch self { + case .total: + 0 + case .collection: + 0 + case .item: + 1 + case .monster: + 2 + case .map: + 3 + case .npc: + 4 + case .quest: + 5 + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionnaryItemType.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionnaryItemType.swift new file mode 100644 index 00000000..f229667c --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionnaryItemType.swift @@ -0,0 +1,118 @@ +import UIKit + +public enum DictionaryItemType: String { + case item + case monster + case map + case npc + case quest + + public var detailTypes: [DetailType] { + switch self { + case .item: + [.normal, .dropMonsterWithText] + case .monster: + [.normal, .appearMapWithText, .dropItemWithText] + case .map: + [.mapInfo, .appearMonsterWithText, .appearNPC] + case .npc: + [.appearMap, .quest] + case .quest: + [.normal, .linkedQuest] + } + } + + public var detailTitle: String { + switch self { + case .item: + "아이템 상세 정보" + case .monster: + "몬스터 상세 정보" + case .map: + "맵 상세 정보" + case .npc: + "NPC 상세 정보" + case .quest: + "퀘스트 상세 정보" + } + } + + public var toDictionaryType: DictionaryType? { + switch self { + case .item: + return .item + case .monster: + return .monster + case .map: + return .map + case .npc: + return .npc + case .quest: + return .quest + } + } + + public var backgroundColor: UIColor { + switch self { + case .item: + .listItem + case .monster: + .listMonster + case .map: + .listMap + case .npc: + .listNPC + case .quest: + .listQuest + } + } +} + +public enum DetailType { + case normal + case mapInfo + case appearMap + case appearNPC + case linkedQuest + case quest + case dropItemWithText + case appearMapWithText + case appearMonsterWithText + case dropMonsterWithText + + public var description: String { + switch self { + case .normal: + return "상세 정보" + case .mapInfo: + return "맵 정보" + case .appearNPC: + return "출현 NPC" + case .linkedQuest: + return "연계 퀘스트" + case .quest: + return "퀘스트" + case .appearMap, .appearMapWithText: + return "출현 맵" + case .dropItemWithText: + return "드롭 아이템" + case .appearMonsterWithText: + return "출현 몬스터" + case .dropMonsterWithText: + return "드롭 몬스터" + } + } + + public var sortFilter: [SortType] { + switch self { + case .appearMonsterWithText, .appearMapWithText: + [.mostAppear] + case .dropItemWithText, .dropMonsterWithText: + [.mostDrop, .levelASC, .levelDESC] + case .quest: + [.levelLowest, .levelHighest] + default: + [] + } + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/ItemFilterCriteria.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/ItemFilterCriteria.swift new file mode 100644 index 00000000..8fe61cd6 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/ItemFilterCriteria.swift @@ -0,0 +1,13 @@ +public struct ItemFilterCriteria { + public let jobIds: [Int] + public let startLevel: Int? + public let endLevel: Int? + public let categoryIds: [Int] + + public init(jobIds: [Int], startLevel: Int?, endLevel: Int?, categoryIds: [Int]) { + self.jobIds = jobIds + self.startLevel = startLevel + self.endLevel = endLevel + self.categoryIds = categoryIds + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/SearchCountResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/SearchCountResponse.swift new file mode 100644 index 00000000..ef99985a --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/SearchCountResponse.swift @@ -0,0 +1,7 @@ +public struct SearchCountResponse: Decodable { + public let count: Int? + + public init(count: Int?) { + self.count = count + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/SortType.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/SortType.swift new file mode 100644 index 00000000..0adf976c --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/SortType.swift @@ -0,0 +1,51 @@ +public enum SortType: String { + // 도감 메인 정렬 + case korean = "가나다 순" + case levelDESC = "레벨 높은 순" + case levelASC = "레벨 낮은 순" + case expDESC = "획득 경험치 높은 순" + case expASC = "획득 경험치 낮은 순" + case latest = "최신순" + + // 도감 상세 정렬 + case mostAppear = "출현 많은 순" + case levelLowest = "수락 레벨 낮은 순" + case levelHighest = "수락 레벨 높은 순" + case mostDrop = "드롭률 높은 순" + + // 정렬 키 - 이름, 레벨, 경험치 + public var sortKey: String { + switch self { + case .latest: + return "createdAt" + case .korean: + return "name" + case .levelASC, .levelDESC: + return "level" + case .expASC, .expDESC: + return "exp" + case .mostAppear: + return "maxSpawnCount" + case .mostDrop: + return "dropRate" + default: + return "" + } + } + // 정렬 방향 - 오름차순, 내림차순 + public var direction: String { + switch self { + case .expASC, .levelASC, .korean: + return "asc" + case .expDESC, .levelDESC, .mostDrop, .mostAppear, .latest: + return "desc" + default: + return "" + } + } + + // 정렬 파라미터 + public var sortParameter: String { + return "\(sortKey),\(direction)" + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/BookmarkModalFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/BookmarkModalFactory.swift new file mode 100644 index 00000000..4cfd8ea1 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/BookmarkModalFactory.swift @@ -0,0 +1,7 @@ +import MLSCore + +/// Bookmark 모듈 분리 후 제거 +public protocol BookmarkModalFactory { + func make(bookmarkIds: [Int]) -> BaseViewController + func make(bookmarkIds: [Int], onComplete: ((Bool) -> Void)?) -> BaseViewController +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DetailOnBoardingFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DetailOnBoardingFactory.swift new file mode 100644 index 00000000..0983793b --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DetailOnBoardingFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol DetailOnBoardingFactory { + func make() -> BaseViewController +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryDetailFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryDetailFactory.swift new file mode 100644 index 00000000..ccda4a2d --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryDetailFactory.swift @@ -0,0 +1,7 @@ +import MLSCore + +import RxCocoa + +public protocol DictionaryDetailFactory { + func make(type: DictionaryType, id: Int, bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, loginRelay: PublishRelay?) -> BaseViewController +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryMainListFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryMainListFactory.swift new file mode 100644 index 00000000..720f0883 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryMainListFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol DictionaryMainListFactory { + func make(type: DictionaryType, listType: DictionaryMainViewType, keyword: String?) -> BaseViewController +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryMainViewFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryMainViewFactory.swift new file mode 100644 index 00000000..b7686839 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryMainViewFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol DictionaryMainViewFactory { + func make() -> BaseViewController +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryNotificationFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryNotificationFactory.swift new file mode 100644 index 00000000..9505ffcc --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryNotificationFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol DictionaryNotificationFactory { + func make() -> BaseViewController +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionarySearchFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionarySearchFactory.swift new file mode 100644 index 00000000..18e2beb2 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionarySearchFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol DictionarySearchFactory { + func make() -> BaseViewController +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionarySearchResultFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionarySearchResultFactory.swift new file mode 100644 index 00000000..ced97fca --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionarySearchResultFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol DictionarySearchResultFactory { + func make(keyword: String?) -> BaseViewController +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/ItemFilterBottomSheetFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/ItemFilterBottomSheetFactory.swift new file mode 100644 index 00000000..91679894 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/ItemFilterBottomSheetFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol ItemFilterBottomSheetFactory { + func make(onFilterSelected: @escaping ([(String, String)]) -> Void) -> BaseViewController +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/MonsterFilterBottomSheetFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/MonsterFilterBottomSheetFactory.swift new file mode 100644 index 00000000..ed69d9e0 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/MonsterFilterBottomSheetFactory.swift @@ -0,0 +1,6 @@ +import MLSCore +import MLSDesignSystem + +public protocol MonsterFilterBottomSheetFactory { + func make(startLevel: Int, endLevel: Int, onFilterSelected: @escaping ((Int, Int) -> Void)) -> BaseViewController & ModalPresentable +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/SortedBottomSheetFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/SortedBottomSheetFactory.swift new file mode 100644 index 00000000..25cd59f1 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/SortedBottomSheetFactory.swift @@ -0,0 +1,6 @@ +import MLSCore +import MLSDesignSystem + +public protocol SortedBottomSheetFactory { + func make(sortedOptions: [SortType], selectedIndex: Int, onSelectedIndex: @escaping (Int) -> Void) -> BaseViewController & ModalPresentable +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/BookmarkRepository.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/BookmarkRepository.swift new file mode 100644 index 00000000..85f6a241 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/BookmarkRepository.swift @@ -0,0 +1,38 @@ +import RxSwift + +/// Bookmark 모듈 분리 후 제거 +public struct BookmarkResponse: Equatable { + public let name: String + public let bookmarkId: Int + public let originalId: Int + public let imageUrl: String? + public let type: DictionaryItemType + public let level: Int? + + public init(name: String, bookmarkId: Int, originalId: Int, imageUrl: String?, type: DictionaryItemType, level: Int?) { + self.name = name + self.bookmarkId = bookmarkId + self.originalId = originalId + self.imageUrl = imageUrl + self.type = type + self.level = level + } +} + +public protocol BookmarkRepository { + func setBookmark(bookmarkId: Int, type: DictionaryItemType) -> Observable + + func deleteBookmark(bookmarkId: Int) -> Observable + + func fetchBookmark(sort: String?) -> Observable<[BookmarkResponse]> + + func fetchMonsterBookmark(minLevel: Int?, maxLevel: Int?, sort: String?) -> Observable<[BookmarkResponse]> + + func fetchNPCBookmark(sort: String?) -> Observable<[BookmarkResponse]> + + func fetchQuestBookmark(sort: String?) -> Observable<[BookmarkResponse]> + + func fetchItemBookmark(jobId: Int?, minLevel: Int?, maxLevel: Int?, categoryIds: [Int]?, sort: String?) -> Observable<[BookmarkResponse]> + + func fetchMapBookmark(sort: String?) -> Observable<[BookmarkResponse]> +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/DictionaryDetailAPIRepository.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/DictionaryDetailAPIRepository.swift new file mode 100644 index 00000000..4f593334 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/DictionaryDetailAPIRepository.swift @@ -0,0 +1,32 @@ +import RxSwift + +public protocol DictionaryDetailAPIRepository { + // 몬스터 디테일 상세정보 + func fetchMonsterDetail(id: Int) -> Observable + // 몬스터 디테일 드롭 아이템 + func fetchMonsterDetailDropItem(id: Int, sort: String?) -> Observable<[DictionaryDetailMonsterDropItemResponse]> + // 몬스터 디테일 출현맵 + func fetchMonsterDetailMap(id: Int) -> Observable<[DictionaryDetailMonsterMapResponse]> + + // Npc 디테일 상세정보 + func fetchNpcDetail(id: Int) -> Observable + + // NPC 디테일 퀘스트 + func fetchNpcDetailQuest(id: Int, sort: String?) -> Observable<[DictionaryDetailNpcQuestResponse]> + // NPC 디테일 맵 + func fetchNpcDetailMap(id: Int) -> Observable<[DictionaryDetailMonsterMapResponse]> + // Item 디테일 상세정보 + func fetchItemDetail(id: Int) -> Observable + // Item 디테일 드롭 몬스터 + func fetchItemDetailDropMonster(id: Int, sort: String?) -> Observable<[DictionaryDetailItemDropMonsterResponse]> + // Quest 디테일 상세정보 + func fetchQuestDetail(id: Int) -> Observable + // Quest 연계 퀘스트 상세정보 + func fetchQuestDetailLinkedQuestsDetail(id: Int) -> Observable + // Map 디테일 상세정보 + func fetchMapDetail(id: Int) -> Observable + // Map 디테일 출현 몬스터 정보 + func fetchMapDetailSpawnMonster(id: Int, sort: String?) -> Observable<[DictionaryDetailMapSpawnMonsterResponse]> + // Map 디테일 출현 Npc 정보 + func fetchMapDetailNpc(id: Int) -> Observable<[DictionaryDetailMapNpcResponse]> +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/DictionaryListAPIRepository.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/DictionaryListAPIRepository.swift new file mode 100644 index 00000000..5ea6acdb --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/DictionaryListAPIRepository.swift @@ -0,0 +1,20 @@ +import RxSwift + +public protocol DictionaryListAPIRepository { + // 전체 + func fetchAllList(keyword: String?, page: Int?) -> Observable + // 몬스터 + func fetchMonsterList(keyword: String?, minLevel: Int?, maxLevel: Int?, page: Int, size: Int, sort: String?) -> Observable + // Npc + func fetchNpcList(keyword: String, page: Int, size: Int, sort: String?) -> Observable + // Quest + func fetchQuestList(keyword: String, page: Int, size: Int, sort: String?) -> Observable + // Item + func fetchItemList(keyword: String?, jobId: [Int]?, minLevel: Int?, maxLevel: Int?, categoryIds: [Int]?, page: Int?, size: Int?, sort: String?) -> Observable + // Map + func fetchMapList(keyword: String, page: Int, size: Int, sort: String?) -> Observable + // 검색 + func fetchSearchList(keyword: String?) -> Observable + // 검색 카운트 + func fetchSearchListCount(type: String, keyword: String?) -> Observable +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/RecentSearchRepository.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/RecentSearchRepository.swift new file mode 100644 index 00000000..4b2022f9 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/RecentSearchRepository.swift @@ -0,0 +1,8 @@ +import RxSwift + +public protocol RecentSearchRepository { + func fetchRecentSearch() -> Observable<[String]> + func addRecentSearch(keyword: String) -> Completable + func removeRecentSearch(keyword: String) -> Completable + func removeAllSearch() -> Completable +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/UserDefaultsRepository.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/UserDefaultsRepository.swift new file mode 100644 index 00000000..33ef87bb --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Repositories/UserDefaultsRepository.swift @@ -0,0 +1,6 @@ +import RxSwift + +public protocol UserDefaultsRepository { + func fetchDictionaryDetail() -> Observable + func saveDictionaryDetail() -> Completable +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/CheckLoginUseCase.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/CheckLoginUseCase.swift new file mode 100644 index 00000000..fc8d8347 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/CheckLoginUseCase.swift @@ -0,0 +1,5 @@ +import RxSwift + +public protocol CheckLoginUseCase { + func execute() -> Observable +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/CheckNotificationPermissionUseCase.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/CheckNotificationPermissionUseCase.swift new file mode 100644 index 00000000..04ae921c --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/CheckNotificationPermissionUseCase.swift @@ -0,0 +1,5 @@ +import RxSwift + +public protocol CheckNotificationPermissionUseCase { + func execute() -> Single +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/FetchVisitDictionaryDetailUseCase.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/FetchVisitDictionaryDetailUseCase.swift new file mode 100644 index 00000000..810c4969 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/FetchVisitDictionaryDetailUseCase.swift @@ -0,0 +1,5 @@ +import RxSwift + +public protocol FetchVisitDictionaryDetailUseCase { + func execute() -> Observable +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/ParseItemFilterUseCase.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/ParseItemFilterUseCase.swift new file mode 100644 index 00000000..13a68723 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/ParseItemFilterUseCase.swift @@ -0,0 +1,3 @@ +public protocol ParseItemFilterResultUseCase { + func execute(results: [(String, String)]) -> ItemFilterCriteria +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/SetBookmarkUseCase.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/SetBookmarkUseCase.swift new file mode 100644 index 00000000..b20a53d3 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/UseCases/SetBookmarkUseCase.swift @@ -0,0 +1,11 @@ +import RxSwift + +/// Bookmark 모듈 분리 후 삭제 예정 +public protocol SetBookmarkUseCase { + func execute(bookmarkId: Int, isBookmark: IsBookmark) -> Observable +} + +public enum IsBookmark { + case set(DictionaryItemType) + case delete +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockAppCoordinator.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockAppCoordinator.swift new file mode 100644 index 00000000..6d9f12c0 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockAppCoordinator.swift @@ -0,0 +1,15 @@ +import UIKit + +import MLSAuthFeatureInterface + +public final class MockAppCoordinator: AppCoordinatorProtocol { + public var window: UIWindow? + + public init() {} + + public func showMainTab() { + } + + public func showLogin(exitRoute: LoginExitRoute) { + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockBookmarkModalFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockBookmarkModalFactory.swift new file mode 100644 index 00000000..dad5a899 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockBookmarkModalFactory.swift @@ -0,0 +1,18 @@ +import MLSCore +import MLSDictionaryFeatureInterface + +public final class MockBookmarkModalFactory: BookmarkModalFactory { + public init() {} + + public func make(bookmarkIds: [Int]) -> BaseViewController { + let viewcontroller = BaseViewController() + viewcontroller.view.backgroundColor = .redMLS + return viewcontroller + } + + public func make(bookmarkIds: [Int], onComplete: ((Bool) -> Void)? = nil) -> BaseViewController { + let viewcontroller = BaseViewController() + viewcontroller.view.backgroundColor = .redMLS + return viewcontroller + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockBookmarkRepository.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockBookmarkRepository.swift new file mode 100644 index 00000000..78f9a20c --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockBookmarkRepository.swift @@ -0,0 +1,41 @@ +import MLSCore +import MLSDictionaryFeatureInterface + +import RxSwift + +public final class MockBookmarkRepository: BookmarkRepository { + + public init() {} + + public func setBookmark(bookmarkId: Int, type: DictionaryItemType) -> Observable { + return .just(bookmarkId) + } + + public func deleteBookmark(bookmarkId: Int) -> Observable { + return .just(bookmarkId) + } + + public func fetchBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + return .just([]) + } + + public func fetchMonsterBookmark(minLevel: Int?, maxLevel: Int?, sort: String?) -> Observable<[BookmarkResponse]> { + return .just([]) + } + + public func fetchNPCBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + return .just([]) + } + + public func fetchQuestBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + return .just([]) + } + + public func fetchItemBookmark(jobId: Int?, minLevel: Int?, maxLevel: Int?, categoryIds: [Int]?, sort: String?) -> Observable<[BookmarkResponse]> { + return .just([]) + } + + public func fetchMapBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + return .just([]) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockDictionaryDetailAPIRepository.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockDictionaryDetailAPIRepository.swift new file mode 100644 index 00000000..5c45d81a --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockDictionaryDetailAPIRepository.swift @@ -0,0 +1,263 @@ +import MLSDictionaryFeatureInterface + +import RxSwift + +public final class MockDictionaryDetailAPIRepository: + DictionaryDetailAPIRepository { + + public init() {} + + // MARK: - Monster + + public func fetchMonsterDetail( + id: Int + ) -> Observable { + + .just( + DictionaryDetailMonsterResponse( + monsterId: id, + nameKr: "슬라임", + nameEn: "Slime", + imageUrl: "", + level: 1, + exp: 5, + hp: 50, + mp: 10, + physicalDefense: 3, + magicDefense: 2, + requiredAccuracy: 1, + bonusAccuracyPerLevelLower: 0, + evasionRate: 1, + mesoDropAmount: 12, + mesoDropRate: 100, + typeEffectiveness: Effectiveness( + fire: "WEAK", + lightning: nil, + poison: nil, + holy: nil, + ice: "RESIST", + physical: nil + ), + bookmarkId: nil + ) + ) + } + + public func fetchMonsterDetailDropItem( + id: Int, + sort: String? + ) -> Observable<[DictionaryDetailMonsterDropItemResponse]> { + + .just([ + DictionaryDetailMonsterDropItemResponse( + itemId: 1, + itemName: "빨간 포션", + dropRate: 0.5, + imageUrl: "", + itemLevel: 1 + ) + ]) + } + + public func fetchMonsterDetailMap( + id: Int + ) -> Observable<[DictionaryDetailMonsterMapResponse]> { + + .just([ + DictionaryDetailMonsterMapResponse( + mapId: 1, + mapName: "헤네시스 사냥터", + regionName: "빅토리아 아일랜드", + detailName: "동쪽 풀숲", + topRegionName: "헤네시스", + iconUrl: "", + maxSpawnCount: 10 + ) + ]) + } + + // MARK: - NPC + + public func fetchNpcDetail( + id: Int + ) -> Observable { + + .just( + DictionaryDetailNpcResponse( + npcId: id, + nameKr: "루크", + nameEn: "Luke", + iconUrlDetail: nil, + bookmarkId: nil + ) + ) + } + + public func fetchNpcDetailQuest( + id: Int, + sort: String? + ) -> Observable<[DictionaryDetailNpcQuestResponse]> { + + .just([ + DictionaryDetailNpcQuestResponse( + questId: 1, + questNameKr: "슬라임 퇴치", + questNameEn: "Slime Hunt", + questIconUrl: "", + minLevel: 1, + maxLevel: 10 + ) + ]) + } + + public func fetchNpcDetailMap( + id: Int + ) -> Observable<[DictionaryDetailMonsterMapResponse]> { + + .just([ + DictionaryDetailMonsterMapResponse( + mapId: 1, + mapName: "헤네시스", + regionName: "빅토리아 아일랜드", + detailName: "마을", + topRegionName: "헤네시스", + iconUrl: "", + maxSpawnCount: nil + ) + ]) + } + + // MARK: - Item + + public func fetchItemDetail( + id: Int + ) -> Observable { + + .just( + DictionaryDetailItemResponse( + itemId: id, + nameKr: "빨간 포션", + nameEn: "Red Potion", + descriptionText: "HP를 회복한다.", + imgUrl: "", + npcPrice: 50, + itemType: "consumable", + categoryHierachy: nil, + availableJobs: nil, + requiredStats: nil, + equipmentStats: nil, + scrollDetail: nil, + bookmarkId: nil + ) + ) + } + + public func fetchItemDetailDropMonster( + id: Int, + sort: String? + ) -> Observable<[DictionaryDetailItemDropMonsterResponse]> { + + .just([ + DictionaryDetailItemDropMonsterResponse( + monsterId: 1, + monsterName: "슬라임", + level: 1, + dropRate: 0.5, + imageUrl: "" + ) + ]) + } + + // MARK: - Quest + + public func fetchQuestDetail( + id: Int + ) -> Observable { + + .just( + DictionaryDetailQuestResponse( + questId: id, + titlePrefix: "[초보자]", + nameKr: "슬라임 퇴치", + nameEn: "Slime Hunt", + iconUrl: "", + questType: "NORMAL", + minLevel: 1, + maxLevel: 10, + requiredMesoStart: nil, + startNpcId: 1, + startNpcName: "루크", + endNpcId: 1, + endNpcName: "루크", + reward: MLSDictionaryFeatureInterface.Reward(exp: 100, meso: 50, popularity: nil), + rewardItems: [], + requirements: [], + allowedJobs: [], + bookmarkId: nil + ) + ) + } + + public func fetchQuestDetailLinkedQuestsDetail( + id: Int + ) -> Observable { + + .just( + DictionaryDetailQuestLinkedQuestsResponse( + previousQuests: [], + nextQuests: [] + ) + ) + } + + // MARK: - Map + + public func fetchMapDetail( + id: Int + ) -> Observable { + + .just( + DictionaryDetailMapResponse( + mapId: id, + nameKr: "헤네시스", + nameEn: "Henesys", + regionName: "빅토리아 아일랜드", + detailName: "마을", + topRegionName: "헤네시스", + mapUrl: "", + iconUrl: "", + bookmarkId: nil + ) + ) + } + + public func fetchMapDetailSpawnMonster( + id: Int, + sort: String? + ) -> Observable<[DictionaryDetailMapSpawnMonsterResponse]> { + + .just([ + DictionaryDetailMapSpawnMonsterResponse( + monsterId: 1, + monsterName: "슬라임", + level: 1, + maxSpawnCount: 10, + imageUrl: "" + ) + ]) + } + + public func fetchMapDetailNpc( + id: Int + ) -> Observable<[DictionaryDetailMapNpcResponse]> { + + .just([ + DictionaryDetailMapNpcResponse( + npcId: 1, + npcName: "루크", + npcNameEn: "Luke", + iconUrl: "" + ) + ]) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockDictionaryListAPIRepository.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockDictionaryListAPIRepository.swift new file mode 100644 index 00000000..a5d8ed13 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockDictionaryListAPIRepository.swift @@ -0,0 +1,235 @@ +import MLSDictionaryFeatureInterface + +import RxSwift + +public final class MockDictionaryListAPIRepository: + DictionaryListAPIRepository { + + public init() {} + + // MARK: - Search Count + + public func fetchSearchListCount( + type: String, + keyword: String? + ) -> Observable { + + .just( + SearchCountResponse(count: 99) + ) + } + + // MARK: - Search List + + public func fetchSearchList( + keyword: String? + ) -> Observable { + + .just(makeAllResponse()) + } + + // MARK: - All List + + public func fetchAllList( + keyword: String?, + page: Int? + ) -> Observable { + + .just(makeAllResponse()) + } + + // MARK: - Monster List + + public func fetchMonsterList( + keyword: String?, + minLevel: Int?, + maxLevel: Int?, + page: Int, + size: Int, + sort: String? + ) -> Observable { + + .just( + DictionaryMainResponse( + totalPages: 1, + totalElements: 1, + contents: [ + DictionaryMainItemResponse( + id: 1, + name: "슬라임", + imageUrl: nil, + level: 1, + type: .monster, + bookmarkId: nil + ) + ] + ) + ) + } + + // MARK: - NPC List + + public func fetchNpcList( + keyword: String, + page: Int, + size: Int, + sort: String? + ) -> Observable { + + .just( + DictionaryMainResponse( + totalPages: 1, + totalElements: 1, + contents: [ + DictionaryMainItemResponse( + id: 2, + name: "루크", + imageUrl: nil, + level: nil, + type: .npc, + bookmarkId: nil + ) + ] + ) + ) + } + + // MARK: - Quest List + + public func fetchQuestList( + keyword: String, + page: Int, + size: Int, + sort: String? + ) -> Observable { + + .just( + DictionaryMainResponse( + totalPages: 1, + totalElements: 1, + contents: [ + DictionaryMainItemResponse( + id: 3, + name: "슬라임 퇴치", + imageUrl: nil, + level: 10, + type: .quest, + bookmarkId: nil + ) + ] + ) + ) + } + + // MARK: - Item List + + public func fetchItemList( + keyword: String?, + jobId: [Int]?, + minLevel: Int?, + maxLevel: Int?, + categoryIds: [Int]?, + page: Int?, + size: Int?, + sort: String? + ) -> Observable { + + .just( + DictionaryMainResponse( + totalPages: 1, + totalElements: 1, + contents: [ + DictionaryMainItemResponse( + id: 4, + name: "빨간 포션", + imageUrl: nil, + level: 1, + type: .item, + bookmarkId: nil + ) + ] + ) + ) + } + + // MARK: - Map List + + public func fetchMapList( + keyword: String, + page: Int, + size: Int, + sort: String? + ) -> Observable { + + .just( + DictionaryMainResponse( + totalPages: 1, + totalElements: 1, + contents: [ + DictionaryMainItemResponse( + id: 5, + name: "헤네시스", + imageUrl: nil, + level: nil, + type: .map, + bookmarkId: nil + ) + ] + ) + ) + } +} + +// MARK: - Private + +private extension MockDictionaryListAPIRepository { + + func makeAllResponse() -> DictionaryMainResponse { + DictionaryMainResponse( + totalPages: 1, + totalElements: 5, + contents: [ + DictionaryMainItemResponse( + id: 1, + name: "슬라임", + imageUrl: nil, + level: 1, + type: .monster, + bookmarkId: nil + ), + DictionaryMainItemResponse( + id: 2, + name: "빨간 포션", + imageUrl: nil, + level: 1, + type: .item, + bookmarkId: nil + ), + DictionaryMainItemResponse( + id: 3, + name: "헤네시스", + imageUrl: nil, + level: nil, + type: .map, + bookmarkId: nil + ), + DictionaryMainItemResponse( + id: 4, + name: "루크", + imageUrl: nil, + level: nil, + type: .npc, + bookmarkId: nil + ), + DictionaryMainItemResponse( + id: 5, + name: "슬라임 퇴치", + imageUrl: nil, + level: 10, + type: .quest, + bookmarkId: nil + ) + ] + ) + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockFetchProfileUseCase.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockFetchProfileUseCase.swift new file mode 100644 index 00000000..43bc3908 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockFetchProfileUseCase.swift @@ -0,0 +1,13 @@ +import MLSMyPageFeatureInterface + +import RxSwift + +public final class MockFetchProfileUseCase: FetchProfileUseCase { + public var result: Observable = .just(nil) + + public init() {} + + public func execute() -> Observable { + result + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockNotificationSettingFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockNotificationSettingFactory.swift new file mode 100644 index 00000000..1600231f --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockNotificationSettingFactory.swift @@ -0,0 +1,15 @@ +import MLSCore +import MLSMyPageFeatureInterface + +public final class MockNotificationSettingFactory: NotificationSettingFactory { + public init() { + + } + + public func make(isAgreeEventNotification: Bool, isAgreeNoticeNotification: Bool, isAgreePatchNoteNotification: Bool) -> BaseViewController { + let viewcontroller = BaseViewController() + viewcontroller.view.backgroundColor = .redMLS + return viewcontroller + } +} + diff --git a/MLS/MLSDictionaryFeatureExample/AppDelegate.swift b/MLS/MLSDictionaryFeatureExample/AppDelegate.swift new file mode 100644 index 00000000..55e88a8e --- /dev/null +++ b/MLS/MLSDictionaryFeatureExample/AppDelegate.swift @@ -0,0 +1,18 @@ +import UIKit + +import MLSDesignSystem + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + FontManager.registerFonts() + return true + } + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + } +} diff --git a/MLS/MLSDictionaryFeatureExample/Assets.xcassets/AccentColor.colorset/Contents.json b/MLS/MLSDictionaryFeatureExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/MLS/MLSDictionaryFeatureExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDictionaryFeatureExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/MLS/MLSDictionaryFeatureExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/MLS/MLSDictionaryFeatureExample/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/MLSDictionaryFeatureExample/Assets.xcassets/Contents.json b/MLS/MLSDictionaryFeatureExample/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/MLS/MLSDictionaryFeatureExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSDictionaryFeatureExample/Base.lproj/LaunchScreen.storyboard b/MLS/MLSDictionaryFeatureExample/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..865e9329 --- /dev/null +++ b/MLS/MLSDictionaryFeatureExample/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MLS/MLSDictionaryFeatureExample/Info.plist b/MLS/MLSDictionaryFeatureExample/Info.plist new file mode 100644 index 00000000..0eb786dc --- /dev/null +++ b/MLS/MLSDictionaryFeatureExample/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/MLS/MLSDictionaryFeatureExample/SceneDelegate.swift b/MLS/MLSDictionaryFeatureExample/SceneDelegate.swift new file mode 100644 index 00000000..9167f9be --- /dev/null +++ b/MLS/MLSDictionaryFeatureExample/SceneDelegate.swift @@ -0,0 +1,148 @@ +import UIKit + +import MLSAuthFeatureTesting +import MLSDictionaryFeature +import MLSDictionaryFeatureInterface +import MLSDictionaryFeatureTesting +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 = makeDictionaryViewController() + let nav = UINavigationController(rootViewController: rootVC) + nav.navigationBar.isHidden = true + window.rootViewController = nav + window.makeKeyAndVisible() + } + + func makeDictionaryViewController() -> UIViewController { + // MARK: - Repository + let dictionaryListAPIRepository = MockDictionaryListAPIRepository() + let dictionaryDetailAPIRepository = MockDictionaryDetailAPIRepository() + let recentSearchRepository = RecentSearchRepositoryImpl() + let bookmarkRepository = MockBookmarkRepository() + let authRepository = MockAuthAPIRepository() + let tokenRepository = MockTokenRepository() + let alarmRepository = MockAlarmRepository() + + // MARK: - UseCase + let checkLoginUseCase = CheckLoginUseCaseImpl( + authRepository: authRepository, + tokenRepository: tokenRepository + ) + let setBookmarkUseCase = SetBookmarkUseCaseImpl( + repository: bookmarkRepository + ) + let parseItemFilterResultUseCase = ParseItemFilterResultUseCaseImpl() + let fetchVisitDictionaryDetailUseCase = + FetchVisitDictionaryDetailUseCaseImpl( + repository: UserDefaultsRepositoryImpl() + ) + let fetchProfileUseCase = MockFetchProfileUseCase() + let checkNotificationPermissionUseCase = + CheckNotificationPermissionUseCaseImpl() + + // MARK: - Factory + let loginFactory = MockLoginFactory() + let bookmarkModalFactory = MockBookmarkModalFactory() + let itemFilterFactory = ItemFilterBottomSheetFactoryImpl() + let monsterFilterFactory = MonsterFilterBottomSheetFactoryImpl() + let sortedFactory = SortedBottomSheetFactoryImpl() + let detailOnBoardingFactory = DetailOnBoardingFactoryImpl() + let notificationSettingFactory = + MockNotificationSettingFactory() + + // MARK: - Detail Factory + var detailFactory: DictionaryDetailFactoryImpl! + + detailFactory = DictionaryDetailFactoryImpl( + loginFactory: { + loginFactory + }, + bookmarkModalFactory: bookmarkModalFactory, + dictionaryDetailFactory: { + detailFactory + }, + detailOnBoardingFactory: detailOnBoardingFactory, + appCoordinator: { + MockAppCoordinator() + }, + dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, + checkLoginUseCase: checkLoginUseCase, + setBookmarkUseCase: setBookmarkUseCase, + fetchVisitDictionaryDetailUseCase: + fetchVisitDictionaryDetailUseCase + ) + + // MARK: - Dictionary Main List Factory + let dictionaryMainListFactory = DictionaryListFactoryImpl( + checkLoginUseCase: checkLoginUseCase, + setBookmarkUseCase: setBookmarkUseCase, + parseItemFilterResultUseCase: + parseItemFilterResultUseCase, + dictionaryListAPIRepository: + dictionaryListAPIRepository, + itemFilterFactory: itemFilterFactory, + monsterFilterFactory: monsterFilterFactory, + sortedFactory: sortedFactory, + bookmarkModalFactory: bookmarkModalFactory, + detailFactory: detailFactory + ) { + MockLoginFactory() + } + + // MARK: - Search Factory + let dictionarySearchResultFactory = + DictionarySearchResultFactoryImpl( + dictionaryListAPIRepository: + dictionaryListAPIRepository, + recentSearchRepository: + recentSearchRepository, + dictionaryMainListFactory: + dictionaryMainListFactory + ) + let dictionarySearchFactory = + DictionarySearchFactoryImpl( + recentSearchRepository: + recentSearchRepository, + searchResultFactory: + dictionarySearchResultFactory + ) + + // MARK: - Notification Factory + let dictionaryNotificationFactory = + DictionaryNotificationFactoryImpl( + notificationSettingFactory: + notificationSettingFactory, + fetchProfileUseCase: + fetchProfileUseCase, + checkNotificationPermissionUseCase: + checkNotificationPermissionUseCase, + alarmRepository: + alarmRepository + ) + + // MARK: - Main Factory + let dictionaryFactory = DictionaryMainViewFactoryImpl( + dictionaryMainListFactory: + dictionaryMainListFactory, + searchFactory: + dictionarySearchFactory, + notificationFactory: + dictionaryNotificationFactory, + loginFactory: + loginFactory, + fetchProfileUseCase: + fetchProfileUseCase + ) + + return dictionaryFactory.make() + } +} diff --git a/MLS/MLSDictionaryFeatureExample/ViewController.swift b/MLS/MLSDictionaryFeatureExample/ViewController.swift new file mode 100644 index 00000000..bf76c187 --- /dev/null +++ b/MLS/MLSDictionaryFeatureExample/ViewController.swift @@ -0,0 +1,4 @@ +import UIKit + +// SceneDelegate에서 직접 DictionaryMainViewController를 띄우기 때문에 사용하지 않습니다. +class ViewController: UIViewController {} From d01d670722bd4e23074e63146092299c4c3a51ac Mon Sep 17 00:00:00 2001 From: p2glet Date: Wed, 27 May 2026 23:32:01 +0900 Subject: [PATCH 05/16] =?UTF-8?q?test/332:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=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/MLSDictionaryFeature/Package.swift | 1 + .../MonsterDictionaryDetailReactor.swift | 14 +- .../DictionaryListReactor.swift | 4 +- .../Mock/MockBookmarkFailRepository.swift | 53 ++++ .../Mock/MockRecentSearchRepository.swift | 30 ++ .../DictionaryListReactorTests.swift.swift | 169 +++++++++++ .../DictionarySearchReactorTests.swift | 222 +++++++++++++++ .../ItemDictionaryDetailReactorTests.swift | 77 +++++ .../MLSDictionaryFeatureTests.swift | 6 - .../MapDictionaryDetailReactorTests.swift | 87 ++++++ .../MonsterDictionaryDetailReactorTests.swift | 269 ++++++++++++++++++ .../NpcDictionaryDetailReactorTests.swift | 88 ++++++ .../ParseItemFilterResultUseCaseTests.swift | 119 ++++++++ .../QuestDictionaryDetailReactorTests.swift | 80 ++++++ .../SceneDelegate.swift | 2 +- 15 files changed, 1205 insertions(+), 16 deletions(-) create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockBookmarkFailRepository.swift create mode 100644 MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockRecentSearchRepository.swift create mode 100644 MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/DictionaryListReactorTests.swift.swift create mode 100644 MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/DictionarySearchReactorTests.swift create mode 100644 MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/ItemDictionaryDetailReactorTests.swift delete mode 100644 MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MLSDictionaryFeatureTests.swift create mode 100644 MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MapDictionaryDetailReactorTests.swift create mode 100644 MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MonsterDictionaryDetailReactorTests.swift create mode 100644 MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/NpcDictionaryDetailReactorTests.swift create mode 100644 MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/ParseItemFilterResultUseCaseTests.swift create mode 100644 MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/QuestDictionaryDetailReactorTests.swift diff --git a/MLS/MLSDictionaryFeature/Package.swift b/MLS/MLSDictionaryFeature/Package.swift index bfff778b..eea6680f 100644 --- a/MLS/MLSDictionaryFeature/Package.swift +++ b/MLS/MLSDictionaryFeature/Package.swift @@ -78,6 +78,7 @@ let package = Package( "MLSDictionaryFeatureInterface", "MLSDictionaryFeatureTesting", .product(name: "MLSAuthFeatureInterface", package: "MLSAuthFeature"), + .product(name: "MLSAuthFeatureTesting", package: "MLSAuthFeature"), .product(name: "RxBlocking", package: "RxSwift") ], ) diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift index f73cc40f..e50401af 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift @@ -36,7 +36,7 @@ public final class MonsterDictionaryDetailReactor: Reactor { // MARK: - Mutation public enum Mutation { - case navigatTo(Route) + case navigateTo(Route) case setDetailData(DictionaryDetailMonsterResponse) case setDetailDropItemData([DictionaryDetailMonsterDropItemResponse]) case setDetailMapData([DictionaryDetailMonsterMapResponse]) @@ -99,7 +99,7 @@ public final class MonsterDictionaryDetailReactor: Reactor { public func mutate(action: Action) -> Observable { switch action { case let .filterButtonTapped(type): - return .just(.navigatTo(.filter(type: type, sort: type == .map ? currentState.mapFilter : currentState.itemFilter))) + return .just(.navigateTo(.filter(type: type, sort: type == .map ? currentState.mapFilter : currentState.itemFilter))) case .viewWillAppear: return .merge([ @@ -119,10 +119,10 @@ public final class MonsterDictionaryDetailReactor: Reactor { return handleUndoLastDeletedBookmark() case .itemTapped(index: let index): - return .just(.navigatTo(.detail(type: .item, id: currentState.dropItems[index].itemId))) + return .just(.navigateTo(.detail(type: .item, id: currentState.dropItems[index].itemId))) case .mapTapped(index: let index): - return .just(.navigatTo(.detail(type: .map, id: currentState.spawnMaps[index].mapId))) + return .just(.navigateTo(.detail(type: .map, id: currentState.spawnMaps[index].mapId))) } } @@ -131,7 +131,7 @@ public final class MonsterDictionaryDetailReactor: Reactor { var newState = state switch mutation { - case let .navigatTo(route): + case let .navigateTo(route): newState.route = route case let .setDetailData(data): @@ -190,7 +190,7 @@ private extension MonsterDictionaryDetailReactor { return .concat([eventMutation, refresh]) } .catch { _ in - .just(.navigatTo(.bookmarkError)) + .just(.navigateTo(.bookmarkError)) } } @@ -213,7 +213,7 @@ private extension MonsterDictionaryDetailReactor { return .concat([eventMutation, refresh]) } .catch { _ in - .just(.navigatTo(.bookmarkError)) + .just(.navigateTo(.bookmarkError)) } } } diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListReactor.swift index 04a664bd..bd282293 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListReactor.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListReactor.swift @@ -56,7 +56,7 @@ public final class DictionaryListReactor: Reactor { // MARK: - State public struct State { - @Pulse var uiEvent: UIEvent = .none + @Pulse public var uiEvent: UIEvent = .none @Pulse var route: Route public var listItems: [DictionaryMainItemResponse] = [] public var type: DictionaryType @@ -74,7 +74,7 @@ public final class DictionaryListReactor: Reactor { var isLogin: Bool var lastDeletedBookmark: DictionaryMainItemResponse? var isBookmarkUpdateOnly = false - var isFirstFetch = true + public var isFirstFetch = true } public var initialState: State diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockBookmarkFailRepository.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockBookmarkFailRepository.swift new file mode 100644 index 00000000..ff4908f2 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockBookmarkFailRepository.swift @@ -0,0 +1,53 @@ +import Foundation + +import MLSCore +import MLSDictionaryFeatureInterface + +import RxSwift + +public final class MockBookmarkFailRepository: BookmarkRepository { + + public init() {} + + private func failError() -> Observable { + return Observable.error( + NSError( + domain: "MockBookmarkFailRepository", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Mock failure error"] + ) + ) + } + + public func setBookmark(bookmarkId: Int, type: DictionaryItemType) -> Observable { + return failError() + } + + public func deleteBookmark(bookmarkId: Int) -> Observable { + return failError() + } + + public func fetchBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + return failError() + } + + public func fetchMonsterBookmark(minLevel: Int?, maxLevel: Int?, sort: String?) -> Observable<[BookmarkResponse]> { + return failError() + } + + public func fetchNPCBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + return failError() + } + + public func fetchQuestBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + return failError() + } + + public func fetchItemBookmark(jobId: Int?, minLevel: Int?, maxLevel: Int?, categoryIds: [Int]?, sort: String?) -> Observable<[BookmarkResponse]> { + return failError() + } + + public func fetchMapBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + return failError() + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockRecentSearchRepository.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockRecentSearchRepository.swift new file mode 100644 index 00000000..8ff3d2cb --- /dev/null +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockRecentSearchRepository.swift @@ -0,0 +1,30 @@ +import MLSDictionaryFeatureInterface + +import RxSwift + +final public class MockRecentSearchRepository: RecentSearchRepository { + private var searches: [String] + + public init(searches: [String] = ["사과", "바나나", "포션"]) { + self.searches = searches + } + + public func fetchRecentSearch() -> Observable<[String]> { + return .just(searches) + } + + public func addRecentSearch(keyword: String) -> Completable { + searches.insert(keyword, at: 0) + return .empty() + } + + public func removeRecentSearch(keyword: String) -> Completable { + searches.removeAll { $0 == keyword } + return .empty() + } + + public func removeAllSearch() -> Completable { + searches.removeAll() + return .empty() + } +} diff --git a/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/DictionaryListReactorTests.swift.swift b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/DictionaryListReactorTests.swift.swift new file mode 100644 index 00000000..2ac03e24 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/DictionaryListReactorTests.swift.swift @@ -0,0 +1,169 @@ +import RxBlocking +import Testing + +@testable import MLSAuthFeatureTesting +@testable import MLSDictionaryFeature +@testable import MLSDictionaryFeatureInterface +@testable import MLSDictionaryFeatureTesting + +@Suite("DictionaryListReactorTests") +struct DictionaryListReactorTests { + // MARK: - Mutate + + @Test("viewWillAppear 시 리스트 mutation 발생") + func viewWillAppear_emitListMutation() throws { + let reactor = makeSUT(type: .monster) + + let mutations = try reactor + .mutate(action: .viewWillAppear) + .toBlocking() + .toArray() + + let hasListMutation = mutations.contains { + if case .setListItem = $0 { + return true + } + return false + } + + #expect(hasListMutation) + } + + @Test("정렬 선택 시 sort mutation 발생") + func sortOptionSelected_emitSortMutation() throws { + let reactor = makeSUT(type: .monster) + + let mutations = try reactor + .mutate(action: .sortOptionSelected(.expASC)) + .toBlocking() + .toArray() + + let hasSortMutation = mutations.contains { + if case .setSort(let value) = $0 { + return value == "exp,asc" + } + return false + } + + #expect(hasSortMutation) + } + + @Test("필터 선택 시 filter mutation 발생") + func filterOptionSelected_emitFilterMutation() throws { + let reactor = makeSUT(type: .monster) + + let mutations = try reactor + .mutate( + action: .filterOptionSelected( + startLevel: 10, + endLevel: 30 + ) + ) + .toBlocking() + .toArray() + + let hasFilterMutation = mutations.contains { + if case .setFilter(let start, let end) = $0 { + return start == 10 && end == 30 + } + return false + } + + #expect(hasFilterMutation) + } + + @Test("로그인 액션 시 login 이벤트 발생") + func showLogin_emitLoginEvent() throws { + let reactor = makeSUT(type: .monster) + + let mutation = try reactor + .mutate(action: .showLogin) + .toBlocking() + .first() + + switch mutation { + case .setEvent(let event): + switch event { + case .login: + #expect(true) + default: + Issue.record("Expected login event") + } + + default: + Issue.record("Expected setEvent mutation") + } + } + + // MARK: - Reduce + + @Test("setCurrentPage mutation 시 페이지 증가") + func reduce_setCurrentPage() { + let reactor = makeSUT(type: .monster) + + let newState = reactor.reduce( + state: reactor.initialState, + mutation: .setCurrentPage + ) + + #expect(newState.currentPage == 1) + } + + @Test("updateBookmarkId mutation 시 bookmarkId 변경") + func reduce_updateBookmarkId() { + let reactor = makeSUT(type: .monster) + + var state = reactor.initialState + + state.listItems = [ + DictionaryMainItemResponse( + id: 1, + name: "슬라임", + imageUrl: "", + level: 1, + type: .monster, + bookmarkId: nil + ) + ] + + let newState = reactor.reduce( + state: state, + mutation: .updateBookmarkId( + id: 1, + newBookmarkId: 999 + ) + ) + + #expect(newState.listItems.first?.bookmarkId == 999) + } +} + +// MARK: - Helpers + +private extension DictionaryListReactorTests { + func makeSUT( + type: DictionaryType, + keyword: String? = nil + ) -> DictionaryListReactor { + DictionaryListReactor( + type: type, + keyword: keyword, + dictionaryListAPIRepository: + MockDictionaryListAPIRepository(), + + checkLoginUseCase: + CheckLoginUseCaseImpl( + authRepository: MockAuthAPIRepository(), + tokenRepository: MockTokenRepository() + ), + + setBookmarkUseCase: + SetBookmarkUseCaseImpl( + repository: MockBookmarkRepository() + ), + + parseItemFilterResultUseCase: + ParseItemFilterResultUseCaseImpl() + ) + } +} diff --git a/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/DictionarySearchReactorTests.swift b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/DictionarySearchReactorTests.swift new file mode 100644 index 00000000..765f3101 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/DictionarySearchReactorTests.swift @@ -0,0 +1,222 @@ +import RxBlocking +import Testing + +@testable import MLSDictionaryFeature +@testable import MLSDictionaryFeatureInterface +@testable import MLSDictionaryFeatureTesting + +@Suite("DictionarySearchReactorTests") +struct DictionarySearchReactorTests { + // MARK: - ViewWillAppear + + @Test("최근 검색어 조회") + func test_viewWillAppear_fetchRecentSearches() throws { + let reactor = makeSUT() + + let mutations = try reactor + .mutate(action: .viewWillAppear) + .toBlocking() + .toArray() + + let hasRecentListMutation = mutations.contains { + if case .setRecentList(let list) = $0 { + return list.isEmpty == false + } + return false + } + + #expect(hasRecentListMutation) + } + + // MARK: - Search + + @Test("검색 실행 시 최근 검색어 추가 및 화면 이동") + func test_searchButtonTapped_addRecentAndNavigate() throws { + let reactor = makeSUT() + + let mutations = try reactor + .mutate(action: .searchButtonTapped("슬라임")) + .toBlocking() + .toArray() + + let hasAddRecentMutation = mutations.contains { + if case .addRecentItem(let keyword) = $0 { + return keyword == "슬라임" + } + return false + } + + let hasNavigateMutation = mutations.contains { + if case .navigateTo(let route) = $0 { + switch route { + case .search(let keyword): + return keyword == "슬라임" + default: + return false + } + } + return false + } + + #expect(hasAddRecentMutation) + #expect(hasNavigateMutation) + } + + @Test("최근 검색어 클릭 시 검색 화면 이동") + func test_recentButtonTapped_navigateSearch() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .recentButtonTapped("주황버섯")) + .toBlocking() + .first() + + guard case .navigateTo(let route)? = mutation else { + Issue.record("Expected navigateTo mutation") + return + } + + switch route { + case .search(let keyword): + #expect(keyword == "주황버섯") + + default: + Issue.record("Expected search route") + } + } + + // MARK: - Delete Recent + + @Test("최근 검색어 삭제") + func test_cancelRecentButtonTapped_deleteRecent() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .cancelRecentButtonTapped("슬라임")) + .toBlocking() + .first() + + guard case .deleteItem(let keyword)? = mutation else { + Issue.record("Expected deleteItem mutation") + return + } + + #expect(keyword == "슬라임") + } + + @Test("전체 최근 검색어 삭제") + func test_deleteAllButtonTapped_deleteAll() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .deleteAllButtonTapped) + .toBlocking() + .first() + + guard case .deleteAllItems? = mutation else { + Issue.record("Expected deleteAllItems mutation") + return + } + + #expect(true) + } + + // MARK: - Navigation + + @Test("뒤로가기 클릭 시 dismiss 이동") + func test_backButtonTapped_dismiss() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .backButtonTapped) + .toBlocking() + .first() + + guard case .navigateTo(let route)? = mutation else { + Issue.record("Expected navigateTo mutation") + return + } + + switch route { + case .dismiss: + #expect(true) + + default: + Issue.record("Expected dismiss route") + } + } + + // MARK: - Reduce + + @Test("reduce - 최근 검색어 추가") + func test_reduce_addRecentItem() { + let reactor = makeSUT() + + let newState = reactor.reduce( + state: reactor.initialState, + mutation: .addRecentItem("슬라임") + ) + + #expect(newState.recentResult.first == "슬라임") + } + + @Test("reduce - 최근 검색어 삭제") + func test_reduce_deleteItem() { + let reactor = makeSUT() + + var state = reactor.initialState + state.recentResult = ["슬라임", "주황버섯"] + + let newState = reactor.reduce( + state: state, + mutation: .deleteItem("슬라임") + ) + + #expect(newState.recentResult.contains("슬라임") == false) + #expect(newState.recentResult.count == 1) + } + + @Test("reduce - 최근 검색어 전체 삭제") + func test_reduce_deleteAllItems() { + let reactor = makeSUT() + + var state = reactor.initialState + state.recentResult = ["슬라임", "주황버섯"] + + let newState = reactor.reduce( + state: state, + mutation: .deleteAllItems + ) + + #expect(newState.recentResult.isEmpty) + } + + @Test("reduce - route 상태 변경") + func test_reduce_navigateTo() { + let reactor = makeSUT() + + let newState = reactor.reduce( + state: reactor.initialState, + mutation: .navigateTo(.search("슬라임")) + ) + + switch newState.route { + case .search(let keyword): + #expect(keyword == "슬라임") + + default: + Issue.record("Expected search route") + } + } +} + +// MARK: - Helpers + +private extension DictionarySearchReactorTests { + func makeSUT() -> DictionarySearchReactor { + DictionarySearchReactor( + recentSearchRepository: + MockRecentSearchRepository() + ) + } +} diff --git a/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/ItemDictionaryDetailReactorTests.swift b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/ItemDictionaryDetailReactorTests.swift new file mode 100644 index 00000000..37d6072e --- /dev/null +++ b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/ItemDictionaryDetailReactorTests.swift @@ -0,0 +1,77 @@ +import RxBlocking +import Testing + +@testable import MLSAuthFeatureTesting +@testable import MLSDictionaryFeature +@testable import MLSDictionaryFeatureInterface +@testable import MLSDictionaryFeatureTesting + +// MARK: - ItemDetail + +@Suite("ItemDictionaryDetailReactorTests") +struct ItemDictionaryDetailReactorTests { + @Test("상세 진입 시 상세 데이터 조회") + func test_fetchItemDetail() throws { + let reactor = makeSUT() + + let state = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + #expect(state.itemDetailInfo.itemId == 1) + } + + @Test("드롭 몬스터 조회 성공") + func test_fetchDropMonsters() throws { + let reactor = makeSUT() + + let state = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + #expect(state.monsters.isEmpty == false) + } +} + +// MARK: - Reduce Helpers + +private func reduce( + _ reactor: ItemDictionaryDetailReactor, + from state: ItemDictionaryDetailReactor.State, + action: ItemDictionaryDetailReactor.Action +) throws -> ItemDictionaryDetailReactor.State { + let mutations = try reactor + .mutate(action: action) + .toBlocking() + .toArray() + + return mutations.reduce(state) { + reactor.reduce(state: $0, mutation: $1) + } +} + +// MARK: - SUT + +private func makeSUT() -> ItemDictionaryDetailReactor { + ItemDictionaryDetailReactor( + dictionaryDetailAPIRepository: + MockDictionaryDetailAPIRepository(), + + checkLoginUseCase: + CheckLoginUseCaseImpl( + authRepository: MockAuthAPIRepository(), + tokenRepository: MockTokenRepository() + ), + + setBookmarkUseCase: + SetBookmarkUseCaseImpl( + repository: MockBookmarkRepository() + ), + + id: 1 + ) +} diff --git a/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MLSDictionaryFeatureTests.swift b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MLSDictionaryFeatureTests.swift deleted file mode 100644 index 83b3d0ce..00000000 --- a/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MLSDictionaryFeatureTests.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Testing -@testable import MLSDictionaryFeature - -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. -} diff --git a/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MapDictionaryDetailReactorTests.swift b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MapDictionaryDetailReactorTests.swift new file mode 100644 index 00000000..8f47c243 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MapDictionaryDetailReactorTests.swift @@ -0,0 +1,87 @@ +import RxBlocking +import Testing + +@testable import MLSAuthFeatureTesting +@testable import MLSDictionaryFeature +@testable import MLSDictionaryFeatureInterface +@testable import MLSDictionaryFeatureTesting + +@Suite("MapDictionaryDetailReactorTests") +struct MapDictionaryDetailReactorTests { + @Test("상세 진입 시 상세 데이터 조회") + func test_fetchMapDetail() throws { + let reactor = makeSUT() + + let state = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + #expect(state.mapDetailInfo.mapId == 1) + } + + @Test("스폰 몬스터 조회 성공") + func test_fetchSpawnMonsters() throws { + let reactor = makeSUT() + + let state = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + #expect(state.spawnMonsters.isEmpty == false) + } + + @Test("NPC 조회 성공") + func test_fetchNpc() throws { + let reactor = makeSUT() + + let state = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + #expect(state.npcs.isEmpty == false) + } +} + +// MARK: - Reduce Helpers +private func reduce( + _ reactor: MapDictionaryDetailReactor, + from state: MapDictionaryDetailReactor.State, + action: MapDictionaryDetailReactor.Action +) throws -> MapDictionaryDetailReactor.State { + let mutations = try reactor + .mutate(action: action) + .toBlocking() + .toArray() + + return mutations.reduce(state) { + reactor.reduce(state: $0, mutation: $1) + } +} + +// MARK: - SUT + +private func makeSUT() -> MapDictionaryDetailReactor { + MapDictionaryDetailReactor( + dictionaryDetailAPIRepository: + MockDictionaryDetailAPIRepository(), + + checkLoginUseCase: + CheckLoginUseCaseImpl( + authRepository: MockAuthAPIRepository(), + tokenRepository: MockTokenRepository() + ), + + setBookmarkUseCase: + SetBookmarkUseCaseImpl( + repository: MockBookmarkRepository() + ), + + id: 1 + ) +} diff --git a/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MonsterDictionaryDetailReactorTests.swift b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MonsterDictionaryDetailReactorTests.swift new file mode 100644 index 00000000..e9d8f4e7 --- /dev/null +++ b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/MonsterDictionaryDetailReactorTests.swift @@ -0,0 +1,269 @@ +import RxBlocking +import Testing + +@testable import MLSAuthFeatureTesting +@testable import MLSDictionaryFeature +@testable import MLSDictionaryFeatureInterface +@testable import MLSDictionaryFeatureTesting + +// MARK: - MonsterDetail + +@Suite("MonsterDictionaryDetailReactorTests") +struct MonsterDictionaryDetailReactorTests { + // MARK: - Fetch + + @Test("상세 진입 시 상세 데이터 조회") + func test_fetchMonsterDetail() throws { + let reactor = makeSUT() + + let state = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + #expect(state.monsterDetailInfo.monsterId == 1) + } + + @Test("드롭 아이템 조회 성공") + func test_fetchDropItems() throws { + let reactor = makeSUT() + + let state = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + #expect(state.dropItems.isEmpty == false) + } + + @Test("맵 정보 조회 성공") + func test_fetchMaps() throws { + let reactor = makeSUT() + + let state = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + #expect(state.spawnMaps.isEmpty == false) + } + + @Test("로그인 상태 조회") + func test_loginState() throws { + let reactor = makeSUT() + + let state = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + #expect(state.isLogin == false) + } + + // MARK: - Bookmark + + @Test("북마크 추가 성공") + func test_toggleBookmark_addBookmark() throws { + let reactor = makeSUT() + + let loadedState = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + let bookmarkState = try reduce( + reactor, + from: loadedState, + action: .toggleBookmark + ) + + switch bookmarkState.event { + case .add: + #expect(true) + + default: + Issue.record("Expected add event") + } + } + + @Test("북마크 undo 성공") + func test_undoBookmark() throws { + let reactor = makeSUT() + + let loadedState = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + _ = try reduce( + reactor, + from: loadedState, + action: .toggleBookmark + ) + + let undoState = try reduce( + reactor, + from: loadedState, + action: .undoLastDeletedBookmark + ) + + switch undoState.event { + case .add: + #expect(true) + + default: + Issue.record("Expected add event") + } + } + + @Test("북마크 실패 시 에러 route 이동") + func test_toggleBookmark_failure() throws { + let reactor = makeFailureSUT() + + let mutations = try reactor + .mutate(action: .toggleBookmark) + .toBlocking() + .toArray() + + let hasErrorRoute = mutations.contains { + if case .navigateTo(.bookmarkError) = $0 { + return true + } + return false + } + + #expect(hasErrorRoute) + } + + // MARK: - Route + + @Test("드롭 아이템 클릭 시 상세 이동") + func test_reduce_itemDetailRoute() { + let reactor = makeSUT() + + let newState = reactor.reduce( + state: reactor.initialState, + mutation: .navigateTo( + .detail(type: .item, id: 1) + ) + ) + + switch newState.route { + case let .detail(type, id): + #expect(type == .item) + #expect(id == 1) + + default: + Issue.record("Expected item detail route") + } + } + + @Test("맵 클릭 시 상세 이동") + func test_reduce_mapDetailRoute() { + let reactor = makeSUT() + + let newState = reactor.reduce( + state: reactor.initialState, + mutation: .navigateTo( + .detail(type: .map, id: 1) + ) + ) + + switch newState.route { + case let .detail(type, id): + #expect(type == .map) + #expect(id == 1) + + default: + Issue.record("Expected map detail route") + } + } + + @Test("필터 버튼 클릭 시 필터 화면 이동") + func test_filterButtonTapped_navigateFilter() throws { + let reactor = makeSUT() + + let mutation = try reactor + .mutate(action: .filterButtonTapped(.item)) + .toBlocking() + .first() + + guard case let .navigateTo(route)? = mutation else { + Issue.record("Expected navigate route") + return + } + + switch route { + case let .filter(type, _): + #expect(type == .item) + + default: + Issue.record("Expected filter route") + } + } +} + +// MARK: - Reduce Helpers + +private func reduce( + _ reactor: MonsterDictionaryDetailReactor, + from state: MonsterDictionaryDetailReactor.State, + action: MonsterDictionaryDetailReactor.Action +) throws -> MonsterDictionaryDetailReactor.State { + let mutations = try reactor + .mutate(action: action) + .toBlocking() + .toArray() + + return mutations.reduce(state) { + reactor.reduce(state: $0, mutation: $1) + } +} + +// MARK: - SUT + +private func makeSUT() -> MonsterDictionaryDetailReactor { + MonsterDictionaryDetailReactor( + dictionaryDetailAPIRepository: + MockDictionaryDetailAPIRepository(), + + checkLoginUseCase: + CheckLoginUseCaseImpl( + authRepository: MockAuthAPIRepository(), + tokenRepository: MockTokenRepository() + ), + + setBookmarkUseCase: + SetBookmarkUseCaseImpl( + repository: MockBookmarkRepository() + ), + + id: 1 + ) +} + +private func makeFailureSUT() -> MonsterDictionaryDetailReactor { + MonsterDictionaryDetailReactor( + dictionaryDetailAPIRepository: + MockDictionaryDetailAPIRepository(), + + checkLoginUseCase: + CheckLoginUseCaseImpl( + authRepository: MockAuthAPIRepository(), + tokenRepository: MockTokenRepository() + ), + + setBookmarkUseCase: + SetBookmarkUseCaseImpl( + repository: MockBookmarkFailRepository() + ), + + id: 1 + ) +} diff --git a/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/NpcDictionaryDetailReactorTests.swift b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/NpcDictionaryDetailReactorTests.swift new file mode 100644 index 00000000..fdb3fe6b --- /dev/null +++ b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/NpcDictionaryDetailReactorTests.swift @@ -0,0 +1,88 @@ +import RxBlocking +import Testing + +@testable import MLSAuthFeatureTesting +@testable import MLSDictionaryFeature +@testable import MLSDictionaryFeatureInterface +@testable import MLSDictionaryFeatureTesting + +// MARK: - NpcDetail + +@Suite("NpcDictionaryDetailReactorTests") +struct NpcDictionaryDetailReactorTests { + @Test("상세 진입 시 상세 데이터 조회") + func test_fetchNpcDetail() throws { + let reactor = makeSUT() + + let state = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + #expect(state.npcDetailInfo.npcId == 1) + } + + @Test("상세 맵 조회 성공") + func test_fetchMaps() throws { + let reactor = makeSUT() + + let state = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + #expect(state.maps.isEmpty == false) + } + + @Test("퀘스트 조회 성공") + func test_fetchQuests() throws { + let reactor = makeSUT() + + let state = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + #expect(state.quests.isEmpty == false) + } +} + +// MARK: - Reduce Helpers +private func reduce( + _ reactor: NpcDictionaryDetailReactor, + from state: NpcDictionaryDetailReactor.State, + action: NpcDictionaryDetailReactor.Action +) throws -> NpcDictionaryDetailReactor.State { + let mutations = try reactor + .mutate(action: action) + .toBlocking() + .toArray() + + return mutations.reduce(state) { + reactor.reduce(state: $0, mutation: $1) + } +} + +// MARK: - SUT +private func makeSUT() -> NpcDictionaryDetailReactor { + NpcDictionaryDetailReactor( + dictionaryDetailAPIRepository: + MockDictionaryDetailAPIRepository(), + + checkLoginUseCase: + CheckLoginUseCaseImpl( + authRepository: MockAuthAPIRepository(), + tokenRepository: MockTokenRepository() + ), + + setBookmarkUseCase: + SetBookmarkUseCaseImpl( + repository: MockBookmarkRepository() + ), + + id: 1 + ) +} diff --git a/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/ParseItemFilterResultUseCaseTests.swift b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/ParseItemFilterResultUseCaseTests.swift new file mode 100644 index 00000000..57beb5fe --- /dev/null +++ b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/ParseItemFilterResultUseCaseTests.swift @@ -0,0 +1,119 @@ +import Testing + +@testable import MLSDictionaryFeature + +@Suite("ParseItemFilterResultUseCaseTests") +struct ParseItemFilterResultUseCaseTests { + let sut = ParseItemFilterResultUseCaseImpl() + + // MARK: - Job + + @Test("직업 필터 파싱") + func test_parseJobFilter() { + let result = sut.execute( + results: [ + ("직업", "전사"), + ("직업", "궁수") + ] + ) + + #expect(result.jobIds == [100, 300]) + } + + // MARK: - Level + + @Test("레벨 범위 파싱") + func test_parseLevelRange() { + let result = sut.execute( + results: [ + ("레벨", "10레벨 ~ 30레벨") + ] + ) + + #expect(result.startLevel == 10) + #expect(result.endLevel == 30) + } + + // MARK: - Category + + @Test("카테고리 파싱") + func test_parseCategoryFilter() { + let result = sut.execute( + results: [ + ("무기", "한손검"), + ("방어구", "모자") + ] + ) + + #expect(result.categoryIds == [7, 24]) + } + + @Test("주문서 카테고리 파싱") + func test_parseScrollCategoryFilter() { + let result = sut.execute( + results: [ + ("무기주문서", "한손검") + ] + ) + + #expect(result.categoryIds == [50]) + } + + // MARK: - Empty + + @Test("빈 값 처리") + func test_parseEmptyValues() { + let result = sut.execute(results: []) + + #expect(result.jobIds.isEmpty) + #expect(result.categoryIds.isEmpty) + #expect(result.startLevel == nil) + #expect(result.endLevel == nil) + } + + // MARK: - Duplicate + + @Test("중복 값 허용") + func test_duplicateValuesAllowed() { + let result = sut.execute( + results: [ + ("직업", "전사"), + ("직업", "전사") + ] + ) + + #expect(result.jobIds == [100, 100]) + } + + // MARK: - Invalid + + @Test("잘못된 값은 무시") + func test_ignoreInvalidValues() { + let result = sut.execute( + results: [ + ("직업", "해적"), + ("무기", "레이저건") + ] + ) + + #expect(result.jobIds.isEmpty) + #expect(result.categoryIds.isEmpty) + } + + @Test("복합 필터 파싱") + + func test_parseComplexFilters() { + let result = sut.execute( + results: [ + ("직업", "전사"), + ("레벨", "10레벨 ~ 30레벨"), + ("무기", "한손검") + ] + ) + + #expect(result.jobIds == [100]) + #expect(result.startLevel == 10) + #expect(result.endLevel == 30) + #expect(result.categoryIds == [7]) + } +} diff --git a/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/QuestDictionaryDetailReactorTests.swift b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/QuestDictionaryDetailReactorTests.swift new file mode 100644 index 00000000..c386e52c --- /dev/null +++ b/MLS/MLSDictionaryFeature/Tests/MLSDictionaryFeatureTests/QuestDictionaryDetailReactorTests.swift @@ -0,0 +1,80 @@ +import RxBlocking +import Testing + +@testable import MLSAuthFeatureTesting +@testable import MLSDictionaryFeature +@testable import MLSDictionaryFeatureInterface +@testable import MLSDictionaryFeatureTesting + +// MARK: - QuestDetail + +@Suite("QuestDictionaryDetailReactorTests") +struct QuestDictionaryDetailReactorTests { + @Test("상세 진입 시 상세 데이터 조회") + func test_fetchQuestDetail() throws { + let reactor = makeSUT() + + let state = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + #expect(state.detailInfo.questId == 1) + } + + @Test("연계 퀘스트 조회 성공") + func test_fetchLinkedQuests() throws { + let reactor = makeSUT() + + let state = try reduce( + reactor, + from: reactor.initialState, + action: .viewWillAppear + ) + + #expect( + state.linkedQuestInfo.nextQuests != nil || + state.linkedQuestInfo.previousQuests != nil + ) + } +} + +// MARK: - Reduce Helpers + +private func reduce( + _ reactor: QuestDictionaryDetailReactor, + from state: QuestDictionaryDetailReactor.State, + action: QuestDictionaryDetailReactor.Action +) throws -> QuestDictionaryDetailReactor.State { + let mutations = try reactor + .mutate(action: action) + .toBlocking() + .toArray() + + return mutations.reduce(state) { + reactor.reduce(state: $0, mutation: $1) + } +} + +// MARK: - SUT + +private func makeSUT() -> QuestDictionaryDetailReactor { + QuestDictionaryDetailReactor( + dictionaryDetailAPIRepository: + MockDictionaryDetailAPIRepository(), + + checkLoginUseCase: + CheckLoginUseCaseImpl( + authRepository: MockAuthAPIRepository(), + tokenRepository: MockTokenRepository() + ), + + setBookmarkUseCase: + SetBookmarkUseCaseImpl( + repository: MockBookmarkRepository() + ), + + id: 1 + ) +} diff --git a/MLS/MLSDictionaryFeatureExample/SceneDelegate.swift b/MLS/MLSDictionaryFeatureExample/SceneDelegate.swift index 9167f9be..5594b8c5 100644 --- a/MLS/MLSDictionaryFeatureExample/SceneDelegate.swift +++ b/MLS/MLSDictionaryFeatureExample/SceneDelegate.swift @@ -26,7 +26,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // MARK: - Repository let dictionaryListAPIRepository = MockDictionaryListAPIRepository() let dictionaryDetailAPIRepository = MockDictionaryDetailAPIRepository() - let recentSearchRepository = RecentSearchRepositoryImpl() + let recentSearchRepository = MockRecentSearchRepository() let bookmarkRepository = MockBookmarkRepository() let authRepository = MockAuthAPIRepository() let tokenRepository = MockTokenRepository() From 35e2d223f301a3f7b1a23204059db3f988394538 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 27 May 2026 14:51:20 +0000 Subject: [PATCH 06/16] style/#332: Apply SwiftLint autocorrect --- MLS/MLSCore/Sources/MLSCore/ImageLoader/ImageLoader.swift | 2 +- MLS/MLSDictionaryFeature/Package.swift | 4 ++-- .../MLSDictionaryFeature/Data/DTOs/DictionaryAllDTO.swift | 2 +- .../MLSDictionaryFeature/Data/DTOs/DictionaryItemDTO.swift | 2 +- .../MLSDictionaryFeature/Data/DTOs/DictionaryMapDTO.swift | 2 +- .../MLSDictionaryFeature/Data/DTOs/DictionaryMonsterDTO.swift | 2 +- .../MLSDictionaryFeature/Data/DTOs/DictionaryNPCDTO.swift | 2 +- .../MLSDictionaryFeature/Data/DTOs/DictionaryQuestDTO.swift | 2 +- .../Usecases/FetchVisitDictionaryDetailUseCaseImpl.swift | 2 +- .../DictionaryDetail/DictionaryDetailBaseViewController.swift | 2 +- .../DictionaryList/DictionaryListFactoryImpl.swift | 4 ++-- .../DictionaryList/DictionaryListViewController.swift | 3 +-- .../Presentation/DictionaryMain/DictionaryMainReactor.swift | 1 - .../Entities/DictionaryDetailQuestResponse.swift | 2 +- .../Entities/DictionnaryItemType.swift | 2 +- .../MLSDictionaryFeatureTesting/Mock/MockAppCoordinator.swift | 2 +- .../Mock/MockNotificationSettingFactory.swift | 1 - 17 files changed, 17 insertions(+), 20 deletions(-) diff --git a/MLS/MLSCore/Sources/MLSCore/ImageLoader/ImageLoader.swift b/MLS/MLSCore/Sources/MLSCore/ImageLoader/ImageLoader.swift index 3052a4d1..c26f3b9c 100644 --- a/MLS/MLSCore/Sources/MLSCore/ImageLoader/ImageLoader.swift +++ b/MLS/MLSCore/Sources/MLSCore/ImageLoader/ImageLoader.swift @@ -52,7 +52,7 @@ public final class ImageLoader: @unchecked Sendable { /// - stringURL: 이미지 URL 문자열 /// - defaultImage: 로드 실패 시 반환할 기본 이미지 /// - completion: 로드 완료 후 호출되는 클로저 - @MainActor public func loadImage(url: URL?, defaultImage: UIImage? = nil, completion: @MainActor @escaping @Sendable (UIImage?) -> Void) { + @MainActor public func loadImage(url: URL?, defaultImage: UIImage? = nil, completion: @MainActor @escaping @Sendable (UIImage?) -> Void) { loadImage(url: url) { result in DispatchQueue.main.async { switch result { diff --git a/MLS/MLSDictionaryFeature/Package.swift b/MLS/MLSDictionaryFeature/Package.swift index eea6680f..d289a424 100644 --- a/MLS/MLSDictionaryFeature/Package.swift +++ b/MLS/MLSDictionaryFeature/Package.swift @@ -64,8 +64,8 @@ let package = Package( name: "MLSDictionaryFeatureTesting", dependencies: [ "MLSDictionaryFeatureInterface", - .product(name: "MLSAuthFeatureInterface",package: "MLSAuthFeature"), - .product(name: "MLSMyPageFeatureInterface",package: "MLSMyPageFeature"), + .product(name: "MLSAuthFeatureInterface", package: "MLSAuthFeature"), + .product(name: "MLSMyPageFeatureInterface", package: "MLSMyPageFeature"), .product(name: "RxSwift", package: "RxSwift") ], swiftSettings: [.swiftLanguageMode(.v5)] diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryAllDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryAllDTO.swift index 3f2cf42a..8373b1f6 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryAllDTO.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryAllDTO.swift @@ -6,7 +6,7 @@ public struct DictionaryAllDTO: DictionaryDTOProtocol { public let type: String public let bookmarkId: Int? public var id: Int { originalId } - + public init(originalId: Int, name: String, imageUrl: String?, level: Int?, type: String, bookmarkId: Int?) { self.originalId = originalId self.name = name diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryItemDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryItemDTO.swift index 1174483e..27fd745b 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryItemDTO.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryItemDTO.swift @@ -6,7 +6,7 @@ public struct DictionaryItemDTO: DictionaryDTOProtocol { public let type: String public let bookmarkId: Int? public var id: Int { itemId } - + public init(itemId: Int, name: String, imageUrl: String?, level: Int?, type: String, bookmarkId: Int?) { self.itemId = itemId self.name = name diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMapDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMapDTO.swift index be9d876c..7f3d3782 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMapDTO.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMapDTO.swift @@ -6,7 +6,7 @@ public struct DictionaryMapDTO: DictionaryDTOProtocol { public let type: String public let bookmarkId: Int? public var id: Int { mapId } - + public init(mapId: Int, name: String, imageUrl: String?, level: Int?, type: String, bookmarkId: Int?) { self.mapId = mapId self.name = name diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMonsterDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMonsterDTO.swift index 6601796f..fb4b2f44 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMonsterDTO.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryMonsterDTO.swift @@ -6,7 +6,7 @@ public struct DictionaryMonsterDTO: DictionaryDTOProtocol { public let type: String public let bookmarkId: Int? public var id: Int { monsterId } - + public init(monsterId: Int, name: String, imageUrl: String?, level: Int?, type: String, bookmarkId: Int?) { self.monsterId = monsterId self.name = name diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryNPCDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryNPCDTO.swift index b496c87e..710eb6c0 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryNPCDTO.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryNPCDTO.swift @@ -6,7 +6,7 @@ public struct DictionaryNPCDTO: DictionaryDTOProtocol { public let type: String public let bookmarkId: Int? public var id: Int { npcId } - + public init(npcId: Int, name: String, imageUrl: String?, level: Int?, type: String, bookmarkId: Int?) { self.npcId = npcId self.name = name diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryQuestDTO.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryQuestDTO.swift index 69ff8b29..704f061a 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryQuestDTO.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/DTOs/DictionaryQuestDTO.swift @@ -6,7 +6,7 @@ public struct DictionaryQuestDTO: DictionaryDTOProtocol { public let type: String public let bookmarkId: Int? public var id: Int { questId } - + public init(questId: Int, name: String, imageUrl: String?, level: Int?, type: String, bookmarkId: Int?) { self.questId = questId self.name = name diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/FetchVisitDictionaryDetailUseCaseImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/FetchVisitDictionaryDetailUseCaseImpl.swift index e72aa801..07d85de6 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/FetchVisitDictionaryDetailUseCaseImpl.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/FetchVisitDictionaryDetailUseCaseImpl.swift @@ -1,5 +1,5 @@ -import MLSDictionaryFeatureInterface import Foundation +import MLSDictionaryFeatureInterface import RxSwift diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift index b7c64bae..84b29818 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift @@ -1,7 +1,7 @@ import UIKit import MLSAuthFeatureInterface -//import MLSBookmarkFeatureInterface 추가 예정 +// import MLSBookmarkFeatureInterface 추가 예정 import MLSCore import MLSDesignSystem import MLSDictionaryFeatureInterface diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListFactoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListFactoryImpl.swift index 894b0a39..d98f8a2b 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListFactoryImpl.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListFactoryImpl.swift @@ -1,12 +1,12 @@ -import MLSCore import MLSAuthFeatureInterface +import MLSCore import MLSDictionaryFeatureInterface public final class DictionaryListFactoryImpl: DictionaryMainListFactory { private let checkLoginUseCase: CheckLoginUseCase private let setBookmarkUseCase: SetBookmarkUseCase private let parseItemFilterResultUseCase: ParseItemFilterResultUseCase - + private let dictionaryListAPIRepository: DictionaryListAPIRepository private let itemFilterFactory: ItemFilterBottomSheetFactory diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListViewController.swift index 971af4b1..27fe9778 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListViewController.swift @@ -353,8 +353,7 @@ extension DictionaryListViewController { for cell in collectionView.visibleCells { if let indexPath = collectionView.indexPath(for: cell), indexPath.item < items.count, - let cell = cell as? DictionaryListCell - { + let cell = cell as? DictionaryListCell { let item = items[indexPath.item] cell.updateBookmarkState(isBookmarked: item.bookmarkId != nil) } diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainReactor.swift index 84ed32f2..14c09a57 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainReactor.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryMain/DictionaryMainReactor.swift @@ -3,7 +3,6 @@ import ReactorKit import MLSDictionaryFeatureInterface import MLSMyPageFeatureInterface - public final class DictionaryMainReactor: Reactor { // MARK: - Reactor public enum Route { diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailQuestResponse.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailQuestResponse.swift index 3dcd9fa1..e1b1845f 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailQuestResponse.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionaryDetailQuestResponse.swift @@ -63,7 +63,7 @@ public struct Reward: Decodable, Equatable { public let exp: Int? public let meso: Int? public let popularity: Int? - + public init(exp: Int?, meso: Int?, popularity: Int?) { self.exp = exp self.meso = meso diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionnaryItemType.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionnaryItemType.swift index f229667c..52c9ae2f 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionnaryItemType.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionnaryItemType.swift @@ -51,7 +51,7 @@ public enum DictionaryItemType: String { return .quest } } - + public var backgroundColor: UIColor { switch self { case .item: diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockAppCoordinator.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockAppCoordinator.swift index 6d9f12c0..37130ff2 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockAppCoordinator.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockAppCoordinator.swift @@ -4,7 +4,7 @@ import MLSAuthFeatureInterface public final class MockAppCoordinator: AppCoordinatorProtocol { public var window: UIWindow? - + public init() {} public func showMainTab() { diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockNotificationSettingFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockNotificationSettingFactory.swift index 1600231f..09564b94 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockNotificationSettingFactory.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureTesting/Mock/MockNotificationSettingFactory.swift @@ -12,4 +12,3 @@ public final class MockNotificationSettingFactory: NotificationSettingFactory { return viewcontroller } } - From 8e418e3592a7ef95a973f41c96751decfcba1af1 Mon Sep 17 00:00:00 2001 From: p2glet Date: Wed, 3 Jun 2026 20:43:09 +0900 Subject: [PATCH 07/16] =?UTF-8?q?refactor/#332:=20DictionaryDetailBaseView?= =?UTF-8?q?Controller=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EB=B7=B0=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DictionaryDetailBaseViewController.swift | 48 ++++++++++++++----- .../ItemDictionaryDetailViewController.swift | 10 ++-- .../MapDictionaryDetailViewController.swift | 20 ++++---- ...onsterDictionaryDetailViewController.swift | 12 ++--- .../NpcDictionaryDetailViewController.swift | 10 ++-- .../QuestDictionaryDetailViewController.swift | 10 ++-- .../Entities/DictionnaryItemType.swift | 2 +- 7 files changed, 63 insertions(+), 49 deletions(-) diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift index b7c64bae..236d8d1c 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift @@ -1,7 +1,7 @@ import UIKit import MLSAuthFeatureInterface -//import MLSBookmarkFeatureInterface 추가 예정 +// import MLSBookmarkFeatureInterface 추가 예정 import MLSCore import MLSDesignSystem import MLSDictionaryFeatureInterface @@ -19,13 +19,12 @@ class DictionaryDetailBaseViewController: BaseViewController { var loginRelay: PublishRelay? /// 각 탭에 해당하는 콘텐츠 뷰들을 담는 배열 - public var contentViews: [UIView] = [] { - didSet { - let index = currentTabIndex ?? 0 - guard index < contentViews.count else { return } - mainView.setTabView(index: index, contentViews: contentViews) - } - } + private(set) lazy var contentViews: [UIView] = { + Array( + repeating: UIView(), + count: type.detailTypes.count + ) + }() /// 현재 보여지고 있는 뷰의 인덱스 private var currentTabIndex: Int? @@ -80,6 +79,7 @@ class DictionaryDetailBaseViewController: BaseViewController { setupConstraints() configureUI() bind() // 액션 바인딩 +// configureContentView() setupMenu(type.detailTypes) } @@ -104,6 +104,32 @@ class DictionaryDetailBaseViewController: BaseViewController { open func undoBookmark() { assertionFailure("Subclass should override undoBookmark()") } + +// func configureContentView() { +// contentViews = Array( +// repeating: UIView(), +// count: type.detailTypes.count +// ) +// } + + func index(of detailType: DetailType) -> Int? { + type.detailTypes.firstIndex { $0 == detailType } + } + + func setContentView(view: UIView, detailType: DetailType) { + guard let index = index(of: detailType) else { + assertionFailure("detailType not found") + return + } + contentViews[index] = view + + if currentTabIndex == index { + mainView.setTabView( + index: index, + contentViews: contentViews + ) + } + } } // MARK: - SetUp @@ -271,9 +297,7 @@ extension DictionaryDetailBaseViewController { func didSelectMenuTab(index: Int) { // 인덱스 유효성 검사 guard index < contentViews.count else { return } - - // 현재 뷰가 같다면 변경 안함 - if currentTabIndex == index { return } + // 각 탭에 맞는 뷰 설정 mainView.setTabView(index: index, contentViews: contentViews) currentTabIndex = index @@ -410,7 +434,7 @@ extension DictionaryDetailBaseViewController { sheet.prefersGrabberVisible = true sheet.preferredCornerRadius = 16 } - self.present(viewController, animated: true) + present(viewController, animated: true) } func presentLoginGuide() { diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift index 863171ee..a818c002 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift @@ -43,11 +43,11 @@ private extension ItemDictionaryDetailViewController { guard let reactor = reactor else { return } let infos = reactor.currentState.itemDetailInfo - contentViews.append(detailInfoView) + detailInfoView.reset() // descriptionText detailInfoView.descriptionLabel.text = infos.descriptionText ?? "" + setContentView(view: detailInfoView, detailType: .normal) - detailInfoView.reset() if let npcPrice = infos.npcPrice { detailInfoView.addInfo(mainText: "상점판매가", subText: "\(npcPrice.formatted()) 메소") } @@ -153,11 +153,11 @@ private extension ItemDictionaryDetailViewController { let monsters = reactor.currentState.monsters monsterCardView.reset() - contentViews.append(monsterCardView) if monsters.isEmpty { - contentViews[1] = DetailEmptyView(type: .dropMonsterWithText) + setContentView(view: DetailEmptyView(type: .dropMonsterWithText), detailType: .dropMonsterWithText) } else { - contentViews[1] = monsterCardView + setContentView(view: monsterCardView + , detailType: .dropMonsterWithText) for monster in monsters { monsterCardView.inject(input: DetailStackCardView.Input(type: .dropMonsterWithText, imageUrl: monster.imageUrl ?? "", mainText: monster.monsterName, additionalText: "\(monster.dropRate ?? 0)%") ) diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailViewController.swift index 99dc0513..6ff6f535 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailViewController.swift @@ -53,13 +53,11 @@ private extension MapDictionaryDetailViewController { func setUpMapView() { guard let reactor = reactor else { return } - - contentViews.append(mapInfoView) if let mapUrl = reactor.currentState.mapDetailInfo.mapUrl, !mapUrl.isEmpty { - mapInfoView.setUpMapView(imageUrl: reactor.currentState.mapDetailInfo.mapUrl) - contentViews[0] = mapInfoView + mapInfoView.setUpMapView(imageUrl: mapUrl) + setContentView(view: mapInfoView, detailType: .mapInfo) } else { - contentViews[0] = DetailEmptyView(type: .mapInfo) + setContentView(view: DetailEmptyView(type: .mapInfo), detailType: .mapInfo) } } @@ -70,11 +68,10 @@ private extension MapDictionaryDetailViewController { appearMonsterView.reset() let monsters = reactor.currentState.spawnMonsters - contentViews.append(appearMonsterView) if monsters.isEmpty { - contentViews[1] = DetailEmptyView(type: .appearMonsterWithText) + setContentView(view: DetailEmptyView(type: .appearMonsterWithText), detailType: .appearMonsterWithText) } else { - contentViews[1] = appearMonsterView + setContentView(view: appearMonsterView, detailType: .appearMonsterWithText) for monster in monsters { appearMonsterView.inject(input: DetailStackCardView.Input(type: .appearMonsterWithText, imageUrl: monster.imageUrl ?? "", mainText: monster.monsterName, subText: "Lv.\(monster.level ?? 0)", additionalText: { if let count = monster.maxSpawnCount { @@ -92,11 +89,10 @@ private extension MapDictionaryDetailViewController { let npcs = reactor.currentState.npcs appearNpcView.reset() - contentViews.append(appearNpcView) if npcs.isEmpty { - contentViews[2] = DetailEmptyView(type: .appearNPC) + setContentView(view: DetailEmptyView(type: .appearNPC), detailType: .appearNPC) } else { - contentViews[2] = appearNpcView + setContentView(view: appearNpcView, detailType: .appearNPC) for npc in npcs { appearNpcView.inject(input: DetailStackCardView.Input(type: .appearNPC, imageUrl: npc.iconUrl ?? "", mainText: npc.npcName)) } @@ -116,7 +112,7 @@ private extension MapDictionaryDetailViewController { let viewController = PinchMapViewController(imageUrl: url) viewController.modalPresentationStyle = .overFullScreen owner.isBottomTabbarHidden = true - self.present(viewController, animated: true) + owner.present(viewController, animated: true) }) .disposed(by: disposeBag) } diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift index ef98f631..6da8f55d 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift @@ -53,7 +53,7 @@ private extension MonsterDictionaryDetailViewController { guard let reactor = reactor else { return } let infos = reactor.currentState.infos - contentViews.append(detailView) + setContentView(view: detailView, detailType: .normal) for info in infos { detailView.addInfo(mainText: info.name, subText: info.desc) @@ -68,11 +68,10 @@ private extension MonsterDictionaryDetailViewController { let maps = reactor.currentState.spawnMaps appearMapView.reset() - contentViews.append(appearMapView) if maps.isEmpty { - contentViews[1] = DetailEmptyView(type: .appearMap) + setContentView(view: DetailEmptyView(type: .appearMap), detailType: .appearMapWithText) } else { - contentViews[1] = appearMapView + setContentView(view: appearMapView, detailType: .appearMapWithText) for map in maps { appearMapView.inject(input: DetailStackCardView @@ -102,13 +101,12 @@ private extension MonsterDictionaryDetailViewController { let items = reactor.currentState.dropItems dropItemView.reset() - contentViews.append(dropItemView) // 드롭아이템 if items.isEmpty { // 드롭 아이템 - contentViews[2] = DetailEmptyView(type: .dropItemWithText) + setContentView(view: DetailEmptyView(type: .dropItemWithText), detailType: .dropItemWithText) } else { - contentViews[2] = dropItemView + setContentView(view: dropItemView, detailType: .dropItemWithText) for item in items { dropItemView .inject( diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift index a17bbe6e..1cbe11ff 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift @@ -51,12 +51,11 @@ private extension NpcDictionaryDetailViewController { let maps = reactor.currentState.maps appearMapView.reset() - contentViews.append(appearMapView) if maps.isEmpty { // 출현맵 - contentViews[0] = DetailEmptyView(type: .appearMap) + } else { - contentViews[0] = appearMapView + setContentView(view: appearMapView, detailType: .appearMap) for map in maps { appearMapView.inject(input: DetailStackCardView.Input( type: .appearMap, @@ -76,12 +75,11 @@ private extension NpcDictionaryDetailViewController { let quests = reactor.currentState.quests questView.reset() - contentViews.append(questView) if quests.isEmpty { // 퀘스트 - contentViews[1] = DetailEmptyView(type: .quest) + setContentView(view: DetailEmptyView(type: .quest), detailType: .quest) } else { - contentViews[1] = questView + setContentView(view: questView, detailType: .quest) for quest in quests { questView.inject(input: DetailStackCardView.Input( type: .quest, diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift index 95d183b2..fc96160d 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift @@ -46,11 +46,10 @@ private extension QuestDictionaryDetailViewController { let rewardInfos = reactor.currentState.detailInfo.reward let rewardItemInfos = reactor.currentState.detailInfo.rewardItems - contentViews.append(detailInfoView) // 뭘로 빈페이지 보여줄지 정하지.. detailInfoView.reset() if !(detailInfos.startNpcName == nil) { - contentViews.append(detailInfoView) + setContentView(view: detailInfoView, detailType: .normal) // 완료조건 추가 if let requirements = detailInfos.requirements { for requirement in requirements { @@ -119,7 +118,7 @@ private extension QuestDictionaryDetailViewController { } } else { - contentViews.append(DetailEmptyView(type: .normal)) + setContentView(view: DetailEmptyView(type: .normal), detailType: .normal) } } @@ -128,12 +127,11 @@ private extension QuestDictionaryDetailViewController { let quests = reactor.currentState.totalQuest linkedQuestView.reset() - contentViews.append(linkedQuestView) if quests.isEmpty { - contentViews[1] = DetailEmptyView(type: .quest) + setContentView(view: DetailEmptyView(type: .quest), detailType: .linkedQuest) } else { - contentViews[1] = linkedQuestView + setContentView(view: linkedQuestView, detailType: .linkedQuest) for data in quests { linkedQuestView.inject( diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionnaryItemType.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionnaryItemType.swift index f229667c..c3e32d67 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionnaryItemType.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Entities/DictionnaryItemType.swift @@ -68,7 +68,7 @@ public enum DictionaryItemType: String { } } -public enum DetailType { +public enum DetailType: Equatable { case normal case mapInfo case appearMap From 167a4f5e508ecdb7f76c5adde8554ac6d02efe97 Mon Sep 17 00:00:00 2001 From: p2glet Date: Wed, 3 Jun 2026 20:48:25 +0900 Subject: [PATCH 08/16] =?UTF-8?q?refactor/#332:=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=96=B4=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=A7=80=EC=97=B0=20=EC=8B=A4=ED=96=89=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecentSearchRepositoryImpl.swift | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/RecentSearchRepositoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/RecentSearchRepositoryImpl.swift index 3307cb26..ac77a231 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/RecentSearchRepositoryImpl.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Data/Repositories/RecentSearchRepositoryImpl.swift @@ -19,20 +19,23 @@ public final class RecentSearchRepositoryImpl: RecentSearchRepository { } public func addRecentSearch(keyword: String) -> Completable { - let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) + return Completable.create { [recentSearchkey] completable in + let trimmedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) - guard !keyword.isEmpty else { - return .empty() - } - - var current = UserDefaults.standard.stringArray(forKey: recentSearchkey) ?? [] + guard !trimmedKeyword.isEmpty else { + completable(.completed) + return Disposables.create() + } - current.removeAll(where: { $0 == keyword }) - current.insert(keyword, at: 0) + var current = UserDefaults.standard.stringArray(forKey: recentSearchkey) ?? [] - UserDefaults.standard.set(current, forKey: recentSearchkey) + current.removeAll(where: { $0 == trimmedKeyword }) + current.insert(trimmedKeyword, at: 0) - return .empty() + UserDefaults.standard.set(current, forKey: recentSearchkey) + completable(.completed) + return Disposables.create() + } } public func removeRecentSearch(keyword: String) -> Completable { From 92fd5d743b6cf7df275c304b63cbfe89fcb4d27d Mon Sep 17 00:00:00 2001 From: p2glet Date: Wed, 3 Jun 2026 21:13:14 +0900 Subject: [PATCH 09/16] =?UTF-8?q?refactor/#332:=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=EC=A0=90=EC=9D=84=20=EA=B5=AC?= =?UTF-8?q?=EB=8F=85=20=EC=8B=9C=EC=A0=90=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Usecases/CheckLoginUseCaseImpl.swift | 55 ++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/CheckLoginUseCaseImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/CheckLoginUseCaseImpl.swift index 74d8866a..83dac045 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/CheckLoginUseCaseImpl.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/CheckLoginUseCaseImpl.swift @@ -14,33 +14,36 @@ public final class CheckLoginUseCaseImpl: CheckLoginUseCase { } public func execute() -> Observable { - switch tokenRepository.fetchToken(type: .refreshToken) { - case .success(let token): - guard !token.isEmpty else { return .just(false) } - - return authRepository.reissueToken(refreshToken: token) - .map { [weak self] response in - guard let self else { return false } - - let accessResult = self.tokenRepository.saveToken(type: .accessToken, value: response.accessToken) - let refreshResult = self.tokenRepository.saveToken(type: .refreshToken, value: response.refreshToken) - - switch (accessResult, refreshResult) { - case (.success, .success): - return true - case (.failure(let error), _), - (_, .failure(let error)): - print("Token 저장 실패:", error.localizedDescription) - return false + return Observable.deferred { [weak self] in + guard let self else { return .just(false) } + switch self.tokenRepository.fetchToken(type: .refreshToken) { + case .success(let token): + guard !token.isEmpty else { return .just(false) } + + return self.authRepository.reissueToken(refreshToken: token) + .map { [weak self] response in + guard let self else { return false } + + let accessResult = self.tokenRepository.saveToken(type: .accessToken, value: response.accessToken) + let refreshResult = self.tokenRepository.saveToken(type: .refreshToken, value: response.refreshToken) + + switch (accessResult, refreshResult) { + case (.success, .success): + return true + case (.failure(let error), _), + (_, .failure(let error)): + print("Token 저장 실패:", error.localizedDescription) + return false + } } - } - .catch { error in - print("reissueToken 실패:", error.localizedDescription) - return .just(false) - } - - case .failure: - return .just(false) + .catch { error in + print("reissueToken 실패:", error.localizedDescription) + return .just(false) + } + + case .failure: + return .just(false) + } } } } From a06021a7491eb14b77fbc4c091bdc32655e3d81c Mon Sep 17 00:00:00 2001 From: p2glet Date: Wed, 3 Jun 2026 22:16:15 +0900 Subject: [PATCH 10/16] =?UTF-8?q?refactor/#332:=20=EC=A0=9C=EB=AF=B8?= =?UTF-8?q?=EB=82=98=EC=9D=B4=20=EC=BD=94=EB=93=9C=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Usecases/FetchVisitDictionaryDetailUseCaseImpl.swift | 2 +- .../DictionaryDetail/DictionaryDetailBaseViewController.swift | 2 +- .../DictionaryNotificationViewController.swift | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/FetchVisitDictionaryDetailUseCaseImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/FetchVisitDictionaryDetailUseCaseImpl.swift index e72aa801..f48bd930 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/FetchVisitDictionaryDetailUseCaseImpl.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Domain/Usecases/FetchVisitDictionaryDetailUseCaseImpl.swift @@ -4,7 +4,7 @@ import Foundation import RxSwift public class FetchVisitDictionaryDetailUseCaseImpl: FetchVisitDictionaryDetailUseCase { - var repository: UserDefaultsRepository + private let repository: UserDefaultsRepository public init(repository: UserDefaultsRepository) { self.repository = repository } diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift index 236d8d1c..55d2a5da 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift @@ -207,7 +207,7 @@ extension DictionaryDetailBaseViewController { func makeTagsRow(_ tags: Effectiveness) { mainView.tagsVerticalStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } - let maxWidth = UIScreen.main.bounds.width - DictionaryDetailBaseView.Constant.horizontalInset // 좌우 여백 고려 (16 * 2) + let maxWidth = view.bounds.width - DictionaryDetailBaseView.Constant.horizontalInset // 좌우 여백 고려 (16 * 2) // 좌우 여백 고려 (16 * 2) let tagSpacing: CGFloat = DictionaryDetailBaseView.Constant.tagVerticalSpacing var tagsRowStackView = mainView.createHorizontalStackView() diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationViewController.swift index 3cb17c37..5924139c 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryNotification/DictionaryNotificationViewController.swift @@ -175,6 +175,7 @@ extension DictionaryNotificationViewController: UICollectionViewDelegate, UIColl let frameHeight = scrollView.frame.size.height if offsetY > contentHeight - frameHeight - 100 { + lastPagingTime = now reactor.action.onNext(.loadMore) } } From d9664e360897789aeb691328f4e4061db7c4ee32 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 3 Jun 2026 13:17:44 +0000 Subject: [PATCH 11/16] style/#332: Apply SwiftLint autocorrect --- .../DictionaryDetail/DictionaryDetailBaseViewController.swift | 2 +- .../NPC/NpcDictionaryDetailViewController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift index 55d2a5da..1528b5ba 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift @@ -297,7 +297,7 @@ extension DictionaryDetailBaseViewController { func didSelectMenuTab(index: Int) { // 인덱스 유효성 검사 guard index < contentViews.count else { return } - + // 각 탭에 맞는 뷰 설정 mainView.setTabView(index: index, contentViews: contentViews) currentTabIndex = index diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift index 1cbe11ff..7fb68f32 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift @@ -53,7 +53,7 @@ private extension NpcDictionaryDetailViewController { appearMapView.reset() if maps.isEmpty { // 출현맵 - + } else { setContentView(view: appearMapView, detailType: .appearMap) for map in maps { From 156afcbd8cbf119bddf0487862cc2aa550307559 Mon Sep 17 00:00:00 2001 From: p2glet Date: Wed, 3 Jun 2026 22:45:29 +0900 Subject: [PATCH 12/16] =?UTF-8?q?fix/#332:=20navigateTo=20=EC=98=A4?= =?UTF-8?q?=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Item/ItemDictionaryDetailReactor.swift | 12 ++++++------ .../Map/MapDictionaryDetailReactor.swift | 14 +++++++------- .../NPC/NpcDictionaryDetailReactor.swift | 14 +++++++------- .../Quest/QuestDictionaryDetailReactor.swift | 12 ++++++------ .../DictionaryList/DictionaryListReactor.swift | 12 ++++++------ 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailReactor.swift index 713edb3d..aca5520f 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailReactor.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailReactor.swift @@ -31,7 +31,7 @@ public final class ItemDictionaryDetailReactor: Reactor { // MARK: Mutation public enum Mutation { - case navigatTo(Route) + case navigateTo(Route) case setDetailData(DictionaryDetailItemResponse) case setDetailDropMonsterData([DictionaryDetailItemDropMonsterResponse]) case setLoginState(Bool) @@ -83,7 +83,7 @@ public final class ItemDictionaryDetailReactor: Reactor { public func mutate(action: Action) -> Observable { switch action { case .filterButtonTapped: - return .just(.navigatTo(.filter(currentState.type))) + return .just(.navigateTo(.filter(currentState.type))) case .viewWillAppear: return .merge([ checkLoginUseCase.execute().map { .setLoginState($0) }, @@ -101,7 +101,7 @@ public final class ItemDictionaryDetailReactor: Reactor { case .dataTapped(let index): guard let id = currentState.monsters[index].monsterId else { return .empty() } - return .just(.navigatTo(.detail(id))) + return .just(.navigateTo(.detail(id))) } } @@ -115,7 +115,7 @@ public final class ItemDictionaryDetailReactor: Reactor { newState.monsters = data case let .setLoginState(isLogin): newState.isLogin = isLogin - case .navigatTo(let route): + case .navigateTo(let route): newState.route = route case let .setEvent(event): newState.event = event @@ -147,7 +147,7 @@ private extension ItemDictionaryDetailReactor { return .concat([eventMutation, refresh]) } .catch { _ in - .just(.navigatTo(.bookmarkError)) + .just(.navigateTo(.bookmarkError)) } } @@ -170,7 +170,7 @@ private extension ItemDictionaryDetailReactor { return .concat([eventMutation, refresh]) } .catch { _ in - .just(.navigatTo(.bookmarkError)) + .just(.navigateTo(.bookmarkError)) } } } diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailReactor.swift index c8b62245..a2cb3ac0 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailReactor.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailReactor.swift @@ -29,7 +29,7 @@ public final class MapDictionaryDetailReactor: Reactor { } public enum Mutation { - case navigatTo(Route) + case navigateTo(Route) case setDetailData(DictionaryDetailMapResponse) case setDetailSpawnMonsters([DictionaryDetailMapSpawnMonsterResponse]) case setDetailNpc([DictionaryDetailMapNpcResponse]) @@ -93,7 +93,7 @@ public final class MapDictionaryDetailReactor: Reactor { public func mutate(action: Action) -> Observable { switch action { case .monsterFilterButtonTapped: - return Observable.just(.navigatTo(.filter(currentState.monsterFilter))) + return Observable.just(.navigateTo(.filter(currentState.monsterFilter))) case .viewWillAppear: return .merge([ checkLoginUseCase.execute().map { .setLoginState($0) }, @@ -113,10 +113,10 @@ public final class MapDictionaryDetailReactor: Reactor { case .monsterTapped(index: let index): guard let id = currentState.spawnMonsters[index].monsterId else { return .empty() } - return .just(.navigatTo(.detail(type: .monster, id: id))) + return .just(.navigateTo(.detail(type: .monster, id: id))) case .npcTapped(index: let index): guard let id = currentState.npcs[index].npcId else { return .empty() } - return .just(.navigatTo(.detail(type: .npc, id: id))) + return .just(.navigateTo(.detail(type: .npc, id: id))) } } @@ -124,7 +124,7 @@ public final class MapDictionaryDetailReactor: Reactor { var newState = state switch mutation { - case .navigatTo(let route): + case .navigateTo(let route): newState.route = route case let .setDetailData(data): newState.mapDetailInfo = data @@ -168,7 +168,7 @@ private extension MapDictionaryDetailReactor { return .concat([eventMutation, refresh]) } .catch { _ in - .just(.navigatTo(.bookmarkError)) + .just(.navigateTo(.bookmarkError)) } } @@ -191,7 +191,7 @@ private extension MapDictionaryDetailReactor { return .concat([eventMutation, refresh]) } .catch { _ in - .just(.navigatTo(.bookmarkError)) + .just(.navigateTo(.bookmarkError)) } } } diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailReactor.swift index e7aeabaa..d4dd5fe7 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailReactor.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailReactor.swift @@ -31,7 +31,7 @@ public final class NpcDictionaryDetailReactor: Reactor { // MARK: - Mutation public enum Mutation { - case navigatTo(Route) + case navigateTo(Route) case setDetailData(DictionaryDetailNpcResponse) case setDetailMaps([DictionaryDetailMonsterMapResponse]) case setDetailQuests([DictionaryDetailNpcQuestResponse]) @@ -88,7 +88,7 @@ public final class NpcDictionaryDetailReactor: Reactor { public func mutate(action: Action) -> Observable { switch action { case .filterButtonTapped: - return .just(.navigatTo(.filter(currentState.questFilter))) + return .just(.navigateTo(.filter(currentState.questFilter))) case .viewWillAppear: return .merge([ checkLoginUseCase.execute().map { .setLoginState($0) }, @@ -106,10 +106,10 @@ public final class NpcDictionaryDetailReactor: Reactor { return handleUndoLastDeletedBookmark() case .mapTapped(index: let index): - return .just(.navigatTo(.detail(type: .map, id: currentState.maps[index].mapId))) + return .just(.navigateTo(.detail(type: .map, id: currentState.maps[index].mapId))) case .questTapped(index: let index): - return .just(.navigatTo(.detail(type: .quest, id: currentState.quests[index].questId))) + return .just(.navigateTo(.detail(type: .quest, id: currentState.quests[index].questId))) } } @@ -117,7 +117,7 @@ public final class NpcDictionaryDetailReactor: Reactor { public func reduce(state: State, mutation: Mutation) -> State { var newState = state switch mutation { - case .navigatTo(let route): + case .navigateTo(let route): newState.route = route case let .setDetailData(data): newState.npcDetailInfo = data @@ -159,7 +159,7 @@ private extension NpcDictionaryDetailReactor { return .concat([eventMutation, refresh]) } .catch { _ in - .just(.navigatTo(.bookmarkError)) + .just(.navigateTo(.bookmarkError)) } } @@ -182,7 +182,7 @@ private extension NpcDictionaryDetailReactor { return .concat([eventMutation, refresh]) } .catch { _ in - .just(.navigatTo(.bookmarkError)) + .just(.navigateTo(.bookmarkError)) } } } diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailReactor.swift index 2f5b732b..981731eb 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailReactor.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailReactor.swift @@ -37,7 +37,7 @@ public final class QuestDictionaryDetailReactor: Reactor { } public enum Mutation { - case navigatTo(Route) + case navigateTo(Route) case setDetailData(DictionaryDetailQuestResponse) case setLinkedQuests(DictionaryDetailQuestLinkedQuestsResponse) case setLoginState(Bool) @@ -118,10 +118,10 @@ public final class QuestDictionaryDetailReactor: Reactor { let tappedQuestInfo = currentState.totalQuest[index] guard let id = tappedQuestInfo.quest.questId, tappedQuestInfo.type != .current else { return .empty() } - return .just(.navigatTo(.detail(type: .quest, id: id))) + return .just(.navigateTo(.detail(type: .quest, id: id))) case let .infoTapped(type: type, id: id): - return .just(.navigatTo(.detail(type: type, id: id))) + return .just(.navigateTo(.detail(type: type, id: id))) } } @@ -144,7 +144,7 @@ public final class QuestDictionaryDetailReactor: Reactor { newState.isLogin = isLogin case let .setLastDeletedBookmark(data): newState.lastDeletedBookmark = data - case let .navigatTo(route): + case let .navigateTo(route): newState.route = route case let .setEvent(event): newState.event = event @@ -206,7 +206,7 @@ private extension QuestDictionaryDetailReactor { return .concat([eventMutation, refresh]) } .catch { _ in - .just(.navigatTo(.bookmarkError)) + .just(.navigateTo(.bookmarkError)) } } @@ -229,7 +229,7 @@ private extension QuestDictionaryDetailReactor { return .concat([eventMutation, refresh]) } .catch { _ in - .just(.navigatTo(.bookmarkError)) + .just(.navigateTo(.bookmarkError)) } } } diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListReactor.swift index bd282293..eeeeadcb 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListReactor.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryList/DictionaryListReactor.swift @@ -39,7 +39,7 @@ public final class DictionaryListReactor: Reactor { // MARK: - Mutation public enum Mutation { - case navigatTo(Route) + case navigateTo(Route) case setListItem(DictionaryMainResponse, updateBookmarkOnly: Bool = false) case setSort(String) case setFilter(start: Int?, end: Int?) @@ -109,9 +109,9 @@ public final class DictionaryListReactor: Reactor { case .viewWillAppear: return handleViewWillAppear() case .sortButtonTapped: - return .just(.navigatTo(.sort(currentState.type))) + return .just(.navigateTo(.sort(currentState.type))) case .filterButtonTapped: - return .just(.navigatTo(.filter(currentState.type))) + return .just(.navigateTo(.filter(currentState.type))) case let .sortOptionSelected(sort): return handleSortOptionSelected(sort: sort) case let .filterOptionSelected(startLevel, endLevel): @@ -137,7 +137,7 @@ public final class DictionaryListReactor: Reactor { public func reduce(state: State, mutation: Mutation) -> State { var newState = state switch mutation { - case let .navigatTo(route): + case let .navigateTo(route): newState.route = route case let .setListItem(items, updateBookmarkOnly): newState.isBookmarkUpdateOnly = updateBookmarkOnly @@ -296,7 +296,7 @@ private extension DictionaryListReactor { return .from([lastItem, updateMutation, eventMutation]) } .catch { _ in - .just(.navigatTo(.bookmarkError)) + .just(.navigateTo(.bookmarkError)) } } @@ -351,7 +351,7 @@ private extension DictionaryListReactor { return .from([lastItem, updateMutation, eventMutation]) } .catch { _ in - .just(.navigatTo(.bookmarkError)) + .just(.navigateTo(.bookmarkError)) } } From 056d95818c215d324d9f4eeb17053683095950f9 Mon Sep 17 00:00:00 2001 From: p2glet Date: Wed, 3 Jun 2026 23:25:05 +0900 Subject: [PATCH 13/16] =?UTF-8?q?fix/#332:=20DictionaryDetailFactoryImpl?= =?UTF-8?q?=20=EC=9E=90=EA=B8=B0=20=EC=9E=90=EC=8B=A0=20=EC=88=9C=ED=99=98?= =?UTF-8?q?=20=EC=B0=B8=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DictionaryDetailBaseViewController.swift | 4 +- .../DictionaryDetailFactoryImpl.swift | 258 ++++++++++++------ .../ItemDictionaryDetailViewController.swift | 2 +- .../MapDictionaryDetailViewController.swift | 2 +- ...onsterDictionaryDetailViewController.swift | 2 +- .../NpcDictionaryDetailViewController.swift | 2 +- .../QuestDictionaryDetailViewController.swift | 2 +- .../Factories/DictionaryDetailFactory.swift | 2 +- .../SceneDelegate.swift | 10 +- 9 files changed, 179 insertions(+), 105 deletions(-) diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift index 55d2a5da..34a2fdc7 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift @@ -31,8 +31,8 @@ class DictionaryDetailBaseViewController: BaseViewController { let bookmarkModalFactory: BookmarkModalFactory let loginFactory: LoginFactory - public let dictionaryDetailFactory: DictionaryDetailFactory private let detailOnBoardingFactory: DetailOnBoardingFactory + weak var dictionaryDetailFactory: DictionaryDetailFactory? private let appCoordinator: AppCoordinatorProtocol private let fetchVisitDictionaryDetailUseCase: FetchVisitDictionaryDetailUseCase @@ -47,7 +47,6 @@ class DictionaryDetailBaseViewController: BaseViewController { type: DictionaryItemType, bookmarkModalFactory: BookmarkModalFactory, loginFactory: LoginFactory, - dictionaryDetailFactory: DictionaryDetailFactory, detailOnBoardingFactory: DetailOnBoardingFactory, appCoordinator: AppCoordinatorProtocol, fetchVisitDictionaryDetailUseCase: FetchVisitDictionaryDetailUseCase, @@ -58,7 +57,6 @@ class DictionaryDetailBaseViewController: BaseViewController { self.bookmarkModalFactory = bookmarkModalFactory self.loginFactory = loginFactory self.appCoordinator = appCoordinator - self.dictionaryDetailFactory = dictionaryDetailFactory self.detailOnBoardingFactory = detailOnBoardingFactory self.fetchVisitDictionaryDetailUseCase = fetchVisitDictionaryDetailUseCase mainView.titleLabel.attributedText = .makeStyledString(font: .sub_m_b, text: type.detailTitle) diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailFactoryImpl.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailFactoryImpl.swift index 4d17c049..40949741 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailFactoryImpl.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailFactoryImpl.swift @@ -7,7 +7,6 @@ import RxCocoa public final class DictionaryDetailFactoryImpl: DictionaryDetailFactory { private let loginFactory: () -> LoginFactory private let bookmarkModalFactory: BookmarkModalFactory - private let dictionaryDetailFactory: () -> DictionaryDetailFactory private let detailOnBoardingFactory: DetailOnBoardingFactory private let appCoordinator: () -> AppCoordinatorProtocol private let dictionaryDetailAPIRepository: DictionaryDetailAPIRepository @@ -18,7 +17,6 @@ public final class DictionaryDetailFactoryImpl: DictionaryDetailFactory { public init( loginFactory: @escaping () -> LoginFactory, bookmarkModalFactory: BookmarkModalFactory, - dictionaryDetailFactory: @escaping () -> DictionaryDetailFactory, detailOnBoardingFactory: DetailOnBoardingFactory, appCoordinator: @escaping () -> AppCoordinatorProtocol, dictionaryDetailAPIRepository: DictionaryDetailAPIRepository, @@ -29,121 +27,59 @@ public final class DictionaryDetailFactoryImpl: DictionaryDetailFactory { self.loginFactory = loginFactory self.bookmarkModalFactory = bookmarkModalFactory self.detailOnBoardingFactory = detailOnBoardingFactory + self.appCoordinator = appCoordinator self.dictionaryDetailAPIRepository = dictionaryDetailAPIRepository self.checkLoginUseCase = checkLoginUseCase self.setBookmarkUseCase = setBookmarkUseCase - self.appCoordinator = appCoordinator - self.dictionaryDetailFactory = dictionaryDetailFactory self.fetchVisitDictionaryDetailUseCase = fetchVisitDictionaryDetailUseCase } - public func make(type: DictionaryType, id: Int, bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, loginRelay: PublishRelay?) -> BaseViewController { - var viewController = BaseViewController() + public func make( + type: DictionaryType, + id: Int, + bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, + loginRelay: PublishRelay? + ) -> BaseViewController { + let viewController: BaseViewController + switch type { - case .total: - break - case .collection: - break + case .total, .collection: + viewController = BaseViewController() + case .item: - viewController = ItemDictionaryDetailViewController( - type: .item, - bookmarkModalFactory: bookmarkModalFactory, - loginFactory: loginFactory(), - dictionaryDetailFactory: dictionaryDetailFactory(), - detailOnBoardingFactory: detailOnBoardingFactory, - appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + viewController = makeItemViewController( + id: id, bookmarkRelay: bookmarkRelay, loginRelay: loginRelay ) - let reactor = ItemDictionaryDetailReactor( - dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, - checkLoginUseCase: checkLoginUseCase, - setBookmarkUseCase: setBookmarkUseCase, - id: id - ) - if let viewController = viewController as? ItemDictionaryDetailViewController { - viewController.reactor = reactor - } + case .monster: - viewController = MonsterDictionaryDetailViewController( - type: .monster, - bookmarkModalFactory: bookmarkModalFactory, - loginFactory: loginFactory(), - dictionaryDetailFactory: dictionaryDetailFactory(), - detailOnBoardingFactory: detailOnBoardingFactory, - appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + viewController = makeMonsterViewController( + id: id, bookmarkRelay: bookmarkRelay, loginRelay: loginRelay ) - let reactor = MonsterDictionaryDetailReactor( - dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, - checkLoginUseCase: checkLoginUseCase, - setBookmarkUseCase: setBookmarkUseCase, - id: id - ) - if let viewController = viewController as? MonsterDictionaryDetailViewController { - viewController.reactor = reactor - } + case .map: - viewController = MapDictionaryDetailViewController( - type: .map, - bookmarkModalFactory: bookmarkModalFactory, - loginFactory: loginFactory(), - dictionaryDetailFactory: dictionaryDetailFactory(), - detailOnBoardingFactory: detailOnBoardingFactory, - appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + viewController = makeMapViewController( + id: id, bookmarkRelay: bookmarkRelay, loginRelay: loginRelay ) - let reactor = MapDictionaryDetailReactor( - dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, - checkLoginUseCase: checkLoginUseCase, - setBookmarkUseCase: setBookmarkUseCase, - id: id - ) - if let viewController = viewController as? MapDictionaryDetailViewController { - viewController.reactor = reactor - } + case .npc: - viewController = NpcDictionaryDetailViewController( - type: .npc, - bookmarkModalFactory: bookmarkModalFactory, - loginFactory: loginFactory(), - dictionaryDetailFactory: dictionaryDetailFactory(), - detailOnBoardingFactory: detailOnBoardingFactory, - appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + viewController = makeNpcViewController( + id: id, bookmarkRelay: bookmarkRelay, loginRelay: loginRelay ) - let reactor = NpcDictionaryDetailReactor( - dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, - checkLoginUseCase: checkLoginUseCase, - setBookmarkUseCase: setBookmarkUseCase, - id: id - ) - if let viewController = viewController as? NpcDictionaryDetailViewController { - viewController.reactor = reactor - } + case .quest: - viewController = QuestDictionaryDetailViewController( - type: .quest, - bookmarkModalFactory: bookmarkModalFactory, - loginFactory: loginFactory(), - dictionaryDetailFactory: dictionaryDetailFactory(), - detailOnBoardingFactory: detailOnBoardingFactory, - appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + viewController = makeQuestViewController( + id: id, bookmarkRelay: bookmarkRelay, loginRelay: loginRelay ) - let reactor = QuestDictionaryDetailReactor( - dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, - checkLoginUseCase: checkLoginUseCase, - setBookmarkUseCase: setBookmarkUseCase, - id: id - ) - if let viewController = viewController as? QuestDictionaryDetailViewController { - viewController.reactor = reactor - } } // 하단 탭바 히든 @@ -151,3 +87,145 @@ public final class DictionaryDetailFactoryImpl: DictionaryDetailFactory { return viewController } } + +private extension DictionaryDetailFactoryImpl { + func makeItemViewController( + id: Int, + bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, + loginRelay: PublishRelay? + ) -> BaseViewController { + let viewController = ItemDictionaryDetailViewController( + type: .item, + bookmarkModalFactory: bookmarkModalFactory, + loginFactory: loginFactory(), + detailOnBoardingFactory: detailOnBoardingFactory, + appCoordinator: appCoordinator(), + fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay + ) + + viewController.dictionaryDetailFactory = self + + viewController.reactor = ItemDictionaryDetailReactor( + dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, + checkLoginUseCase: checkLoginUseCase, + setBookmarkUseCase: setBookmarkUseCase, + id: id + ) + + return viewController + } + + func makeMonsterViewController( + id: Int, + bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, + loginRelay: PublishRelay? + ) -> BaseViewController { + let viewController = MonsterDictionaryDetailViewController( + type: .monster, + bookmarkModalFactory: bookmarkModalFactory, + loginFactory: loginFactory(), + detailOnBoardingFactory: detailOnBoardingFactory, + appCoordinator: appCoordinator(), + fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay + ) + + viewController.dictionaryDetailFactory = self + + viewController.reactor = MonsterDictionaryDetailReactor( + dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, + checkLoginUseCase: checkLoginUseCase, + setBookmarkUseCase: setBookmarkUseCase, + id: id + ) + + return viewController + } + + func makeMapViewController( + id: Int, + bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, + loginRelay: PublishRelay? + ) -> BaseViewController { + let viewController = MapDictionaryDetailViewController( + type: .map, + bookmarkModalFactory: bookmarkModalFactory, + loginFactory: loginFactory(), + detailOnBoardingFactory: detailOnBoardingFactory, + appCoordinator: appCoordinator(), + fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay + ) + + viewController.dictionaryDetailFactory = self + + viewController.reactor = MapDictionaryDetailReactor( + dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, + checkLoginUseCase: checkLoginUseCase, + setBookmarkUseCase: setBookmarkUseCase, + id: id + ) + + return viewController + } + + func makeNpcViewController( + id: Int, + bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, + loginRelay: PublishRelay? + ) -> BaseViewController { + let viewController = NpcDictionaryDetailViewController( + type: .npc, + bookmarkModalFactory: bookmarkModalFactory, + loginFactory: loginFactory(), + detailOnBoardingFactory: detailOnBoardingFactory, + appCoordinator: appCoordinator(), + fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay + ) + + viewController.dictionaryDetailFactory = self + + viewController.reactor = NpcDictionaryDetailReactor( + dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, + checkLoginUseCase: checkLoginUseCase, + setBookmarkUseCase: setBookmarkUseCase, + id: id + ) + + return viewController + } + + func makeQuestViewController( + id: Int, + bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, + loginRelay: PublishRelay? + ) -> BaseViewController { + let viewController = QuestDictionaryDetailViewController( + type: .quest, + bookmarkModalFactory: bookmarkModalFactory, + loginFactory: loginFactory(), + detailOnBoardingFactory: detailOnBoardingFactory, + appCoordinator: appCoordinator(), + fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay + ) + + viewController.dictionaryDetailFactory = self + + viewController.reactor = QuestDictionaryDetailReactor( + dictionaryDetailAPIRepository: dictionaryDetailAPIRepository, + checkLoginUseCase: checkLoginUseCase, + setBookmarkUseCase: setBookmarkUseCase, + id: id + ) + + return viewController + } +} diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift index a818c002..8bafb730 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift @@ -232,7 +232,7 @@ extension ItemDictionaryDetailViewController { } owner.tabBarController?.presentModal(viewController, hideTabBar: true) case let .detail(id): - let viewController = owner.dictionaryDetailFactory.make(type: .monster, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) + guard let viewController = owner.dictionaryDetailFactory?.make(type: .monster, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) else { return } owner.navigationController?.pushViewController(viewController, animated: true) case .bookmarkError: ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailViewController.swift index 6ff6f535..1631e825 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Map/MapDictionaryDetailViewController.swift @@ -194,7 +194,7 @@ extension MapDictionaryDetailViewController { } owner.tabBarController?.presentModal(viewController, hideTabBar: true) case .detail(let type, let id): - let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) + guard let viewController = owner.dictionaryDetailFactory?.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) else { return } owner.navigationController?.pushViewController(viewController, animated: true) case .bookmarkError: ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift index 6da8f55d..17576ad2 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift @@ -217,7 +217,7 @@ extension MonsterDictionaryDetailViewController { } owner.tabBarController?.presentModal(viewController, hideTabBar: true) case let .detail(type: type, id: id): - let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) + guard let viewController = owner.dictionaryDetailFactory?.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) else { return } owner.navigationController?.pushViewController(viewController, animated: true) case .bookmarkError: ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift index 1cbe11ff..15912138 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift @@ -138,7 +138,7 @@ extension NpcDictionaryDetailViewController { } owner.tabBarController?.presentModal(viewController, hideTabBar: true) case .detail(type: let type, id: let id): - let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) + guard let viewController = owner.dictionaryDetailFactory?.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) else { return } owner.navigationController?.pushViewController(viewController, animated: true) case .bookmarkError: ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift index fc96160d..1fc038be 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift @@ -197,7 +197,7 @@ extension QuestDictionaryDetailViewController { .subscribe { owner, route in switch route { case let .detail(type, id): - let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) + guard let viewController = owner.dictionaryDetailFactory?.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) else { return } owner.navigationController?.pushViewController(viewController, animated: true) case .bookmarkError: ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryDetailFactory.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryDetailFactory.swift index ccda4a2d..795a0144 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryDetailFactory.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeatureInterface/Factories/DictionaryDetailFactory.swift @@ -2,6 +2,6 @@ import MLSCore import RxCocoa -public protocol DictionaryDetailFactory { +public protocol DictionaryDetailFactory: AnyObject { func make(type: DictionaryType, id: Int, bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, loginRelay: PublishRelay?) -> BaseViewController } diff --git a/MLS/MLSDictionaryFeatureExample/SceneDelegate.swift b/MLS/MLSDictionaryFeatureExample/SceneDelegate.swift index 5594b8c5..bfbbf06b 100644 --- a/MLS/MLSDictionaryFeatureExample/SceneDelegate.swift +++ b/MLS/MLSDictionaryFeatureExample/SceneDelegate.swift @@ -60,16 +60,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { MockNotificationSettingFactory() // MARK: - Detail Factory - var detailFactory: DictionaryDetailFactoryImpl! - - detailFactory = DictionaryDetailFactoryImpl( + var detailFactory = DictionaryDetailFactoryImpl( loginFactory: { loginFactory }, bookmarkModalFactory: bookmarkModalFactory, - dictionaryDetailFactory: { - detailFactory - }, +// dictionaryDetailFactory: { +// detailFactory +// }, detailOnBoardingFactory: detailOnBoardingFactory, appCoordinator: { MockAppCoordinator() From 668f315b841824d7b8dd3f2eb4eadb1d5014256d Mon Sep 17 00:00:00 2001 From: p2glet Date: Wed, 3 Jun 2026 23:29:31 +0900 Subject: [PATCH 14/16] =?UTF-8?q?fix/#332:=20MonsterDictionaryDetailReacto?= =?UTF-8?q?r=20itemTapped=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MonsterDictionaryDetailReactor.swift | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift index e50401af..1b5c0598 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift @@ -118,11 +118,25 @@ public final class MonsterDictionaryDetailReactor: Reactor { case .undoLastDeletedBookmark: return handleUndoLastDeletedBookmark() - case .itemTapped(index: let index): - return .just(.navigateTo(.detail(type: .item, id: currentState.dropItems[index].itemId))) - - case .mapTapped(index: let index): - return .just(.navigateTo(.detail(type: .map, id: currentState.spawnMaps[index].mapId))) + case let .itemTapped(index): + guard currentState.dropItems.indices.contains(index) else { + return .empty() + } + + return .just( + .navigateTo(.detail(type: .item, id: currentState.dropItems[index].itemId) + ) + ) + + case let .mapTapped(index): + guard currentState.spawnMaps.indices.contains(index) else { + return .empty() + } + + return .just( + .navigateTo(.detail(type: .map, id: currentState.spawnMaps[index].mapId) + ) + ) } } From 616752accb8063abcd9e6cde8099e5ebb788324b Mon Sep 17 00:00:00 2001 From: p2glet Date: Wed, 3 Jun 2026 23:31:22 +0900 Subject: [PATCH 15/16] =?UTF-8?q?fix/#332:=20LaunchScreen.storyboard=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Base.lproj/LaunchScreen.storyboard | 25 ------------------- 1 file changed, 25 deletions(-) delete mode 100644 MLS/MLSDictionaryFeatureExample/Base.lproj/LaunchScreen.storyboard diff --git a/MLS/MLSDictionaryFeatureExample/Base.lproj/LaunchScreen.storyboard b/MLS/MLSDictionaryFeatureExample/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index 865e9329..00000000 --- a/MLS/MLSDictionaryFeatureExample/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - From 5d9044e34580a3a8f5e5969ec11e7fb1fe57cf4e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 3 Jun 2026 14:44:47 +0000 Subject: [PATCH 16/16] style/#332: Apply SwiftLint autocorrect --- .../DictionaryDetail/DictionaryDetailBaseViewController.swift | 2 +- .../NPC/NpcDictionaryDetailViewController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift index 34a2fdc7..192b9bdb 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/DictionaryDetailBaseViewController.swift @@ -295,7 +295,7 @@ extension DictionaryDetailBaseViewController { func didSelectMenuTab(index: Int) { // 인덱스 유효성 검사 guard index < contentViews.count else { return } - + // 각 탭에 맞는 뷰 설정 mainView.setTabView(index: index, contentViews: contentViews) currentTabIndex = index diff --git a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift index 15912138..6efedf8d 100644 --- a/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift +++ b/MLS/MLSDictionaryFeature/Sources/MLSDictionaryFeature/Presentation/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift @@ -53,7 +53,7 @@ private extension NpcDictionaryDetailViewController { appearMapView.reset() if maps.isEmpty { // 출현맵 - + } else { setContentView(view: appearMapView, detailType: .appearMap) for map in maps {