diff --git a/MLS/MLS.xcodeproj/project.pbxproj b/MLS/MLS.xcodeproj/project.pbxproj index fee16634..b0ab1b5c 100644 --- a/MLS/MLS.xcodeproj/project.pbxproj +++ b/MLS/MLS.xcodeproj/project.pbxproj @@ -79,6 +79,12 @@ 77FA68BA2F72C9C10064B6EB /* RxSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 77FA68B92F72C9C10064B6EB /* RxSwift */; }; 77FA68BC2F72C9C70064B6EB /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 77FA68BB2F72C9C70064B6EB /* SnapKit */; }; 77FA68BE2F72CA490064B6EB /* MLSDesignSystem in Frameworks */ = {isa = PBXBuildFile; productRef = 77FA68BD2F72CA490064B6EB /* MLSDesignSystem */; }; + BBCC0B002FA000000000BBCC /* MLSBookmarkFeature in Frameworks */ = {isa = PBXBuildFile; productRef = BBCC0C002FA000000000BBCC /* MLSBookmarkFeature */; }; + BBCC0D002FA000000000BBCC /* MLSBookmarkFeatureInterface in Frameworks */ = {isa = PBXBuildFile; productRef = BBCC0E002FA000000000BBCC /* MLSBookmarkFeatureInterface */; }; + BBCC0F002FA000000000BBCC /* MLSBookmarkFeatureTesting in Frameworks */ = {isa = PBXBuildFile; productRef = BBCC10002FA000000000BBCC /* MLSBookmarkFeatureTesting */; }; + BBCC11002FA000000000BBCC /* MLSCore in Frameworks */ = {isa = PBXBuildFile; productRef = BBCC12002FA000000000BBCC /* MLSCore */; }; + BBCC13002FA000000000BBCC /* MLSAuthFeatureInterface in Frameworks */ = {isa = PBXBuildFile; productRef = BBCC14002FA000000000BBCC /* MLSAuthFeatureInterface */; }; + BBCC15002FA000000000BBCC /* MLSDesignSystem in Frameworks */ = {isa = PBXBuildFile; productRef = BBCC16002FA000000000BBCC /* MLSDesignSystem */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -171,6 +177,7 @@ 77E260402EEABEC40059E889 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; name = Settings.bundle; path = MLS/Resource/Settings.bundle; sourceTree = ""; }; 77EB18D52DED9256004FB380 /* AuthFeature.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AuthFeature.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 77FA687A2F72C7360064B6EB /* MLSDesignSystemExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLSDesignSystemExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + BBCC02002FA000000000BBCC /* MLSBookmarkFeatureExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLSBookmarkFeatureExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -216,6 +223,13 @@ ); target = 77FA68792F72C7360064B6EB /* MLSDesignSystemExample */; }; + BBCC04002FA000000000BBCC /* Exceptions for "MLSBookmarkFeatureExample" folder in "MLSBookmarkFeatureExample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = BBCC01002FA000000000BBCC /* MLSBookmarkFeatureExample */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -272,6 +286,14 @@ path = MLSDesignSystemExample; sourceTree = ""; }; + BBCC03002FA000000000BBCC /* MLSBookmarkFeatureExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + BBCC04002FA000000000BBCC /* Exceptions for "MLSBookmarkFeatureExample" folder in "MLSBookmarkFeatureExample" target */, + ); + path = MLSBookmarkFeatureExample; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -375,6 +397,19 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BBCC06002FA000000000BBCC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BBCC0B002FA000000000BBCC /* MLSBookmarkFeature in Frameworks */, + BBCC0D002FA000000000BBCC /* MLSBookmarkFeatureInterface in Frameworks */, + BBCC0F002FA000000000BBCC /* MLSBookmarkFeatureTesting in Frameworks */, + BBCC11002FA000000000BBCC /* MLSCore in Frameworks */, + BBCC13002FA000000000BBCC /* MLSAuthFeatureInterface in Frameworks */, + BBCC15002FA000000000BBCC /* MLSDesignSystem in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -461,6 +496,7 @@ 08F7A9242F86745C00EF5C06 /* MLSAuthFeatureExample */, 77217FBF2F9A04CF000915EF /* MLSMyPageFeatureExample */, 08F7DC622F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */, + BBCC03002FA000000000BBCC /* MLSBookmarkFeatureExample */, 7794E1072FBCA8AA001474A2 /* MLSDictionaryFeatureExample */, 084A25312DB93A5400C395C0 /* Frameworks */, 087D3EE92DA7972C002F924D /* Products */, @@ -476,6 +512,7 @@ 08F7A9232F86745C00EF5C06 /* MLSAuthFeatureExample.app */, 77217FBE2F9A04CF000915EF /* MLSMyPageFeatureExample.app */, 08F7DC612F9DEA8000EF5C06 /* MLSRecommendationFeatureExample.app */, + BBCC02002FA000000000BBCC /* MLSBookmarkFeatureExample.app */, 7794E1062FBCA8AA001474A2 /* MLSDictionaryFeatureExample.app */, ); name = Products; @@ -675,6 +712,34 @@ productReference = 77FA687A2F72C7360064B6EB /* MLSDesignSystemExample.app */; productType = "com.apple.product-type.application"; }; + BBCC01002FA000000000BBCC /* MLSBookmarkFeatureExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = BBCC08002FA000000000BBCC /* Build configuration list for PBXNativeTarget "MLSBookmarkFeatureExample" */; + buildPhases = ( + BBCC05002FA000000000BBCC /* Sources */, + BBCC06002FA000000000BBCC /* Frameworks */, + BBCC07002FA000000000BBCC /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + BBCC03002FA000000000BBCC /* MLSBookmarkFeatureExample */, + ); + name = MLSBookmarkFeatureExample; + packageProductDependencies = ( + BBCC0C002FA000000000BBCC /* MLSBookmarkFeature */, + BBCC0E002FA000000000BBCC /* MLSBookmarkFeatureInterface */, + BBCC10002FA000000000BBCC /* MLSBookmarkFeatureTesting */, + BBCC12002FA000000000BBCC /* MLSCore */, + BBCC14002FA000000000BBCC /* MLSAuthFeatureInterface */, + BBCC16002FA000000000BBCC /* MLSDesignSystem */, + ); + productName = MLSBookmarkFeatureExample; + productReference = BBCC02002FA000000000BBCC /* MLSBookmarkFeatureExample.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -764,6 +829,7 @@ 08F7A9222F86745C00EF5C06 /* MLSAuthFeatureExample */, 77217FBD2F9A04CF000915EF /* MLSMyPageFeatureExample */, 08F7DC602F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */, + BBCC01002FA000000000BBCC /* MLSBookmarkFeatureExample */, 7794E1052FBCA8AA001474A2 /* MLSDictionaryFeatureExample */, ); }; @@ -822,6 +888,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BBCC07002FA000000000BBCC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -895,6 +968,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BBCC05002FA000000000BBCC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -1526,6 +1606,78 @@ }; name = Release; }; + BBCC09002FA000000000BBCC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = W5QTRMS954; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MLSBookmarkFeatureExample/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 = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLS.BookmarkFeatureExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + BBCC0A002FA000000000BBCC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = W5QTRMS954; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MLSBookmarkFeatureExample/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 = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLS.BookmarkFeatureExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1601,6 +1753,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + BBCC08002FA000000000BBCC /* Build configuration list for PBXNativeTarget "MLSBookmarkFeatureExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BBCC09002FA000000000BBCC /* Debug */, + BBCC0A002FA000000000BBCC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -1809,6 +1970,30 @@ isa = XCSwiftPackageProductDependency; productName = MLSDesignSystem; }; + BBCC0C002FA000000000BBCC /* MLSBookmarkFeature */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSBookmarkFeature; + }; + BBCC0E002FA000000000BBCC /* MLSBookmarkFeatureInterface */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSBookmarkFeatureInterface; + }; + BBCC10002FA000000000BBCC /* MLSBookmarkFeatureTesting */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSBookmarkFeatureTesting; + }; + BBCC12002FA000000000BBCC /* MLSCore */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSCore; + }; + BBCC14002FA000000000BBCC /* MLSAuthFeatureInterface */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSAuthFeatureInterface; + }; + BBCC16002FA000000000BBCC /* MLSDesignSystem */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSDesignSystem; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 087D3EE02DA7972C002F924D /* Project object */; diff --git a/MLS/MLS.xcworkspace/contents.xcworkspacedata b/MLS/MLS.xcworkspace/contents.xcworkspacedata index f07f779c..952c7c53 100644 --- a/MLS/MLS.xcworkspace/contents.xcworkspacedata +++ b/MLS/MLS.xcworkspace/contents.xcworkspacedata @@ -56,4 +56,7 @@ + + diff --git a/MLS/MLSBookmarkFeature/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/MLS/MLSBookmarkFeature/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/MLS/MLSBookmarkFeature/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/MLS/MLSBookmarkFeature/Package.swift b/MLS/MLSBookmarkFeature/Package.swift new file mode 100644 index 00000000..dda4b20d --- /dev/null +++ b/MLS/MLSBookmarkFeature/Package.swift @@ -0,0 +1,81 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "MLSBookmarkFeature", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "MLSBookmarkFeatureInterface", + targets: ["MLSBookmarkFeatureInterface"] + ), + .library( + name: "MLSBookmarkFeature", + targets: ["MLSBookmarkFeature"] + ), + .library( + name: "MLSBookmarkFeatureTesting", + targets: ["MLSBookmarkFeatureTesting"] + ) + ], + dependencies: [ + .package(path: "../MLSAuthFeature"), + .package(path: "../MLSCore"), + .package(path: "../MLSDesignSystem"), + .package(url: "https://github.com/ReactorKit/ReactorKit.git", from: "3.2.0"), + .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.7.0"), + .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1") + ], + targets: [ + // Interface 모듈 (팩토리/리포지토리 프로토콜 + 엔티티) + .target( + name: "MLSBookmarkFeatureInterface", + dependencies: [ + .product(name: "MLSCore", package: "MLSCore"), + .product(name: "MLSDesignSystem", package: "MLSDesignSystem"), + .product(name: "RxSwift", package: "RxSwift"), + .product(name: "RxRelay", package: "RxSwift") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + // Feature 모듈 (Presentation + Domain + Data 구현체) + .target( + name: "MLSBookmarkFeature", + dependencies: [ + "MLSBookmarkFeatureInterface", + .product(name: "MLSAuthFeatureInterface", package: "MLSAuthFeature"), + .product(name: "MLSCore", package: "MLSCore"), + .product(name: "MLSDesignSystem", package: "MLSDesignSystem"), + .product(name: "ReactorKit", package: "ReactorKit"), + .product(name: "RxSwift", package: "RxSwift"), + .product(name: "RxCocoa", package: "RxSwift"), + .product(name: "RxRelay", package: "RxSwift"), + .product(name: "SnapKit", package: "SnapKit") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + // Testing 모듈 (Mock + Stub 객체) + .target( + name: "MLSBookmarkFeatureTesting", + dependencies: [ + "MLSBookmarkFeatureInterface", + .product(name: "MLSAuthFeatureInterface", package: "MLSAuthFeature"), + .product(name: "MLSCore", package: "MLSCore"), + .product(name: "MLSDesignSystem", package: "MLSDesignSystem"), + .product(name: "RxSwift", package: "RxSwift"), + .product(name: "RxRelay", package: "RxSwift") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + // Tests 모듈 + .testTarget( + name: "MLSBookmarkFeatureTests", + dependencies: [ + "MLSBookmarkFeature", + "MLSBookmarkFeatureInterface", + "MLSBookmarkFeatureTesting", + .product(name: "RxBlocking", package: "RxSwift") + ] + ) + ] +) diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/DTOs/BookmarkDTO.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/DTOs/BookmarkDTO.swift new file mode 100644 index 00000000..f5f9765b --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/DTOs/BookmarkDTO.swift @@ -0,0 +1,28 @@ +import MLSBookmarkFeatureInterface + +struct BookmarkDTO: Decodable { + let bookmarkId: Int + let originalId: Int + let name: String + let imageUrl: String + let type: String + let level: Int? + + func toDomain() -> BookmarkResponse? { + guard let type = DictionaryItemType(rawValue: type) else { return nil } + return BookmarkResponse( + name: name, + bookmarkId: bookmarkId, + originalId: originalId, + imageUrl: imageUrl, + type: type, + level: level + ) + } +} + +extension Array where Element == BookmarkDTO { + func toDomain() -> [BookmarkResponse] { + return self.compactMap { $0.toDomain() } + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/DTOs/CollectionListResponseDTO.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/DTOs/CollectionListResponseDTO.swift new file mode 100644 index 00000000..b3494c56 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/DTOs/CollectionListResponseDTO.swift @@ -0,0 +1,17 @@ +import MLSBookmarkFeatureInterface + +struct CollectionListResponseDTO: Decodable { + let collectionId: Int + let name: String + let createdAt: [Int] + let recentBookmarks: [BookmarkDTO] + + func toDomain() -> CollectionResponse { + return CollectionResponse( + collectionId: collectionId, + name: name, + createdAt: createdAt, + recentBookmarks: recentBookmarks.toDomain() + ) + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/DTOs/EmptyResponseDTO.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/DTOs/EmptyResponseDTO.swift new file mode 100644 index 00000000..fc48e0bd --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/DTOs/EmptyResponseDTO.swift @@ -0,0 +1,5 @@ +struct EmptyResponseDTO: Decodable { + func toBookmarkDomain() -> Int? { + return nil + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/DTOs/SetBookmarkDTO.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/DTOs/SetBookmarkDTO.swift new file mode 100644 index 00000000..676b62bf --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/DTOs/SetBookmarkDTO.swift @@ -0,0 +1,9 @@ +struct SetBookmarkDTO: Decodable { + let bookmarkId: Int + let bookmarkType: String + let resourceId: Int + + func toDomain() -> Int { + return self.bookmarkId + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Endpoints/BookmarkEndPoint.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Endpoints/BookmarkEndPoint.swift new file mode 100644 index 00000000..ca79ae5d --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Endpoints/BookmarkEndPoint.swift @@ -0,0 +1,37 @@ +import MLSCore + +enum BookmarkEndPoint { + private static let base = "https://mapleland.2megabytes.me" + + static func setBookmark(body: Encodable) -> ResponsableEndPoint { + .init(baseURL: base, path: "/api/v1/bookmarks", method: .POST, body: body) + } + + static func deleteBookmark(bookmarkId: Int) -> ResponsableEndPoint { + .init(baseURL: base, path: "/api/v1/bookmarks/\(bookmarkId)", method: .DELETE) + } + + static func fetchBookmark(query: Encodable) -> ResponsableEndPoint<[BookmarkDTO]> { + .init(baseURL: base, path: "/api/v1/bookmarks", method: .GET, query: query) + } + + static func fetchMonsterBookmark(query: Encodable) -> ResponsableEndPoint<[BookmarkDTO]> { + .init(baseURL: base, path: "/api/v1/bookmarks/monsters", method: .GET, query: query) + } + + static func fetchNPCBookmark(query: Encodable) -> ResponsableEndPoint<[BookmarkDTO]> { + .init(baseURL: base, path: "/api/v1/bookmarks/npcs", method: .GET, query: query) + } + + static func fetchQuestBookmark(query: Encodable) -> ResponsableEndPoint<[BookmarkDTO]> { + .init(baseURL: base, path: "/api/v1/bookmarks/quests", method: .GET, query: query) + } + + static func fetchItemBookmark(query: Encodable) -> ResponsableEndPoint<[BookmarkDTO]> { + .init(baseURL: base, path: "/api/v1/bookmarks/items", method: .GET, query: query) + } + + static func fetchMapBookmark(query: Encodable) -> ResponsableEndPoint<[BookmarkDTO]> { + .init(baseURL: base, path: "/api/v1/bookmarks/maps", method: .GET, query: query) + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Endpoints/CollectionEndPoint.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Endpoints/CollectionEndPoint.swift new file mode 100644 index 00000000..a5821920 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Endpoints/CollectionEndPoint.swift @@ -0,0 +1,29 @@ +import MLSCore + +enum CollectionEndPoint { + private static let base = "https://mapleland.2megabytes.me" + + static func fetchCollectionList(query: Encodable) -> ResponsableEndPoint<[CollectionListResponseDTO]> { + .init(baseURL: base, path: "/api/v1/collections", method: .GET, query: query) + } + + static func createCollectionList(body: Encodable) -> EndPoint { + .init(baseURL: base, path: "/api/v1/collections", method: .POST, body: body) + } + + static func fetchCollection(id: Int) -> ResponsableEndPoint<[BookmarkDTO]> { + .init(baseURL: base, path: "/api/v1/collections/\(id)/bookmarks", method: .GET) + } + + static func setCollectionName(id: Int, body: Encodable) -> EndPoint { + .init(baseURL: base, path: "/api/v1/collections/\(id)", method: .PUT, body: body) + } + + static func deleteCollection(id: Int) -> EndPoint { + .init(baseURL: base, path: "/api/v1/collections/\(id)", method: .DELETE) + } + + static func addCollectionAndBookmark(body: Encodable) -> EndPoint { + .init(baseURL: base, path: "/api/v1/bookmark-collections", method: .POST, body: body) + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Repositories/BookmarkAuthRepositoryImpl.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Repositories/BookmarkAuthRepositoryImpl.swift new file mode 100644 index 00000000..2905423d --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Repositories/BookmarkAuthRepositoryImpl.swift @@ -0,0 +1,33 @@ +import MLSAuthFeatureInterface +import MLSBookmarkFeatureInterface +import RxSwift + +final class BookmarkAuthRepositoryImpl: BookmarkAuthRepository { + private let tokenRepository: TokenRepository + private let authAPIRepository: AuthAPIRepository + + init(tokenRepository: TokenRepository, authAPIRepository: AuthAPIRepository) { + self.tokenRepository = tokenRepository + self.authAPIRepository = authAPIRepository + } + + func isLoggedIn() -> Observable { + switch tokenRepository.fetchToken(type: .refreshToken) { + case .success(let token): + guard !token.isEmpty else { return .just(false) } + return authAPIRepository.reissueToken(refreshToken: token) + .map { [weak self] response -> Bool 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 + default: return false + } + } + .catch { _ in .just(false) } + case .failure: + return .just(false) + } + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Repositories/BookmarkRepositoryImpl.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Repositories/BookmarkRepositoryImpl.swift new file mode 100644 index 00000000..49f33429 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Repositories/BookmarkRepositoryImpl.swift @@ -0,0 +1,86 @@ +import MLSBookmarkFeatureInterface +import MLSCore +import RxSwift + +final class BookmarkRepositoryImpl: BookmarkRepository { + private let provider: NetworkProvider + private let interceptor: Interceptor + + init() { + self.provider = NetworkProviderImpl() + self.interceptor = TokenInterceptor() + } + + func setBookmark(resourceId: Int, type: DictionaryItemType) -> Observable { + let endpoint = BookmarkEndPoint.setBookmark(body: SetBookmarkBody(bookmarkType: type.rawValue, resourceId: resourceId)) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + .map { $0.toDomain() } + } + + func deleteBookmark(bookmarkId: Int) -> Observable { + let endpoint = BookmarkEndPoint.deleteBookmark(bookmarkId: bookmarkId) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + .map { $0.toBookmarkDomain() } + } + + func fetchBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + let endpoint = BookmarkEndPoint.fetchBookmark(query: SortQuery(sort: sort)) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + .map { $0.toDomain() } + } + + func fetchMonsterBookmark(minLevel: Int?, maxLevel: Int?, sort: String?) -> Observable<[BookmarkResponse]> { + let endpoint = BookmarkEndPoint.fetchMonsterBookmark(query: MonsterQuery(minLevel: minLevel, maxLevel: maxLevel, sort: sort)) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + .map { $0.toDomain() } + } + + func fetchNPCBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + let endpoint = BookmarkEndPoint.fetchNPCBookmark(query: SortQuery(sort: sort)) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + .map { $0.toDomain() } + } + + func fetchQuestBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + let endpoint = BookmarkEndPoint.fetchQuestBookmark(query: SortQuery(sort: sort)) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + .map { $0.toDomain() } + } + + func fetchItemBookmark(jobId: Int?, minLevel: Int?, maxLevel: Int?, categoryIds: [Int]?, sort: String?) -> Observable<[BookmarkResponse]> { + let endpoint = BookmarkEndPoint.fetchItemBookmark(query: ItemQuery(jobId: jobId, minLevel: minLevel, maxLevel: maxLevel, categoryIds: categoryIds, sort: sort)) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + .map { $0.toDomain() } + } + + func fetchMapBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + let endpoint = BookmarkEndPoint.fetchMapBookmark(query: SortQuery(sort: sort)) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + .map { $0.toDomain() } + } +} + +private extension BookmarkRepositoryImpl { + struct SortQuery: Encodable { + let sort: String? + } + + struct SetBookmarkBody: Encodable { + let bookmarkType: String + let resourceId: Int + } + + struct MonsterQuery: Encodable { + let minLevel: Int? + let maxLevel: Int? + let sort: String? + } + + struct ItemQuery: Encodable { + let jobId: Int? + let minLevel: Int? + let maxLevel: Int? + let categoryIds: [Int]? + let sort: String? + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Repositories/BookmarkUserDefaultsRepositoryImpl.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Repositories/BookmarkUserDefaultsRepositoryImpl.swift new file mode 100644 index 00000000..8176ca4b --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Repositories/BookmarkUserDefaultsRepositoryImpl.swift @@ -0,0 +1,28 @@ +import Foundation +import MLSBookmarkFeatureInterface +import RxSwift + +final class BookmarkUserDefaultsRepositoryImpl: BookmarkUserDefaultsRepository { + private let key = "bookmark_onboarding_visited" + + func hasVisitedOnboarding() -> Observable { + let hasVisited = UserDefaults.standard.bool(forKey: key) + if !hasVisited { + UserDefaults.standard.set(true, forKey: key) + return .just(false) + } + return .just(true) + } + + func markOnboardingVisited() -> Completable { + return Completable.create { [weak self] observer in + guard let self else { + observer(.completed) + return Disposables.create() + } + UserDefaults.standard.set(true, forKey: self.key) + observer(.completed) + return Disposables.create() + } + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Repositories/CollectionRepositoryImpl.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Repositories/CollectionRepositoryImpl.swift new file mode 100644 index 00000000..903e6efd --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Data/Repositories/CollectionRepositoryImpl.swift @@ -0,0 +1,64 @@ +import MLSBookmarkFeatureInterface +import MLSCore +import RxSwift + +final class CollectionRepositoryImpl: CollectionRepository { + private let provider: NetworkProvider + private let interceptor: Interceptor + + init() { + self.provider = NetworkProviderImpl() + self.interceptor = TokenInterceptor() + } + + func fetchCollectionList(sort: String?) -> Observable<[CollectionResponse]> { + let endpoint = CollectionEndPoint.fetchCollectionList(query: FetchListQuery(sort: sort)) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + .map { $0.map { $0.toDomain() } } + } + + func createCollectionList(name: String) -> Completable { + let endpoint = CollectionEndPoint.createCollectionList(body: CreateBody(name: name)) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + } + + func fetchCollection(id: Int) -> Observable<[BookmarkResponse]> { + let endpoint = CollectionEndPoint.fetchCollection(id: id) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + .map { $0.toDomain() } + } + + func updateCollectionName(collectionId: Int, name: String) -> Completable { + let endpoint = CollectionEndPoint.setCollectionName(id: collectionId, body: UpdateNameBody(name: name)) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + } + + func deleteCollection(collectionId: Int) -> Completable { + let endpoint = CollectionEndPoint.deleteCollection(id: collectionId) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + } + + func addCollectionAndBookmark(collectionIds: [Int], bookmarkIds: [Int]) -> Completable { + let endpoint = CollectionEndPoint.addCollectionAndBookmark(body: AddBody(collectionIds: collectionIds, bookmarkIds: bookmarkIds)) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + } +} + +private extension CollectionRepositoryImpl { + struct FetchListQuery: Encodable { + let sort: String? + } + + struct CreateBody: Encodable { + let name: String + } + + struct UpdateNameBody: Encodable { + let name: String + } + + struct AddBody: Encodable { + let collectionIds: [Int] + let bookmarkIds: [Int] + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/AddCollection/AddCollectionFactoryImpl.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/AddCollection/AddCollectionFactoryImpl.swift new file mode 100644 index 00000000..70bc2ba6 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/AddCollection/AddCollectionFactoryImpl.swift @@ -0,0 +1,19 @@ +import MLSBookmarkFeatureInterface +import MLSCore + +public final class AddCollectionFactoryImpl: AddCollectionFactory { + private let collectionRepository: CollectionRepository + + public init(collectionRepository: CollectionRepository) { + self.collectionRepository = collectionRepository + } + + public func make(collection: CollectionResponse?) -> BaseViewController { + let viewController = AddCollectionViewController() + viewController.reactor = AddCollectionReactor( + collection: collection, + collectionRepository: collectionRepository + ) + return viewController + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/AddCollection/AddCollectionReactor.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/AddCollection/AddCollectionReactor.swift new file mode 100644 index 00000000..15b8f3a0 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/AddCollection/AddCollectionReactor.swift @@ -0,0 +1,80 @@ +import MLSBookmarkFeatureInterface +import ReactorKit +import RxSwift + +final class AddCollectionReactor: Reactor { + enum Route { + case dismiss + case dismissWithData + case createError + case updateError + } + + enum Action { + case inputTextChanged(String?) + case backButtonTapped + case completeButtonTapped + } + + enum Mutation { + case saveInput(String) + case setError(Bool) + case setButtonEnabled(Bool) + case toNavigate(Route) + } + + struct State { + @Pulse var route: Route? + var collection: CollectionResponse? + var inputText: String? + var isError: Bool = false + var isButtonEnabled: Bool = false + } + + var initialState: State + private let collectionRepository: CollectionRepository + + init(collection: CollectionResponse?, collectionRepository: CollectionRepository) { + self.initialState = State(collection: collection, inputText: collection?.name) + self.collectionRepository = collectionRepository + } + + func mutate(action: Action) -> Observable { + switch action { + case .inputTextChanged(let text): + let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return Observable.from([.setButtonEnabled(!trimmed.isEmpty), .saveInput(trimmed)]) + + case .backButtonTapped: + return .just(.toNavigate(.dismiss)) + + case .completeButtonTapped: + guard let text = currentState.inputText else { return .empty() } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.count > 18 { + return .just(.setError(true)) + } + if currentState.collection == nil { + return collectionRepository.createCollectionList(name: trimmed) + .andThen(.just(.toNavigate(.dismissWithData))) + .catch { _ in .just(.toNavigate(.createError)) } + } else { + guard let id = currentState.collection?.collectionId else { return .empty() } + return collectionRepository.updateCollectionName(collectionId: id, name: trimmed) + .andThen(.just(.toNavigate(.dismissWithData))) + .catch { _ in .just(.toNavigate(.updateError)) } + } + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case .saveInput(let text): newState.inputText = text + case .setError(let isError): newState.isError = isError + case .setButtonEnabled(let isEnabled): newState.isButtonEnabled = isEnabled + case .toNavigate(let route): newState.route = route + } + return newState + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/AddCollection/AddCollectionView.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/AddCollection/AddCollectionView.swift new file mode 100644 index 00000000..700a0830 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/AddCollection/AddCollectionView.swift @@ -0,0 +1,171 @@ +import MLSBookmarkFeatureInterface +import MLSDesignSystem +import SnapKit +import UIKit + +final class AddCollectionView: UIView { + enum Constant { + static let radius: CGFloat = 8 + static let titleTopMargin: CGFloat = 20 + static let imageViewSize: CGFloat = 40 + static let iconInset: CGFloat = 8 + static let inputInset: CGFloat = 10 + static let inputSpacing: CGFloat = 16 + static let inputHeight: CGFloat = 60 + static let horizontalMargin: CGFloat = 16 + static let nameLabelTopMargin: CGFloat = 20 + static let nameLabelBottomMargin: CGFloat = 14 + static let buttonTopMargin: CGFloat = 68 + static let buttonBottomMargin: CGFloat = 10 + } + + var addButtonBottomConstraint: Constraint? + + private let titleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .h_xl_b, text: "컬렉션", alignment: .left) + return label + }() + + let backButton: UIButton = { + let button = UIButton() + button.setImage(DesignSystemAsset.image(named: "largeX"), for: .normal) + return button + }() + + private lazy var inputTextView: UIView = { + let view = UIView() + view.backgroundColor = .neutral100 + view.layer.cornerRadius = Constant.radius + view.addSubview(imageView) + view.addSubview(inputTextField) + + imageView.snp.makeConstraints { make in + make.verticalEdges.leading.equalToSuperview().inset(Constant.inputInset) + make.size.equalTo(Constant.imageViewSize) + } + + inputTextField.snp.makeConstraints { make in + make.centerY.trailing.equalToSuperview() + make.leading.equalTo(imageView.snp.trailing).offset(Constant.inputSpacing) + } + return view + }() + + private var nameLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .sub_m_sb, text: "컬렉션 이름 입력", alignment: .left) + return label + }() + + private lazy var imageView: UIView = { + let view = UIView() + view.layer.cornerRadius = Constant.radius + view.backgroundColor = .neutral200 + view.addSubview(iconView) + iconView.snp.makeConstraints { make in + make.center.equalTo(view).inset(Constant.iconInset) + } + return view + }() + + private let iconView: UIImageView = { + let view = UIImageView() + view.image = DesignSystemAsset.image(named: "bookmark").withRenderingMode(.alwaysTemplate) + view.tintColor = .neutral300 + return view + }() + + let inputTextField: UITextField = { + let textField = UITextField() + textField.backgroundColor = .clearMLS + textField.tintColor = .primary300 + textField.textAlignment = .left + textField.font = .korFont(style: .semiBold, size: 14) + textField.attributedPlaceholder = NSAttributedString( + string: "컬렉션 이름을 입력해주세요 (최대 18자)", + attributes: [.foregroundColor: UIColor.neutral500] + ) + return textField + }() + + private let errorMessage = ErrorMessage(message: "폴더명은 18자 이하로 입력해주세요.") + let completeButton = CommonButton(style: .normal, title: "완료", disabledTitle: "완료") + + init() { + super.init(frame: .zero) + addViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} + +private extension AddCollectionView { + func addViews() { + addSubview(titleLabel) + addSubview(backButton) + addSubview(nameLabel) + addSubview(inputTextView) + addSubview(errorMessage) + addSubview(completeButton) + } + + func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(Constant.titleTopMargin) + $0.leading.equalToSuperview().inset(Constant.horizontalMargin) + } + + backButton.snp.makeConstraints { + $0.top.equalTo(titleLabel) + $0.leading.equalTo(titleLabel.snp.trailing) + $0.trailing.equalToSuperview().inset(Constant.horizontalMargin) + } + + nameLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(Constant.nameLabelTopMargin) + $0.horizontalEdges.equalToSuperview().inset(Constant.horizontalMargin) + } + + inputTextView.snp.makeConstraints { + $0.top.equalTo(nameLabel.snp.bottom).offset(Constant.nameLabelBottomMargin) + $0.horizontalEdges.equalToSuperview().inset(Constant.horizontalMargin) + $0.height.equalTo(Constant.inputHeight) + } + + errorMessage.snp.makeConstraints { + $0.bottom.equalTo(completeButton.snp.top).offset(-Constant.buttonBottomMargin) + $0.centerX.equalToSuperview() + } + + completeButton.snp.makeConstraints { + $0.top.equalTo(inputTextView.snp.bottom).offset(Constant.buttonTopMargin) + $0.horizontalEdges.equalToSuperview().inset(Constant.horizontalMargin) + addButtonBottomConstraint = $0.bottom.equalToSuperview().inset(Constant.buttonBottomMargin).constraint + } + } +} + +extension AddCollectionView { + func setError(isError: Bool) { + errorMessage.isHidden = !isError + } + + func setButtonEnabled(isEnabled: Bool) { + completeButton.isEnabled = isEnabled + } + + func checkIsEmptyCollection(collection: CollectionResponse?) { + if collection != nil { + nameLabel.attributedText = .makeStyledString(font: .sub_m_sb, text: "컬렉션 이름 수정", alignment: .left) + } + } + + func updateTextField(text: String?) { + if let text { + inputTextField.attributedText = .makeStyledString(font: .b_s_sb, text: text, color: .textColor, alignment: .left) + } + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/AddCollection/AddCollectionViewController.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/AddCollection/AddCollectionViewController.swift new file mode 100644 index 00000000..a82dcf12 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/AddCollection/AddCollectionViewController.swift @@ -0,0 +1,209 @@ +import MLSCore +import MLSDesignSystem +import ReactorKit +import RxSwift +import SnapKit +import UIKit + +final class AddCollectionViewController: BaseViewController, View { + typealias Reactor = AddCollectionReactor + + var disposeBag = DisposeBag() + let dismissed = PublishSubject() + + private let mainView = AddCollectionView() + private let addCollectionContainer = UIView() + private let dimmedBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.5) + view.alpha = 0 + view.isHidden = true + view.isUserInteractionEnabled = true + return view + }() + + override init() { + super.init() + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + setupViews() + setupConstraints() + setupGestures() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + setupKeyboard() + presentWithAnimation() + } +} + +private extension AddCollectionViewController { + func setupViews() { + view.backgroundColor = .clear + view.addSubview(dimmedBackgroundView) + view.addSubview(addCollectionContainer) + addCollectionContainer.addSubview(mainView) + + addCollectionContainer.layer.cornerRadius = 16 + addCollectionContainer.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + addCollectionContainer.backgroundColor = .whiteMLS + addCollectionContainer.isHidden = true + } + + func setupConstraints() { + dimmedBackgroundView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + + addCollectionContainer.snp.makeConstraints { make in + make.horizontalEdges.bottom.equalTo(view.safeAreaLayoutGuide) + } + + mainView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + func setupGestures() { + let tapGesture = UITapGestureRecognizer() + dimmedBackgroundView.addGestureRecognizer(tapGesture) + tapGesture.rx.event + .withUnretained(self) + .subscribe(onNext: { owner, _ in + owner.dismissWithAnimation(withData: false) + }) + .disposed(by: disposeBag) + } + + func setupKeyboard() { + setupKeyboard(inset: AddCollectionView.Constant.buttonBottomMargin) { [weak self] height in + self?.mainView.addButtonBottomConstraint?.update(inset: height) + } + } +} + +extension AddCollectionViewController { + func bind(reactor: Reactor) { + mainView.inputTextField.rx.text + .distinctUntilChanged() + .map { Reactor.Action.inputTextChanged($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.backButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.completeButton.rx.tap + .map { Reactor.Action.completeButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + reactor.state + .map(\.inputText) + .take(1) + .withUnretained(self) + .bind(onNext: { owner, text in + owner.mainView.updateTextField(text: text) + }) + .disposed(by: disposeBag) + + reactor.state + .map(\.isError) + .distinctUntilChanged() + .withUnretained(self) + .bind { owner, isError in + owner.mainView.setError(isError: isError) + } + .disposed(by: disposeBag) + + reactor.state + .map(\.collection) + .distinctUntilChanged() + .withUnretained(self) + .bind { owner, collection in + owner.mainView.checkIsEmptyCollection(collection: collection) + } + .disposed(by: disposeBag) + + reactor.state + .map(\.isButtonEnabled) + .distinctUntilChanged() + .withUnretained(self) + .bind { owner, isEnabled in + owner.mainView.setButtonEnabled(isEnabled: isEnabled) + } + .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .observe(on: MainScheduler.asyncInstance) + .withUnretained(self) + .subscribe(onNext: { owner, route in + switch route { + case .dismiss: + owner.dismissWithAnimation(withData: false) { + owner.dismiss(animated: false) + } + case .dismissWithData: + owner.dismissWithAnimation(withData: true) { + owner.dismiss(animated: false) + } + case .updateError: + ToastFactory.createToast(message: "컬렉션 수정에 실패했어요. 다시 시도해주세요.") + case .createError: + ToastFactory.createToast(message: "컬렉션 생성에 실패했어요. 다시 시도해주세요.") + default: + break + } + }) + .disposed(by: disposeBag) + } +} + +private extension AddCollectionViewController { + func presentWithAnimation() { + dimmedBackgroundView.alpha = 0 + dimmedBackgroundView.isHidden = false + addCollectionContainer.isHidden = false + addCollectionContainer.transform = CGAffineTransform(translationX: 0, y: 400) + + UIView.animate(withDuration: 0.25) { + self.dimmedBackgroundView.alpha = 1 + self.addCollectionContainer.transform = .identity + } + + mainView.setError(isError: false) + mainView.setButtonEnabled(isEnabled: false) + mainView.inputTextField.becomeFirstResponder() + } + + func dismissWithAnimation(withData: Bool, completion: (() -> Void)? = nil) { + mainView.endEditing(true) + UIView.animate(withDuration: 0.25, animations: { + self.dimmedBackgroundView.alpha = 0 + self.addCollectionContainer.transform = CGAffineTransform(translationX: 0, y: 400) + }, completion: { _ in + self.addCollectionContainer.isHidden = true + self.dimmedBackgroundView.isHidden = true + + if withData { + guard let text = self.mainView.inputTextField.text else { return } + self.dismissed.onNext(text) + self.dismissed.onCompleted() + } + + self.dismiss(animated: false, completion: completion) + }) + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkList/BookmarkListFactoryImpl.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkList/BookmarkListFactoryImpl.swift new file mode 100644 index 00000000..ad3f231a --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkList/BookmarkListFactoryImpl.swift @@ -0,0 +1,63 @@ +import MLSAuthFeatureInterface +import MLSBookmarkFeatureInterface +import MLSCore + +public final class BookmarkListFactoryImpl: BookmarkListFactory { + private let itemFilterFactory: ItemFilterBottomSheetFactory + private let monsterFilterFactory: MonsterFilterBottomSheetFactory + private let sortedFactory: SortedBottomSheetFactory + private let bookmarkModalFactory: BookmarkModalFactory + private let loginFactory: LoginFactory + private let dictionaryDetailFactory: DictionaryDetailFactory + private let collectionEditFactory: CollectionEditFactory + private let authRepository: BookmarkAuthRepository + private let bookmarkRepository: BookmarkRepository + private let parseItemFilterResultUseCase: ParseItemFilterResultUseCase + + public init( + itemFilterFactory: ItemFilterBottomSheetFactory, + monsterFilterFactory: MonsterFilterBottomSheetFactory, + sortedFactory: SortedBottomSheetFactory, + bookmarkModalFactory: BookmarkModalFactory, + loginFactory: LoginFactory, + dictionaryDetailFactory: DictionaryDetailFactory, + collectionEditFactory: CollectionEditFactory, + authRepository: BookmarkAuthRepository, + bookmarkRepository: BookmarkRepository, + parseItemFilterResultUseCase: ParseItemFilterResultUseCase + ) { + self.itemFilterFactory = itemFilterFactory + self.monsterFilterFactory = monsterFilterFactory + self.sortedFactory = sortedFactory + self.bookmarkModalFactory = bookmarkModalFactory + self.loginFactory = loginFactory + self.dictionaryDetailFactory = dictionaryDetailFactory + self.collectionEditFactory = collectionEditFactory + self.authRepository = authRepository + self.bookmarkRepository = bookmarkRepository + self.parseItemFilterResultUseCase = parseItemFilterResultUseCase + } + + public func make(type: DictionaryType, listType: DictionaryMainViewType) -> BaseViewController { + let reactor = BookmarkListReactor( + type: type, + authRepository: authRepository, + bookmarkRepository: bookmarkRepository, + parseItemFilterResultUseCase: parseItemFilterResultUseCase + ) + let viewController = BookmarkListViewController( + reactor: reactor, + itemFilterFactory: itemFilterFactory, + monsterFilterFactory: monsterFilterFactory, + sortedFactory: sortedFactory, + bookmarkModalFactory: bookmarkModalFactory, + loginFactory: loginFactory, + dictionaryDetailFactory: dictionaryDetailFactory, + collectionEditFactory: collectionEditFactory + ) + if listType == .search { + viewController.isBottomTabbarHidden = true + } + return viewController + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkList/BookmarkListReactor.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkList/BookmarkListReactor.swift new file mode 100644 index 00000000..a0201188 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkList/BookmarkListReactor.swift @@ -0,0 +1,237 @@ +import MLSBookmarkFeatureInterface +import ReactorKit +import RxSwift + +final class BookmarkListReactor: Reactor { + enum Route { + case none + case sort(DictionaryType) + case filter(DictionaryType) + case detail(DictionaryType, Int) + case dictionary + case login + case edit + case bookmarkError + } + + enum UIEvent { + case none + case add(BookmarkResponse) + case delete(BookmarkResponse) + case undo + case login + } + + enum ViewState: Equatable { + case loginWithData + case loginWithoutData + case logout + } + + enum Action { + case viewWillAppear + case toggleBookmark(Int) + case sortButtonTapped + case filterButtonTapped + case editButtonTapped + case fetchList + case sortOptionSelected(SortType) + case filterOptionSelected(startLevel: Int, endLevel: Int) + case undoLastDeletedBookmark + case dataTapped(Int) + case emptyButtonTapped + case itemFilterOptionSelected([(String, String)]) + case showLogin + } + + enum Mutation { + case setItems([BookmarkResponse]) + case setLoginState(Bool) + case setSort(SortType) + case setFilter(start: Int?, end: Int?) + case setLastDeletedBookmark(BookmarkResponse?) + case navigateTo(Route) + case setJobId([Int]) + case setCategoryId([Int]) + case setEvent(UIEvent) + } + + struct State { + @Pulse var uiEvent: UIEvent = .none + @Pulse var route: Route + var items: [BookmarkResponse] = [] + var type: DictionaryType + var isLogin: Bool + var jobId: [Int]? + var categoryIds: [Int]? + var sort: SortType? + var startLevel: Int? + var endLevel: Int? + var lastDeletedBookmark: BookmarkResponse? + var viewState: ViewState { + if !isLogin { return .logout } else if items.isEmpty { return .loginWithoutData } else { return .loginWithData } + } + } + + var initialState: State + + private let authRepository: BookmarkAuthRepository + private let bookmarkRepository: BookmarkRepository + private let parseItemFilterResultUseCase: ParseItemFilterResultUseCase + + init( + type: DictionaryType, + authRepository: BookmarkAuthRepository, + bookmarkRepository: BookmarkRepository, + parseItemFilterResultUseCase: ParseItemFilterResultUseCase + ) { + self.initialState = State(route: .none, type: type, isLogin: false) + self.authRepository = authRepository + self.bookmarkRepository = bookmarkRepository + self.parseItemFilterResultUseCase = parseItemFilterResultUseCase + } + + func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear: + return authRepository.isLoggedIn() + .flatMap { [weak self] isLogin -> Observable in + guard let self else { return .empty() } + if !isLogin { + return .just(.setLoginState(false)) + } else { + return Observable.concat([.just(.setLoginState(true)), self.fetchList()]) + } + } + + case let .toggleBookmark(id): + return handleToggle(id: id) + + case .sortButtonTapped: + return .just(.navigateTo(.sort(currentState.type))) + + case .filterButtonTapped: + return .just(.navigateTo(.filter(currentState.type))) + + case .fetchList: + guard currentState.isLogin else { return .empty() } + return fetchList() + + case let .sortOptionSelected(sort): + return Observable.concat([.just(.setSort(sort)), fetchList(sort: sort)]) + + case let .filterOptionSelected(startLevel, endLevel): + return Observable.concat([.just(.setFilter(start: startLevel, end: endLevel)), fetchList()]) + + case .undoLastDeletedBookmark: + return handleUndo() + + case let .dataTapped(index): + let item = currentState.items[index] + guard let type = item.type.toDictionaryType else { return .empty() } + return .just(.navigateTo(.detail(type, item.originalId))) + + case .emptyButtonTapped: + if currentState.viewState == .logout { + return .just(.navigateTo(.login)) + } else { + return .just(.navigateTo(.dictionary)) + } + + case .editButtonTapped: + return .just(.navigateTo(.edit)) + + case let .itemFilterOptionSelected(results): + let criteria = parseItemFilterResultUseCase.execute(results: results) + return Observable.concat([ + .just(.setJobId(criteria.jobIds)), + .just(.setFilter(start: criteria.startLevel, end: criteria.endLevel)), + .just(.setCategoryId(criteria.categoryIds)) + ]) + .concat(Observable.deferred { [weak self] in + guard let self else { return .empty() } + return self.fetchList() + }) + + case .showLogin: + return .just(.setEvent(.login)) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case let .setItems(response): newState.items = response + case let .setLoginState(isLogin): newState.isLogin = isLogin + case let .setSort(sort): newState.sort = sort + case let .setFilter(start, end): + newState.startLevel = start + newState.endLevel = end + case let .setLastDeletedBookmark(item): newState.lastDeletedBookmark = item + case let .navigateTo(route): newState.route = route + case let .setJobId(ids): newState.jobId = ids + case let .setCategoryId(ids): newState.categoryIds = ids + case let .setEvent(event): newState.uiEvent = event + } + return newState + } +} + +private extension BookmarkListReactor { + func fetchList(sort: SortType? = nil) -> Observable { + let resolvedSort = (sort ?? currentState.sort)?.sortParameter + switch currentState.type { + case .total: + return bookmarkRepository.fetchBookmark(sort: resolvedSort).map { .setItems($0) } + case .monster: + return bookmarkRepository.fetchMonsterBookmark( + minLevel: currentState.startLevel ?? 1, + maxLevel: currentState.endLevel ?? 200, + sort: resolvedSort + ).map { .setItems($0) } + case .item: + return bookmarkRepository.fetchItemBookmark( + jobId: nil, + minLevel: currentState.startLevel, + maxLevel: currentState.endLevel, + categoryIds: nil, + sort: resolvedSort + ).map { .setItems($0) } + case .npc: + return bookmarkRepository.fetchNPCBookmark(sort: resolvedSort).map { .setItems($0) } + case .quest: + return bookmarkRepository.fetchQuestBookmark(sort: resolvedSort).map { .setItems($0) } + case .map: + return bookmarkRepository.fetchMapBookmark(sort: resolvedSort).map { .setItems($0) } + default: + return .empty() + } + } + + func handleToggle(id: Int) -> Observable { + guard let index = currentState.items.firstIndex(where: { $0.originalId == id }) else { + return .empty() + } + let targetItem = currentState.items[index] + return bookmarkRepository.deleteBookmark(bookmarkId: targetItem.bookmarkId) + .flatMap { [self] _ -> Observable in + return Observable.concat([ + .from([.setLastDeletedBookmark(targetItem), .setEvent(.delete(targetItem))]), + self.fetchList() + ]) + } + .catch { _ in .just(.navigateTo(.bookmarkError)) } + } + + func handleUndo() -> Observable { + guard let lastDeleted = currentState.lastDeletedBookmark else { return .empty() } + return bookmarkRepository.setBookmark(resourceId: lastDeleted.originalId, type: lastDeleted.type) + .flatMap { [self] _ -> Observable in + return Observable.concat([ + .from([.setLastDeletedBookmark(nil), .setEvent(.add(lastDeleted))]), + self.fetchList() + ]) + } + .catch { _ in .just(.navigateTo(.bookmarkError)) } + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkList/BookmarkListView.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkList/BookmarkListView.swift new file mode 100644 index 00000000..4c5300d4 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkList/BookmarkListView.swift @@ -0,0 +1,24 @@ +import MLSCore +import MLSDesignSystem +import UIKit + +final class BookmarkListView: BaseListView { + let bookmarkEmptyView: DataEmptyView + + init(isFilterHidden: Bool, bookmarkEmptyView: DataEmptyView) { + let editButton = TextButton() + let sortButton = BaseListView.makeSortButton(title: "가나다 순", tintColor: .textColor) + let filterButton = BaseListView.makeFilterButton(title: "필터", tintColor: .textColor) + self.bookmarkEmptyView = bookmarkEmptyView + super.init( + editButton: editButton, + sortButton: sortButton, + filterButton: filterButton, + emptyView: bookmarkEmptyView, + isFilterHidden: isFilterHidden + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkList/BookmarkListViewController.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkList/BookmarkListViewController.swift new file mode 100644 index 00000000..31bbdfe3 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkList/BookmarkListViewController.swift @@ -0,0 +1,363 @@ +import MLSAuthFeatureInterface +import MLSBookmarkFeatureInterface +import MLSCore +import MLSDesignSystem +import ReactorKit +import RxCocoa +import RxRelay +import RxSwift +import UIKit + +final class BookmarkListViewController: BaseViewController, View { + typealias Reactor = BookmarkListReactor + + var disposeBag = DisposeBag() + + private let bookmarkChangeRelay = PublishRelay<(id: Int, newBookmarkId: Int?)>() + private let undoRelay = PublishRelay() + + private let itemFilterFactory: ItemFilterBottomSheetFactory + private let monsterFilterFactory: MonsterFilterBottomSheetFactory + private let bookmarkModalFactory: BookmarkModalFactory + private let sortedFactory: SortedBottomSheetFactory + private let loginFactory: LoginFactory + private let dictionaryDetailFactory: DictionaryDetailFactory + private let collectionEditFactory: CollectionEditFactory + + private var selectedSortIndex = 0 + + private var mainView: BookmarkListView + private var emptyView = DataEmptyView(type: .bookmark) + + init( + reactor: BookmarkListReactor, + itemFilterFactory: ItemFilterBottomSheetFactory, + monsterFilterFactory: MonsterFilterBottomSheetFactory, + sortedFactory: SortedBottomSheetFactory, + bookmarkModalFactory: BookmarkModalFactory, + loginFactory: LoginFactory, + dictionaryDetailFactory: DictionaryDetailFactory, + collectionEditFactory: CollectionEditFactory + ) { + self.itemFilterFactory = itemFilterFactory + self.monsterFilterFactory = monsterFilterFactory + self.sortedFactory = sortedFactory + self.bookmarkModalFactory = bookmarkModalFactory + self.loginFactory = loginFactory + self.dictionaryDetailFactory = dictionaryDetailFactory + self.collectionEditFactory = collectionEditFactory + self.mainView = BookmarkListView(isFilterHidden: reactor.currentState.type.isBookmarkSortHidden, bookmarkEmptyView: emptyView) + super.init() + self.reactor = reactor + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstraints() + configureUI() + } +} + +private extension BookmarkListViewController { + 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.isBookmarkSortHidden else { return UICollectionViewLayout() } + let layout = CompositionalLayoutBuilder() + .section { _ in LayoutFactory.getDictionaryListLayout(isFilterHidden: isHidden) } + .build() + layout.register(Neutral300DividerView.self, forDecorationViewOfKind: Neutral300DividerView.identifier) + return layout + } +} + +extension BookmarkListViewController { + func bind(reactor: Reactor) { + // User Actions + rx.viewWillAppear + .map { _ in Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + 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) + + mainView.editButton?.rx.tap + .map { Reactor.Action.editButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + emptyView.button.rx.tap + .map { .emptyButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + // View State + reactor.state + .map(\.items) + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind(onNext: { owner, items in + owner.mainView.checkEmptyData(isEmpty: items.isEmpty) + owner.mainView.listCollectionView.reloadData() + }) + .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 .sort(let type): + let viewController = owner.sortedFactory.make( + sortedOptions: type.bookmarkSortedFilter, + selectedIndex: owner.selectedSortIndex + ) { index in + owner.selectedSortIndex = index + let selectedFilter = reactor.currentState.type.bookmarkSortedFilter[index] + reactor.action.onNext(.sortOptionSelected(selectedFilter)) + owner.mainView.selectSort(selectedType: selectedFilter.rawValue) + } + owner.tabBarController?.presentModal(viewController) + case .filter(let type): + switch type { + case .item: + let viewController = owner.itemFilterFactory.make { results in + reactor.action.onNext(.itemFilterOptionSelected(results)) + if results.isEmpty { owner.mainView.resetFilter() } else { owner.mainView.selectFilter() } + } + owner.present(viewController, animated: true) + case .monster: + let viewController = owner.monsterFilterFactory.make( + startLevel: reactor.currentState.startLevel ?? 1, + endLevel: reactor.currentState.endLevel ?? 200 + ) { startLevel, endLevel in + owner.mainView.selectFilter() + reactor.action.onNext(.filterOptionSelected(startLevel: startLevel, endLevel: endLevel)) + } + owner.tabBarController?.presentModal(viewController) + default: + break + } + case .detail(let type, let id): + let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: owner.bookmarkChangeRelay, loginRelay: nil) + owner.navigationController?.pushViewController(viewController, animated: true) + case .login: + let viewController = owner.loginFactory.make(exitRoute: .pop) + owner.navigationController?.pushViewController(viewController, animated: true) + case .dictionary: + if let tabBarController = owner.tabBarController as? BottomTabBarController { + tabBarController.selectTab(index: 0) + DictionaryTabRegistry.changeTab(index: reactor.currentState.type.tabIndex) + } + case .edit: + let viewController = owner.collectionEditFactory.make(bookmarks: reactor.currentState.items) + owner.navigationController?.pushViewController(viewController, animated: true) + case .bookmarkError: + ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") + default: + break + } + } + .disposed(by: disposeBag) + + reactor.state + .map(\.type) + .distinctUntilChanged() + .withUnretained(self) + .bind(onNext: { owner, type in + owner.mainView.updateBookmarkFilter(type: type.title) + owner.mainView.updateFilter(sortType: type.bookmarkSortedFilter.first?.rawValue) + }) + .disposed(by: disposeBag) + + 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: BookmarkResponse) { + let backgroundColor = item.type.backgroundColor + let buttonAction: (() -> Void)? = { [weak self] in + self?.reactor?.state.map(\.items) + .compactMap { items in + items.first(where: { $0.originalId == item.originalId })?.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 ?? DisposeBag()) + } + if let urlString = item.imageUrl, let url = URL(string: urlString) { + ImageLoader.shared.loadImage(url: url) { image in + DispatchQueue.main.async { + SnackBarFactory.createSnackBar( + type: .normal, + image: image ?? UIImage(), + imageBackgroundColor: backgroundColor, + text: "아이템을 북마크에 추가했어요.", + buttonText: "컬렉션 추가", + buttonAction: buttonAction + ) + } + } + } else { + SnackBarFactory.createSnackBar( + type: .normal, + image: UIImage(), + imageBackgroundColor: backgroundColor, + text: "아이템을 북마크에 추가했어요.", + buttonText: "컬렉션 추가", + buttonAction: buttonAction + ) + } + } + + private func presentDeleteSnackBar(item: BookmarkResponse) { + let backgroundColor = item.type.backgroundColor + let buttonAction: (() -> Void)? = { [weak self] in + self?.undoRelay.accept(()) + self?.reactor?.action.onNext(.undoLastDeletedBookmark) + } + if let urlString = item.imageUrl, let url = URL(string: urlString) { + ImageLoader.shared.loadImage(url: url) { image in + DispatchQueue.main.async { + SnackBarFactory.createSnackBar( + type: .delete, + image: image ?? UIImage(), + imageBackgroundColor: backgroundColor, + text: "아이템을 북마크에서 삭제했어요.", + buttonText: "되돌리기", + buttonAction: buttonAction + ) + } + } + } else { + SnackBarFactory.createSnackBar( + type: .delete, + image: UIImage(), + imageBackgroundColor: backgroundColor, + text: "아이템을 북마크에서 삭제했어요.", + buttonText: "되돌리기", + buttonAction: buttonAction + ) + } + } + + 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 + ) + } +} + +extension BookmarkListViewController: UICollectionViewDelegate, UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + reactor?.currentState.items.count ?? 0 + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let state = reactor?.currentState else { return UICollectionViewCell() } + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: DictionaryListCell.identifier, for: indexPath) as? DictionaryListCell else { + return UICollectionViewCell() + } + let item = state.items[indexPath.row] + var 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: true + ), + 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(item.originalId)) + } + ) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + reactor?.action.onNext(.dataTapped(indexPath.item)) + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkMain/BookmarkMainFactoryImpl.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkMain/BookmarkMainFactoryImpl.swift new file mode 100644 index 00000000..fe9cd5d0 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkMain/BookmarkMainFactoryImpl.swift @@ -0,0 +1,52 @@ +import MLSAuthFeatureInterface +import MLSBookmarkFeatureInterface +import MLSCore +import UIKit + +public final class BookmarkMainFactoryImpl: BookmarkMainFactory { + private let authRepository: BookmarkAuthRepository + private let userDefaultsRepository: BookmarkUserDefaultsRepository + private let onBoardingFactory: BookmarkOnBoardingFactory + private let bookmarkListFactory: BookmarkListFactory + private let collectionListFactory: CollectionListFactory + private let searchFactory: DictionarySearchFactory + private let notificationFactory: DictionaryNotificationFactory + private let loginFactory: LoginFactory + + public init( + authRepository: BookmarkAuthRepository, + userDefaultsRepository: BookmarkUserDefaultsRepository, + onBoardingFactory: BookmarkOnBoardingFactory, + bookmarkListFactory: BookmarkListFactory, + collectionListFactory: CollectionListFactory, + searchFactory: DictionarySearchFactory, + notificationFactory: DictionaryNotificationFactory, + loginFactory: LoginFactory + ) { + self.authRepository = authRepository + self.userDefaultsRepository = userDefaultsRepository + self.onBoardingFactory = onBoardingFactory + self.bookmarkListFactory = bookmarkListFactory + self.collectionListFactory = collectionListFactory + self.searchFactory = searchFactory + self.notificationFactory = notificationFactory + self.loginFactory = loginFactory + } + + public func make(bottomInset: CGFloat = 64) -> BaseViewController { + let reactor = BookmarkMainReactor( + authRepository: authRepository, + userDefaultsRepository: userDefaultsRepository + ) + return BookmarkMainViewController( + bottomInset: bottomInset, + onBoardingFactory: onBoardingFactory, + bookmarkListFactory: bookmarkListFactory, + collectionListFactory: collectionListFactory, + searchFactory: searchFactory, + notificationFactory: notificationFactory, + loginFactory: loginFactory, + reactor: reactor + ) + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkMain/BookmarkMainReactor.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkMain/BookmarkMainReactor.swift new file mode 100644 index 00000000..404ab2c3 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkMain/BookmarkMainReactor.swift @@ -0,0 +1,75 @@ +import MLSBookmarkFeatureInterface +import ReactorKit +import RxSwift + +final class BookmarkMainReactor: Reactor { + enum Route { + case none + case search + case onBoarding + case notification + case edit + case login + } + + enum Action { + case viewWillAppear + case searchButtonTapped + case notificationButtonTapped + case loginButtonTapped + } + + enum Mutation { + case navigateTo(Route) + case setLogin(Bool) + } + + struct State { + @Pulse var route: Route + let type = DictionaryMainViewType.bookmark + var sections: [String] { + return type.pageTabList.map { $0.title } + } + var isLogin = false + } + + var initialState: State + + private let authRepository: BookmarkAuthRepository + private let userDefaultsRepository: BookmarkUserDefaultsRepository + + init(authRepository: BookmarkAuthRepository, userDefaultsRepository: BookmarkUserDefaultsRepository) { + self.initialState = State(route: .none) + self.authRepository = authRepository + self.userDefaultsRepository = userDefaultsRepository + } + + func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear: + let onboardingMutation = userDefaultsRepository.hasVisitedOnboarding() + .flatMap { hasVisited -> Observable in + if hasVisited { return .empty() } else { return .just(.navigateTo(.onBoarding)) } + } + let loginMutation = authRepository.isLoggedIn().map { Mutation.setLogin($0) } + return .concat([onboardingMutation, loginMutation]) + case .searchButtonTapped: + return .just(.navigateTo(.search)) + case .notificationButtonTapped: + return .just(.navigateTo(.notification)) + case .loginButtonTapped: + return .just(.navigateTo(.login)) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case let .navigateTo(route): + newState.route = route + case let .setLogin(isLogin): + newState.isLogin = isLogin + } + return newState + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkMain/BookmarkMainView.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkMain/BookmarkMainView.swift new file mode 100644 index 00000000..5dc3caa5 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkMain/BookmarkMainView.swift @@ -0,0 +1,85 @@ +import MLSBookmarkFeatureInterface +import MLSCore +import MLSDesignSystem +import SnapKit +import UIKit + +final class BookmarkMainView: UIView { + enum Constant { + static let topMargin: CGFloat = 20 + static let pageTabHeight: CGFloat = 40 + static let bottomTabHeight: CGFloat = 64 + } + + let headerView = Header(style: .main, title: "북마크") + + let tabCollectionView: UICollectionView = { + let layout = UICollectionViewLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.isScrollEnabled = false + return collectionView + }() + + let pageViewController = UIPageViewController( + transitionStyle: .scroll, + navigationOrientation: .horizontal + ) + + let emptyView = ToLoginView(type: .bookmark) + + init(type: DictionaryMainViewType, bottomInset: CGFloat = Constant.bottomTabHeight) { + super.init(frame: .zero) + setupBaseLayout(type: type, bottomInset: bottomInset) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension BookmarkMainView { + func setupBaseLayout(type: DictionaryMainViewType, bottomInset: CGFloat) { + addSubview(headerView) + headerView.snp.makeConstraints { make in + make.top.equalTo(safeAreaLayoutGuide) + make.horizontalEdges.equalToSuperview() + } + + addSubview(tabCollectionView) + addSubview(pageViewController.view) + addSubview(emptyView) + + 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(bottomInset) + } + + emptyView.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom).offset(Constant.topMargin) + make.horizontalEdges.equalToSuperview() + make.bottom.equalToSuperview().inset(bottomInset) + } + + tabCollectionView.isHidden = true + pageViewController.view.isHidden = true + emptyView.isHidden = false + } +} + +extension BookmarkMainView { + func updateLoginState(isLogin: Bool) { + tabCollectionView.isHidden = !isLogin + pageViewController.view.isHidden = !isLogin + emptyView.isHidden = isLogin + tabCollectionView.isUserInteractionEnabled = isLogin + pageViewController.view.isUserInteractionEnabled = isLogin + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkMain/BookmarkMainViewController.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkMain/BookmarkMainViewController.swift new file mode 100644 index 00000000..71f780e1 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkMain/BookmarkMainViewController.swift @@ -0,0 +1,240 @@ +import MLSAuthFeatureInterface +import MLSBookmarkFeatureInterface +import MLSCore +import MLSDesignSystem +import ReactorKit +import RxCocoa +import RxSwift +import SnapKit +import UIKit + +final class BookmarkMainViewController: BaseViewController, View { + typealias Reactor = BookmarkMainReactor + + var disposeBag = DisposeBag() + + private let initialIndex: Int + private lazy var currentPageIndex = BehaviorRelay(value: initialIndex) + + private let onBoardingFactory: BookmarkOnBoardingFactory + private let searchFactory: DictionarySearchFactory + private let notificationFactory: DictionaryNotificationFactory + private let loginFactory: LoginFactory + + private var viewControllers: [UIViewController] + private let mainView: BookmarkMainView + private let underLineController = TabBarUnderlineController() + + init( + initialIndex: Int = 0, + bottomInset: CGFloat = BookmarkMainView.Constant.bottomTabHeight, + onBoardingFactory: BookmarkOnBoardingFactory, + bookmarkListFactory: BookmarkListFactory, + collectionListFactory: CollectionListFactory, + searchFactory: DictionarySearchFactory, + notificationFactory: DictionaryNotificationFactory, + loginFactory: LoginFactory, + reactor: BookmarkMainReactor + ) { + let type = reactor.currentState.type + self.mainView = BookmarkMainView(type: type, bottomInset: bottomInset) + self.viewControllers = type.pageTabList.enumerated().map { index, tabType in + if index == 1 { + return collectionListFactory.make() + } else { + return bookmarkListFactory.make(type: tabType, listType: type) + } + } + self.onBoardingFactory = onBoardingFactory + 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 +extension BookmarkMainViewController { + override func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstraints() + configureUI() + setInitialIndex() + } +} + +// MARK: - SetUp +private extension BookmarkMainViewController { + func addViews() { + addChild(mainView.pageViewController) + mainView.pageViewController.didMove(toParent: self) + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + 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() + } + } +} + +// MARK: - Bind +extension BookmarkMainViewController { + func bind(reactor: Reactor) { + rx.viewWillAppear + .map { _ in Reactor.Action.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) + + mainView.emptyView.button.rx.tap + .map { Reactor.Action.loginButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .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 .onBoarding: + let viewController = owner.onBoardingFactory.make() + viewController.modalPresentationStyle = .fullScreen + owner.present(viewController, 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 { $0.isLogin } + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind { owner, isLogin in + owner.mainView.updateLoginState(isLogin: isLogin) + owner.underLineController.setHidden(hidden: !isLogin) + } + .disposed(by: disposeBag) + } +} + +// MARK: - UIPageViewController DataSource & Delegate +extension BookmarkMainViewController: UIPageViewControllerDataSource, UIPageViewControllerDelegate { + 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 + } + + 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 + } + + 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 BookmarkMainViewController: UICollectionViewDataSource, UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard let reactor = reactor else { return 0 } + return reactor.currentState.sections.count + } + + 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 == currentPageIndex.value + return cell + } + + 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() + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkModal/BookmarkModalFactoryImpl.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkModal/BookmarkModalFactoryImpl.swift new file mode 100644 index 00000000..fdc51e48 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkModal/BookmarkModalFactoryImpl.swift @@ -0,0 +1,27 @@ +import MLSBookmarkFeatureInterface +import MLSCore + +public final class BookmarkModalFactoryImpl: BookmarkModalFactory { + private let addCollectionFactory: AddCollectionFactory + private let collectionRepository: CollectionRepository + + public init(addCollectionFactory: AddCollectionFactory, collectionRepository: CollectionRepository) { + self.addCollectionFactory = addCollectionFactory + self.collectionRepository = collectionRepository + } + + public func make(bookmarkIds: [Int]) -> BaseViewController { + let reactor = BookmarkModalReactor(bookmarkIds: bookmarkIds, collectionRepository: collectionRepository) + let viewController = BookmarkModalViewController(addCollectionFactory: addCollectionFactory) + viewController.reactor = reactor + return viewController + } + + public func make(bookmarkIds: [Int], onComplete: ((Bool) -> Void)?) -> BaseViewController { + let viewController = make(bookmarkIds: bookmarkIds) + if let vc = viewController as? BookmarkModalViewController { + vc.onCompleted = onComplete + } + return viewController + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkModal/BookmarkModalReactor.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkModal/BookmarkModalReactor.swift new file mode 100644 index 00000000..7e3d6ccb --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkModal/BookmarkModalReactor.swift @@ -0,0 +1,88 @@ +import MLSBookmarkFeatureInterface +import ReactorKit +import RxSwift + +final class BookmarkModalReactor: Reactor { + enum Route { + case none + case dismiss + case dismissWithData + case addCollection + case collectionError + } + + enum Action { + case backButtonTapped + case addButtonTapped + case completeAdding + case addCollectionTapped + case selectItem(Int) + case viewWillAppear + } + + enum Mutation { + case navigateTo(Route) + case checkCollection([CollectionResponse]) + case setCollection([CollectionResponse]) + } + + struct State { + @Pulse var route: Route + var bookmarkIds: [Int] + var collections = [CollectionResponse]() + var selectedItems = [CollectionResponse]() + } + + var initialState: State + + private let collectionRepository: CollectionRepository + + init(bookmarkIds: [Int], collectionRepository: CollectionRepository) { + self.initialState = State(route: .none, bookmarkIds: bookmarkIds) + self.collectionRepository = collectionRepository + } + + func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear, .completeAdding: + return collectionRepository.fetchCollectionList(sort: nil) + .map { .setCollection($0) } + + case .addButtonTapped: + return collectionRepository.addCollectionAndBookmark( + collectionIds: currentState.selectedItems.map { $0.collectionId }, + bookmarkIds: currentState.bookmarkIds + ) + .andThen(.just(.navigateTo(.dismissWithData))) + .catch { _ in .just(.navigateTo(.collectionError)) } + + case .backButtonTapped: + return .just(.navigateTo(.dismiss)) + + case .addCollectionTapped: + return .just(.navigateTo(.addCollection)) + + case .selectItem(let id): + var newItems = currentState.selectedItems + if let index = newItems.firstIndex(where: { $0.collectionId == id }) { + newItems.remove(at: index) + } else if let collection = currentState.collections.first(where: { $0.collectionId == id }) { + newItems.append(collection) + } + return .just(.checkCollection(newItems)) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case .navigateTo(let route): + newState.route = route + case .checkCollection(let collections): + newState.selectedItems = collections + case .setCollection(let collections): + newState.collections = collections + } + return newState + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkModal/BookmarkModalView.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkModal/BookmarkModalView.swift new file mode 100644 index 00000000..8e05072c --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkModal/BookmarkModalView.swift @@ -0,0 +1,91 @@ +import MLSDesignSystem +import SnapKit +import UIKit + +final class BookmarkModalView: UIView { + private enum Constant { + static let buttonTopMargin: CGFloat = 12 + static let buttonBottomMargin: CGFloat = 14 + static let horizontalMargin: CGFloat = 16 + } + + private let titleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .h_xl_b, text: "컬렉션", alignment: .left) + return label + }() + + let backButton: UIButton = { + let button = UIButton() + button.setImage(DesignSystemAsset.image(named: "largeX"), for: .normal) + return button + }() + + let folderCollectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout()) + return collectionView + }() + + private let divider = DividerView() + + let addButtton = CommonButton(style: .normal, title: "", disabledTitle: "추가하기") + + init() { + super.init(frame: .zero) + addViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} + +private extension BookmarkModalView { + func addViews() { + addSubview(titleLabel) + addSubview(backButton) + addSubview(folderCollectionView) + addSubview(divider) + addSubview(addButtton) + } + + func setupConstraints() { + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().inset(40) + make.leading.equalToSuperview().inset(Constant.horizontalMargin) + } + + backButton.snp.makeConstraints { make in + make.top.equalTo(titleLabel) + make.leading.equalTo(titleLabel.snp.trailing) + make.trailing.equalToSuperview().inset(Constant.horizontalMargin) + } + + folderCollectionView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(12) + make.horizontalEdges.equalToSuperview() + } + + divider.snp.makeConstraints { make in + make.top.equalTo(folderCollectionView.snp.bottom) + make.horizontalEdges.equalToSuperview() + } + + addButtton.snp.makeConstraints { make in + make.top.equalTo(folderCollectionView.snp.bottom).offset(Constant.buttonTopMargin) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalMargin) + make.bottom.equalToSuperview().inset(Constant.buttonBottomMargin) + } + } +} + +extension BookmarkModalView { + func setButtonTitle(count: Int) { + if count == 0 { + addButtton.isEnabled = false + } else { + addButtton.isEnabled = true + addButtton.updateTitle(title: "\(count)개의 컬렉션 추가하기") + } + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkModal/BookmarkModalViewController.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkModal/BookmarkModalViewController.swift new file mode 100644 index 00000000..879688be --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkModal/BookmarkModalViewController.swift @@ -0,0 +1,157 @@ +import MLSBookmarkFeatureInterface +import MLSCore +import MLSDesignSystem +import ReactorKit +import RxCocoa +import RxSwift +import SnapKit +import UIKit + +final class BookmarkModalViewController: BaseViewController, View { + typealias Reactor = BookmarkModalReactor + + var disposeBag = DisposeBag() + var onCompleted: ((Bool) -> Void)? + + private let addCollectionFactory: AddCollectionFactory + private let mainView = BookmarkModalView() + + init(addCollectionFactory: AddCollectionFactory) { + self.addCollectionFactory = addCollectionFactory + super.init() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstraints() + configureUI() + } +} + +private extension BookmarkModalViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } + + func configureUI() { + mainView.folderCollectionView.collectionViewLayout = createListLayout() + mainView.folderCollectionView.delegate = self + mainView.folderCollectionView.dataSource = self + mainView.folderCollectionView.register(AddFolderCell.self, forCellWithReuseIdentifier: AddFolderCell.identifier) + mainView.folderCollectionView.register(FolderCell.self, forCellWithReuseIdentifier: FolderCell.identifier) + } + + func createListLayout() -> UICollectionViewLayout { + CompositionalLayoutBuilder() + .section { _ in LayoutFactory.getCollectionModalLayout() } + .build() + } +} + +extension BookmarkModalViewController { + func bind(reactor: Reactor) { + rx.viewWillAppear + .map { .viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.backButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.addButtton.rx.tap + .map { Reactor.Action.addButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + reactor.state + .map(\.collections) + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, _ in + owner.mainView.folderCollectionView.reloadData() + }) + .disposed(by: disposeBag) + + reactor.state + .map(\.selectedItems) + .observe(on: MainScheduler.instance) + .withUnretained(self) + .bind(onNext: { owner, items in + owner.mainView.setButtonTitle(count: items.count) + }) + .disposed(by: disposeBag) + + rx.viewDidAppear + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { owner, route in + switch route { + case .dismissWithData: + owner.onCompleted?(true) + owner.dismiss(animated: true) + case .dismiss: + owner.onCompleted?(false) + owner.dismiss(animated: true) + case .addCollection: + let viewController = owner.addCollectionFactory.make(collection: nil) + guard let vc = viewController as? AddCollectionViewController else { return } + vc.dismissed + .withUnretained(owner) + .subscribe { o, _ in + o.reactor?.action.onNext(.completeAdding) + } + .disposed(by: owner.disposeBag) + owner.present(vc, animated: true) + case .collectionError: + ToastFactory.createToast(message: "컬렉션 저장에 실패했어요. 다시 시도해주세요.") + default: + break + } + }) + .disposed(by: disposeBag) + } +} + +extension BookmarkModalViewController: UICollectionViewDelegate, UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + (reactor?.currentState.collections.count ?? 0) + 1 + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + if indexPath.row == 0 { + return collectionView.dequeueReusableCell(withReuseIdentifier: AddFolderCell.identifier, for: indexPath) as? AddFolderCell ?? UICollectionViewCell() + } + guard let reactor, let cell = collectionView.dequeueReusableCell(withReuseIdentifier: FolderCell.identifier, for: indexPath) as? FolderCell else { + return UICollectionViewCell() + } + let collection = reactor.currentState.collections[indexPath.row - 1] + let isSelected = reactor.currentState.selectedItems.contains(where: { $0.collectionId == collection.collectionId }) + cell.isChecked = isSelected + cell.inject(title: collection.name) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + if indexPath.row == 0 { + reactor?.action.onNext(.addCollectionTapped) + } else { + guard let collection = reactor?.currentState.collections[indexPath.row - 1] else { return } + reactor?.action.onNext(.selectItem(collection.collectionId)) + } + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkOnBoarding/BookmarkOnBoardingFactoryImpl.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkOnBoarding/BookmarkOnBoardingFactoryImpl.swift new file mode 100644 index 00000000..17c87836 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkOnBoarding/BookmarkOnBoardingFactoryImpl.swift @@ -0,0 +1,13 @@ +import MLSBookmarkFeatureInterface +import MLSCore + +public final class BookmarkOnBoardingFactoryImpl: BookmarkOnBoardingFactory { + public init() {} + + public func make() -> BaseViewController { + let reactor = BookmarkOnBoardingReactor() + let viewController = BookmarkOnBoardingViewController() + viewController.reactor = reactor + return viewController + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkOnBoarding/BookmarkOnBoardingReactor.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkOnBoarding/BookmarkOnBoardingReactor.swift new file mode 100644 index 00000000..175172e0 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkOnBoarding/BookmarkOnBoardingReactor.swift @@ -0,0 +1,55 @@ +import ReactorKit +import RxSwift + +final class BookmarkOnBoardingReactor: Reactor { + enum Route { + case none + case dismiss + } + + enum Action { + case nextButtonTapped + case backButtonTapped + } + + enum Mutation { + case setStep(BookmarkOnBoardingView.OnBoardingIndexType) + case dismiss + } + + struct State { + @Pulse var route: Route = .none + var step: BookmarkOnBoardingView.OnBoardingIndexType = .first + } + + var initialState: State + + init() { + self.initialState = State() + } + + func mutate(action: Action) -> Observable { + switch action { + case .nextButtonTapped: + let next = currentState.step.next() + if next == .end { + return .just(.dismiss) + } else { + return .just(.setStep(next)) + } + case .backButtonTapped: + return .just(.setStep(currentState.step.previous())) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case .setStep(let step): + newState.step = step + case .dismiss: + newState.route = .dismiss + } + return newState + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkOnBoarding/BookmarkOnBoardingView.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkOnBoarding/BookmarkOnBoardingView.swift new file mode 100644 index 00000000..a2bb11fe --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkOnBoarding/BookmarkOnBoardingView.swift @@ -0,0 +1,149 @@ +import MLSDesignSystem +import SnapKit +import UIKit + +final class BookmarkOnBoardingView: UIView { + enum OnBoardingIndexType: Int { + case first = 0 + case second + case end + + struct Content { + let imageName: String + let title: String + let description: String + let isBackButtonHidden: Bool + let buttonTitle: String + } + + var content: Content? { + switch self { + case .first: + return .init( + imageName: "onBoardingBookmark", + title: "내가 찜한 정보, 한곳에!", + description: "아이템, 몬스터, 맵, NPC, 퀘스트를\n북마크하면 자동으로 여기에 모여요.", + isBackButtonHidden: true, + buttonTitle: "다음" + ) + case .second: + return .init( + imageName: "addToCollection", + title: "나만의 컬렉션 만들기", + description: "북마크한 정보들을 폴더로 정리해보세요.", + isBackButtonHidden: false, + buttonTitle: "북마크 열기" + ) + default: + return nil + } + } + + func next() -> Self { + switch self { + case .first: return .second + case .second: return .end + case .end: return .end + } + } + + func previous() -> Self { + switch self { + case .first: return .first + case .second: return .first + case .end: return .second + } + } + } + + private enum Constant { + static let imageSize: CGFloat = 220 + static let imageYOffset: CGFloat = -116 + static let imageSpacing: CGFloat = 4 + static let labelSpacing: CGFloat = 16 + static let indicatorSpacing: CGFloat = 29 + static let bottomInset: CGFloat = 16 + static let horizontalMargin: CGFloat = 16 + static let backButtonTrailing: CGFloat = -28 + static let backButtonBottom: CGFloat = 10 + } + + private let imageView = UIImageView() + private let titleLabel = UILabel() + private let descLabel = UILabel() + + let backButton: UIButton = { + let button = UIButton() + button.setImage(DesignSystemAsset.image(named: "arrowBack"), for: .normal) + return button + }() + + private let stepIndicator = StepIndicator(circleCount: 2) + let nextButton = CommonButton(style: .normal, title: "다음", disabledTitle: nil) + + init() { + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI(type: .first) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} + +private extension BookmarkOnBoardingView { + func addViews() { + addSubview(imageView) + addSubview(titleLabel) + addSubview(descLabel) + addSubview(backButton) + addSubview(stepIndicator) + addSubview(nextButton) + } + + func setupConstraints() { + imageView.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalToSuperview().offset(Constant.imageYOffset) + make.size.equalTo(Constant.imageSize) + } + + titleLabel.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom).offset(Constant.imageSpacing) + make.centerX.equalToSuperview() + } + + descLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Constant.labelSpacing) + make.centerX.equalToSuperview() + } + + backButton.snp.makeConstraints { make in + make.trailing.equalTo(imageView.snp.leading).inset(Constant.backButtonTrailing) + make.bottom.equalTo(imageView.snp.bottom).inset(Constant.backButtonBottom) + } + + stepIndicator.snp.makeConstraints { make in + make.top.equalTo(descLabel.snp.bottom).offset(Constant.indicatorSpacing) + make.centerX.equalToSuperview() + } + + nextButton.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalMargin) + make.bottom.equalToSuperview().inset(Constant.bottomInset) + } + } +} + +extension BookmarkOnBoardingView { + func configureUI(type: OnBoardingIndexType) { + guard let content = type.content else { return } + imageView.image = DesignSystemAsset.image(named: content.imageName) + titleLabel.attributedText = .makeStyledString(font: .h_xxxl_b, text: content.title) + descLabel.attributedText = .makeStyledString(font: .b_m_r, text: content.description, color: .neutral700) + nextButton.updateTitle(title: content.buttonTitle) + backButton.isHidden = content.isBackButtonHidden + stepIndicator.selectIndicator(index: type.rawValue) + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkOnBoarding/BookmarkOnBoardingViewController.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkOnBoarding/BookmarkOnBoardingViewController.swift new file mode 100644 index 00000000..0009f6d3 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/BookmarkOnBoarding/BookmarkOnBoardingViewController.swift @@ -0,0 +1,64 @@ +import MLSCore +import ReactorKit +import RxCocoa +import RxSwift +import SnapKit +import UIKit + +final class BookmarkOnBoardingViewController: BaseViewController, View { + typealias Reactor = BookmarkOnBoardingReactor + + var disposeBag = DisposeBag() + private let mainView = BookmarkOnBoardingView() + + override init() { + super.init() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + view.addSubview(mainView) + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } + + func bind(reactor: Reactor) { + mainView.backButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.nextButton.rx.tap + .map { Reactor.Action.nextButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .subscribe(onNext: { owner, route in + switch route { + case .dismiss: + owner.dismiss(animated: true) + default: + break + } + }) + .disposed(by: disposeBag) + + reactor.state + .map(\.step) + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind(onNext: { owner, step in + owner.mainView.configureUI(type: step) + }) + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailEmptyView.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailEmptyView.swift new file mode 100644 index 00000000..f6388220 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailEmptyView.swift @@ -0,0 +1,66 @@ +import MLSDesignSystem +import SnapKit +import UIKit + +final class CollectionDetailEmptyView: UIView { + private enum Constant { + static let imageSize: CGFloat = 220 + static let textSpacing: CGFloat = 10 + static let buttonSpacing: CGFloat = 24 + static let buttonWidth: CGFloat = 186 + } + + let imageView: UIImageView = { + let view = UIImageView() + view.image = DesignSystemAsset.image(named: "connectionError") + return view + }() + + private let mainLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .h_xl_b, text: "아직 아무것도 없어요!", color: .textColor) + return label + }() + + private let subLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .b_s_r, text: "북마크해서 추가해보세요.", color: .neutral600) + return label + }() + + let bookmarkButton = CommonButton(style: .normal, title: "북마크하러 가기", disabledTitle: nil) + + init() { + super.init(frame: .zero) + addSubview(imageView) + addSubview(mainLabel) + addSubview(subLabel) + addSubview(bookmarkButton) + + imageView.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.size.equalTo(Constant.imageSize) + } + + mainLabel.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom) + make.center.equalToSuperview() + } + + subLabel.snp.makeConstraints { make in + make.top.equalTo(mainLabel.snp.bottom).offset(Constant.textSpacing) + make.centerX.equalToSuperview() + } + + bookmarkButton.snp.makeConstraints { make in + make.top.equalTo(subLabel.snp.bottom).offset(Constant.buttonSpacing) + make.width.equalTo(Constant.buttonWidth) + make.centerX.equalToSuperview() + } + + backgroundColor = .clearMLS + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailFactoryImpl.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailFactoryImpl.swift new file mode 100644 index 00000000..63a2effa --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailFactoryImpl.swift @@ -0,0 +1,59 @@ +import MLSBookmarkFeatureInterface +import MLSCore +import MLSDesignSystem +import RxSwift + +public final class CollectionDetailFactoryImpl: CollectionDetailFactory { + private let bookmarkModalFactory: BookmarkModalFactory + private let collectionSettingFactory: CollectionSettingFactory + private let addCollectionFactory: AddCollectionFactory + private let collectionEditFactory: CollectionEditFactory + private let dictionaryDetailFactory: DictionaryDetailFactory + private let collectionRepository: CollectionRepository + + public init( + bookmarkModalFactory: BookmarkModalFactory, + collectionSettingFactory: CollectionSettingFactory, + addCollectionFactory: AddCollectionFactory, + collectionEditFactory: CollectionEditFactory, + dictionaryDetailFactory: DictionaryDetailFactory, + collectionRepository: CollectionRepository + ) { + self.bookmarkModalFactory = bookmarkModalFactory + self.collectionSettingFactory = collectionSettingFactory + self.addCollectionFactory = addCollectionFactory + self.collectionEditFactory = collectionEditFactory + self.dictionaryDetailFactory = dictionaryDetailFactory + self.collectionRepository = collectionRepository + } + + public func make(collection: CollectionResponse, onMoveToMain: (() -> Void)?) -> BaseViewController { + let reactor = CollectionDetailReactor( + collection: collection, + collectionRepository: collectionRepository + ) + let viewController = CollectionDetailViewController( + reactor: reactor, + bookmarkModalFactory: bookmarkModalFactory, + collectionSettingFactory: collectionSettingFactory, + addCollectionFactory: addCollectionFactory, + collectionEditFactory: collectionEditFactory, + dictionaryDetailFactory: dictionaryDetailFactory + ) + + reactor.pulse(\.$route) + .observe(on: MainScheduler.instance) + .bind(onNext: { [weak viewController] route in + switch route { + case .toMain: + onMoveToMain?() + viewController?.navigationController?.popToRootViewController(animated: true) + default: + break + } + }) + .disposed(by: viewController.disposeBag) + + return viewController + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailReactor.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailReactor.swift new file mode 100644 index 00000000..9534d99e --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailReactor.swift @@ -0,0 +1,86 @@ +import MLSBookmarkFeatureInterface +import ReactorKit +import RxSwift + +final class CollectionDetailReactor: Reactor { + enum Route { + case none + case toMain + case dismiss + case edit + case detail(DictionaryType, Int) + } + + enum Action { + case viewWillAppear + case backButtonTapped + case editButtonTapped + case addButtonTapped + case bookmarkButtonTapped + case selectSetting(CollectionSettingMenu) + case changeName(String) + case dataTapped(Int) + case deleteCollection + } + + enum Mutation { + case navigateTo(Route) + case setItems([BookmarkResponse]) + case setMenu(CollectionSettingMenu) + case setName(String) + } + + struct State { + @Pulse var route: Route + @Pulse var collectionMenu: CollectionSettingMenu? + var collection: CollectionResponse + } + + var initialState: State + private let collectionRepository: CollectionRepository + + init(collection: CollectionResponse, collectionRepository: CollectionRepository) { + self.initialState = State(route: .none, collection: collection) + self.collectionRepository = collectionRepository + } + + func mutate(action: Action) -> Observable { + switch action { + case .backButtonTapped: + return .just(.navigateTo(.dismiss)) + case .editButtonTapped: + return .just(.navigateTo(.edit)) + case .viewWillAppear: + return collectionRepository.fetchCollection(id: currentState.collection.collectionId) + .map { .setItems($0) } + case .addButtonTapped, .bookmarkButtonTapped: + return .just(.navigateTo(.toMain)) + case .selectSetting(let menu): + return .just(.setMenu(menu)) + case .changeName(let name): + return .just(.setName(name)) + case .dataTapped(let index): + let item = currentState.collection.recentBookmarks[index] + guard let type = item.type.toDictionaryType else { return .empty() } + return .just(.navigateTo(.detail(type, item.originalId))) + case .deleteCollection: + return collectionRepository.deleteCollection(collectionId: currentState.collection.collectionId) + .andThen(.just(.navigateTo(.dismiss))) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case .setItems(let items): + newState.collection.recentBookmarks = items + case .navigateTo(let route): + newState.route = route + case .setMenu(let menu): + newState.collectionMenu = menu + case .setName(let name): + newState.collection.name = name + } + return newState + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailView.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailView.swift new file mode 100644 index 00000000..b4b6a29b --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailView.swift @@ -0,0 +1,87 @@ +import MLSDesignSystem +import SnapKit +import UIKit + +final class CollectionDetailView: UIView { + private enum Constant { + static let topMargin: CGFloat = 12 + static let collectionViewMargin: CGFloat = 24 + } + + let navigation: NavigationBar + let spacer: UIView = { + let view = UIView() + view.backgroundColor = .whiteMLS + return view + }() + let listCollectionView: UICollectionView = { + UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout()) + }() + let emptyContainerView = UIView() + let emptyView = CollectionDetailEmptyView() + + init(navTitle: String) { + self.navigation = NavigationBar(type: .collection(navTitle)) + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} + +private extension CollectionDetailView { + func addViews() { + addSubview(navigation) + addSubview(spacer) + addSubview(listCollectionView) + addSubview(emptyContainerView) + emptyContainerView.addSubview(emptyView) + } + + func setupConstraints() { + navigation.snp.makeConstraints { make in + make.top.horizontalEdges.equalTo(safeAreaLayoutGuide) + } + + spacer.snp.makeConstraints { make in + make.top.equalTo(navigation.snp.bottom) + make.horizontalEdges.equalToSuperview() + make.height.equalTo(Constant.topMargin) + } + + listCollectionView.snp.makeConstraints { make in + make.top.equalTo(spacer.snp.bottom).offset(Constant.collectionViewMargin) + make.horizontalEdges.bottom.equalToSuperview() + } + + emptyContainerView.snp.makeConstraints { make in + make.top.equalTo(navigation.snp.bottom).offset(Constant.collectionViewMargin) + make.horizontalEdges.bottom.equalTo(safeAreaLayoutGuide) + } + + emptyView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + func configureUI() { + navigation.backgroundColor = .whiteMLS + backgroundColor = .neutral100 + emptyContainerView.backgroundColor = .neutral100 + listCollectionView.backgroundColor = .neutral100 + } +} + +extension CollectionDetailView { + func isEmptyData(isEmpty: Bool) { + listCollectionView.isHidden = isEmpty + emptyContainerView.isHidden = !isEmpty + } + + func setName(name: String) { + navigation.setTitle(title: name) + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailViewController.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailViewController.swift new file mode 100644 index 00000000..1980ab53 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionDetail/CollectionDetailViewController.swift @@ -0,0 +1,209 @@ +import MLSBookmarkFeatureInterface +import MLSCore +import MLSDesignSystem +import ReactorKit +import RxCocoa +import RxSwift +import UIKit + +final class CollectionDetailViewController: BaseViewController, View { + typealias Reactor = CollectionDetailReactor + + var disposeBag = DisposeBag() + + private let bookmarkModalFactory: BookmarkModalFactory + private let collectionSettingFactory: CollectionSettingFactory + private let addCollectionFactory: AddCollectionFactory + private let collectionEditFactory: CollectionEditFactory + private let dictionaryDetailFactory: DictionaryDetailFactory + + private var mainView: CollectionDetailView + + init( + reactor: CollectionDetailReactor, + bookmarkModalFactory: BookmarkModalFactory, + collectionSettingFactory: CollectionSettingFactory, + addCollectionFactory: AddCollectionFactory, + collectionEditFactory: CollectionEditFactory, + dictionaryDetailFactory: DictionaryDetailFactory + ) { + self.mainView = CollectionDetailView(navTitle: reactor.currentState.collection.name) + self.bookmarkModalFactory = bookmarkModalFactory + self.collectionSettingFactory = collectionSettingFactory + self.addCollectionFactory = addCollectionFactory + self.collectionEditFactory = collectionEditFactory + self.dictionaryDetailFactory = dictionaryDetailFactory + super.init() + self.reactor = reactor + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + view.addSubview(mainView) + mainView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.horizontalEdges.bottom.equalToSuperview() + } + configureUI() + } +} + +private extension CollectionDetailViewController { + 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 { + let layout = CompositionalLayoutBuilder() + .section { _ in LayoutFactory.getDictionaryListLayout() } + .build() + layout.register(Neutral300DividerView.self, forDecorationViewOfKind: Neutral300DividerView.identifier) + return layout + } +} + +extension CollectionDetailViewController { + func bind(reactor: Reactor) { + rx.viewWillAppear + .map { _ in Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.emptyView.bookmarkButton.rx.tap + .map { Reactor.Action.bookmarkButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.navigation.leftButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.navigation.editButton.rx.tap + .map { Reactor.Action.editButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.navigation.addButton.rx.tap + .map { Reactor.Action.addButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + reactor.state + .map(\.collection.recentBookmarks) + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind(onNext: { owner, items in + owner.mainView.listCollectionView.reloadData() + owner.mainView.isEmptyData(isEmpty: items.isEmpty) + }) + .disposed(by: disposeBag) + + reactor.state + .map(\.collection.name) + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind(onNext: { owner, name in + owner.mainView.setName(name: name) + }) + .disposed(by: disposeBag) + + reactor.pulse(\.$collectionMenu) + .withUnretained(self) + .observe(on: MainScheduler.instance) + .bind(onNext: { owner, menu in + switch menu { + case .editBookmark: + let vc = owner.collectionEditFactory.make(bookmarks: reactor.currentState.collection.recentBookmarks) + owner.navigationController?.pushViewController(vc, animated: true) + case .editName: + let vc = owner.addCollectionFactory.make(collection: reactor.currentState.collection) + guard let vc = vc as? AddCollectionViewController else { return } + vc.dismissed + .subscribe { name in + reactor.action.onNext(.changeName(name)) + } + .disposed(by: owner.disposeBag) + owner.present(vc, animated: true) + case .delete: + GuideAlertFactory.show( + mainText: "컬렉션을 삭제하시겠어요?", + ctaText: "삭제하기", + cancelText: "취소", + ctaAction: { reactor.action.onNext(.deleteCollection) }, + cancelAction: {} + ) + default: + break + } + }) + .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .observe(on: MainScheduler.instance) + .subscribe { owner, route in + switch route { + case .dismiss: + owner.navigationController?.popViewController(animated: true) + case .edit: + let vc = owner.collectionSettingFactory.make(setEditMenu: { menu in + owner.reactor?.action.onNext(.selectSetting(menu)) + }) + owner.presentModal(vc) + case .detail(let type, let id): + let vc = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: nil, loginRelay: nil) + owner.navigationController?.pushViewController(vc, animated: true) + default: + break + } + } + .disposed(by: disposeBag) + } +} + +extension CollectionDetailViewController: UICollectionViewDelegate, UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + reactor?.currentState.collection.recentBookmarks.count ?? 0 + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: DictionaryListCell.identifier, for: indexPath) as? DictionaryListCell, + let item = reactor?.currentState.collection.recentBookmarks[indexPath.row] + else { + return UICollectionViewCell() + } + var 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: true + ), + indexPath: indexPath, + collectionView: collectionView, + onBookmarkTapped: {} + ) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + reactor?.action.onNext(.dataTapped(indexPath.row)) + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionEdit/CollectionEditFactoryImpl.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionEdit/CollectionEditFactoryImpl.swift new file mode 100644 index 00000000..910c97b2 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionEdit/CollectionEditFactoryImpl.swift @@ -0,0 +1,18 @@ +import MLSBookmarkFeatureInterface +import MLSCore + +public final class CollectionEditFactoryImpl: CollectionEditFactory { + private let bookmarkModalFactory: BookmarkModalFactory + + public init(bookmarkModalFactory: BookmarkModalFactory) { + self.bookmarkModalFactory = bookmarkModalFactory + } + + public func make(bookmarks: [BookmarkResponse]) -> BaseViewController { + let reactor = CollectionEditReactor(bookmarks: bookmarks) + let viewController = CollectionEditViewController(bookmarkModalFactory: bookmarkModalFactory) + viewController.reactor = reactor + viewController.isBottomTabbarHidden = true + return viewController + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionEdit/CollectionEditReactor.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionEdit/CollectionEditReactor.swift new file mode 100644 index 00000000..f5c517b8 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionEdit/CollectionEditReactor.swift @@ -0,0 +1,63 @@ +import MLSBookmarkFeatureInterface +import ReactorKit +import RxSwift + +final class CollectionEditReactor: Reactor { + enum Route { + case none + case dismiss + case collectionList + } + + enum Action { + case backButtonTapped + case addCollectionButtonTapped + case itemTapped(Int) + } + + enum Mutation { + case navigateTo(Route) + case checkBookmarks([BookmarkResponse]) + } + + struct State { + @Pulse var route: Route + var bookmarks: [BookmarkResponse] + var selectedItems = [BookmarkResponse]() + } + + var initialState: State + + init(bookmarks: [BookmarkResponse]) { + self.initialState = State(route: .none, bookmarks: bookmarks) + } + + func mutate(action: Action) -> Observable { + switch action { + case .backButtonTapped: + return .just(.navigateTo(.dismiss)) + case .addCollectionButtonTapped: + return .just(.navigateTo(.collectionList)) + case .itemTapped(let index): + let item = currentState.bookmarks[index] + var newItems = currentState.selectedItems + if let idx = newItems.firstIndex(where: { $0.originalId == item.originalId }) { + newItems.remove(at: idx) + } else { + newItems.append(item) + } + return .just(.checkBookmarks(newItems)) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case .navigateTo(let route): + newState.route = route + case .checkBookmarks(let bookmarks): + newState.selectedItems = bookmarks + } + return newState + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionEdit/CollectionEditView.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionEdit/CollectionEditView.swift new file mode 100644 index 00000000..13c43a62 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionEdit/CollectionEditView.swift @@ -0,0 +1,92 @@ +import MLSDesignSystem +import SnapKit +import UIKit + +final class CollectionEditView: UIView { + private enum Constant { + static let imageViewSize: CGFloat = 44 + static let iconSize: CGFloat = 24 + static let horizontalMargin: CGFloat = 16 + static let topMargin: CGFloat = 12 + static let bottomMargin: CGFloat = 14 + } + + private lazy var headerView: UIView = { + let view = UIView() + view.addSubview(cancelButton) + cancelButton.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(Constant.iconSize) + } + return view + }() + + let cancelButton: UIButton = { + let button = UIButton() + button.setImage(DesignSystemAsset.image(named: "largeX"), for: .normal) + return button + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .korFont(style: .semiBold, size: 16), text: "리스트 편집") + return label + }() + + let listCollectionView: UICollectionView = { + UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout()) + }() + + private let divider = DividerView() + let addButtton = CommonButton(style: .normal, title: "컬렉션에 추가하기", disabledTitle: nil) + + init() { + super.init(frame: .zero) + addViews() + setupConstraints() + backgroundColor = .whiteMLS + listCollectionView.backgroundColor = .neutral100 + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} + +private extension CollectionEditView { + func addViews() { + addSubview(headerView) + addSubview(titleLabel) + addSubview(listCollectionView) + addSubview(divider) + addSubview(addButtton) + } + + func setupConstraints() { + headerView.snp.makeConstraints { make in + make.top.equalTo(safeAreaLayoutGuide) + make.leading.equalToSuperview() + make.size.equalTo(Constant.imageViewSize) + } + + titleLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalTo(headerView) + } + + listCollectionView.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom) + make.horizontalEdges.equalToSuperview() + } + + divider.snp.makeConstraints { make in + make.top.equalTo(listCollectionView.snp.bottom) + make.horizontalEdges.equalToSuperview() + } + + addButtton.snp.makeConstraints { make in + make.top.equalTo(divider.snp.bottom).offset(Constant.topMargin) + make.horizontalEdges.equalToSuperview().inset(Constant.horizontalMargin) + make.bottom.equalToSuperview().inset(Constant.bottomMargin) + } + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionEdit/CollectionEditViewController.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionEdit/CollectionEditViewController.swift new file mode 100644 index 00000000..eb836cde --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionEdit/CollectionEditViewController.swift @@ -0,0 +1,144 @@ +import MLSBookmarkFeatureInterface +import MLSCore +import MLSDesignSystem +import ReactorKit +import RxCocoa +import RxSwift +import UIKit + +final class CollectionEditViewController: BaseViewController, View { + typealias Reactor = CollectionEditReactor + + var disposeBag = DisposeBag() + private let bookmarkModalFactory: BookmarkModalFactory + private var mainView = CollectionEditView() + + init(bookmarkModalFactory: BookmarkModalFactory) { + self.bookmarkModalFactory = bookmarkModalFactory + super.init() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + view.addSubview(mainView) + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + configureUI() + } +} + +private extension CollectionEditViewController { + 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 { + CompositionalLayoutBuilder() + .section { _ in LayoutFactory.getCollectionListEditLayout() } + .build() + } +} + +extension CollectionEditViewController { + func bind(reactor: Reactor) { + mainView.cancelButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.addButtton.rx.tap + .map { Reactor.Action.addCollectionButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + reactor.state + .map(\.bookmarks) + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .subscribe { owner, _ in + owner.mainView.listCollectionView.reloadData() + } + .disposed(by: disposeBag) + + reactor.state + .map(\.selectedItems) + .distinctUntilChanged() + .withUnretained(self) + .observe(on: MainScheduler.instance) + .subscribe { owner, _ in + owner.mainView.listCollectionView.reloadData() + } + .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .observe(on: MainScheduler.instance) + .subscribe { owner, route in + switch route { + case .dismiss: + owner.navigationController?.popViewController(animated: true) + case .collectionList: + let vc = owner.bookmarkModalFactory.make( + bookmarkIds: reactor.currentState.selectedItems.map { $0.bookmarkId } + ) { isSave in + if isSave { + owner.navigationController?.popToRootViewController(animated: true) + } + } + owner.present(vc, animated: true) + default: + break + } + } + .disposed(by: disposeBag) + } +} + +extension CollectionEditViewController: UICollectionViewDelegate, UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + reactor?.currentState.bookmarks.count ?? 0 + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: DictionaryListCell.identifier, for: indexPath) as? DictionaryListCell, + let item = reactor?.currentState.bookmarks[indexPath.row] + else { + return UICollectionViewCell() + } + let isSelected = reactor?.currentState.selectedItems.contains(where: { $0.originalId == item.originalId }) ?? false + var subText: String? { + [.item, .monster, .quest].contains(item.type) ? item.level.map { "Lv. \($0)" } : nil + } + cell.inject( + type: .checkbox, + input: DictionaryListCell.Input( + type: item.type, + mainText: item.name, + subText: subText, + imageUrl: item.imageUrl ?? "", + isBookmarked: isSelected + ), + indexPath: indexPath, + collectionView: collectionView, + onBookmarkTapped: { [weak self] in + self?.reactor?.action.onNext(.itemTapped(indexPath.row)) + } + ) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + reactor?.action.onNext(.itemTapped(indexPath.row)) + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListCell.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListCell.swift new file mode 100644 index 00000000..639c520c --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListCell.swift @@ -0,0 +1,50 @@ +import MLSCore +import MLSDesignSystem +import SnapKit +import UIKit + +final class CollectionListCell: UICollectionViewCell { + let cellView = CollectionList() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(cellView) + cellView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} + +extension CollectionListCell { + struct Input { + let title: String + let count: Int + let images: [String?] + } + + func inject(input: Input) { + loadImages(from: input.images) { [weak self] images in + self?.cellView.setImages(images: images) + } + cellView.setTitle(text: input.title) + cellView.setSubtitle(text: "\(input.count)개") + } +} + +private func loadImages(from urls: [String?], completion: @escaping ([UIImage?]) -> Void) { + var results = [UIImage?](repeating: nil, count: urls.count) + let group = DispatchGroup() + for (index, urlString) in urls.enumerated() { + group.enter() + ImageLoader.shared.loadImage(stringURL: urlString) { image in + results[index] = image + group.leave() + } + } + group.notify(queue: .main) { + completion(results) + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListEmptyView.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListEmptyView.swift new file mode 100644 index 00000000..1bfc5706 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListEmptyView.swift @@ -0,0 +1,56 @@ +import MLSDesignSystem +import SnapKit +import UIKit + +final class CollectionListEmptyView: UIView { + private enum Constant { + static let topInset: CGFloat = 60 + static let imageSize: CGFloat = 220 + static let textSpacing: CGFloat = 10 + } + + let imageView: UIImageView = { + let view = UIImageView() + view.image = DesignSystemAsset.image(named: "fabHint") + return view + }() + + private let mainLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .h_xl_b, text: "첫 컬렉션을 만들어보세요", color: .textColor) + return label + }() + + private let subLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .cp_s_r, text: "컬렉션을 만들면 북마크한 리스트를\n내 취향대로 정리할 수 있어요.", color: .neutral600) + label.numberOfLines = 2 + return label + }() + + init() { + super.init(frame: .zero) + addSubview(imageView) + addSubview(mainLabel) + addSubview(subLabel) + + imageView.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.topInset) + make.centerX.equalToSuperview() + make.size.equalTo(Constant.imageSize) + } + + mainLabel.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom) + make.centerX.equalToSuperview() + } + + subLabel.snp.makeConstraints { make in + make.top.equalTo(mainLabel.snp.bottom).offset(Constant.textSpacing) + make.centerX.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListFactoryImpl.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListFactoryImpl.swift new file mode 100644 index 00000000..ed1a23f8 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListFactoryImpl.swift @@ -0,0 +1,32 @@ +import MLSBookmarkFeatureInterface +import MLSCore + +public final class CollectionListFactoryImpl: CollectionListFactory { + private let collectionRepository: CollectionRepository + private let addCollectionFactory: AddCollectionFactory + private let collectionDetailFactory: CollectionDetailFactory + private let sortedBottomSheetFactory: SortedBottomSheetFactory + + public init( + collectionRepository: CollectionRepository, + addCollectionFactory: AddCollectionFactory, + collectionDetailFactory: CollectionDetailFactory, + sortedBottomSheetFactory: SortedBottomSheetFactory + ) { + self.collectionRepository = collectionRepository + self.addCollectionFactory = addCollectionFactory + self.collectionDetailFactory = collectionDetailFactory + self.sortedBottomSheetFactory = sortedBottomSheetFactory + } + + public func make() -> BaseViewController { + let reactor = CollectionListReactor(collectionRepository: collectionRepository) + let viewController = CollectionListViewController( + addCollectionFactory: addCollectionFactory, + detailFactory: collectionDetailFactory, + sortedBottomSheetFactory: sortedBottomSheetFactory + ) + viewController.reactor = reactor + return viewController + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListReactor.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListReactor.swift new file mode 100644 index 00000000..a635b666 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListReactor.swift @@ -0,0 +1,73 @@ +import MLSBookmarkFeatureInterface +import ReactorKit +import RxSwift + +final class CollectionListReactor: Reactor { + enum Route { + case none + case detail(CollectionResponse) + case sortFilter + } + + enum Action { + case itemTapped(Int) + case viewWillAppear + case completeAdding + case sortButtonTapped + case sortOptionSelected(SortType) + } + + enum Mutation { + case navigateTo(Route) + case setListData([CollectionResponse]) + case setSort(SortType) + } + + struct State { + @Pulse var route: Route + var collectionList: [CollectionResponse] + var sortFilter: [SortType] = [.korean, .latest] + var selectedSort: SortType? + } + + var initialState: State + private let collectionRepository: CollectionRepository + + init(collectionRepository: CollectionRepository) { + self.collectionRepository = collectionRepository + self.initialState = State(route: .none, collectionList: []) + } + + func mutate(action: Action) -> Observable { + switch action { + case .viewWillAppear: + return collectionRepository.fetchCollectionList(sort: currentState.selectedSort?.sortParameter) + .map { .setListData($0) } + case .itemTapped(let index): + return .just(.navigateTo(.detail(currentState.collectionList[index]))) + case .completeAdding: + return collectionRepository.fetchCollectionList(sort: currentState.selectedSort?.sortParameter) + .map { .setListData($0) } + case .sortButtonTapped: + return .just(.navigateTo(.sortFilter)) + case .sortOptionSelected(let sort): + return Observable.concat([ + .just(.setSort(sort)), + collectionRepository.fetchCollectionList(sort: sort.sortParameter).map { .setListData($0) } + ]) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case .setListData(let data): + newState.collectionList = data + case .navigateTo(let route): + newState.route = route + case .setSort(let sort): + newState.selectedSort = sort + } + return newState + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListView.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListView.swift new file mode 100644 index 00000000..16bbab66 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListView.swift @@ -0,0 +1,115 @@ +import MLSBookmarkFeatureInterface +import MLSDesignSystem +import SnapKit +import UIKit + +final class CollectionListView: UIView { + private enum Constant { + static let stackViewSpacing: CGFloat = 12 + static let topMargin: CGFloat = 12 + static let filterHeight: CGFloat = 32 + static let horizontalMargin: CGFloat = 16 + static let nonFilterTopMargin: CGFloat = 20 + } + + let listCollectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout()) + return collectionView + }() + + private lazy var filterStackView: UIStackView = { + let view = UIStackView(arrangedSubviews: [sortButton]) + view.axis = .horizontal + view.spacing = Constant.stackViewSpacing + view.distribution = .fillProportionally + return view + }() + + lazy var sortButton: UIButton = { + let button = UIButton() + button.setAttributedTitle(.makeStyledString(font: .b_s_r, text: "최신 순", color: .textColor), for: .normal) + button.setImage(DesignSystemAsset.image(named: "lineArrowDown").withRenderingMode(.alwaysTemplate), for: .normal) + button.tintColor = .textColor + button.semanticContentAttribute = .forceRightToLeft + return button + }() + + let emptyView: CollectionListEmptyView = { + let view = CollectionListEmptyView() + view.isHidden = true + return view + }() + + init() { + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} + +private extension CollectionListView { + func addViews() { + addSubview(filterStackView) + addSubview(listCollectionView) + addSubview(emptyView) + } + + func setupConstraints() { + filterStackView.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.topMargin) + make.trailing.equalToSuperview().inset(Constant.horizontalMargin) + } + + sortButton.snp.makeConstraints { make in + 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.edges.equalToSuperview() + } + } + + func configureUI() { + backgroundColor = .neutral100 + listCollectionView.backgroundColor = .neutral100 + } +} + +extension CollectionListView { + func updateView(isEmptyData: Bool) { + emptyView.isHidden = !isEmptyData + filterStackView.isHidden = isEmptyData + listCollectionView.isHidden = isEmptyData + + if isEmptyData { + listCollectionView.snp.remakeConstraints { make in + make.top.equalToSuperview().inset(Constant.nonFilterTopMargin) + make.horizontalEdges.bottom.equalToSuperview() + } + } else { + filterStackView.snp.remakeConstraints { make in + make.top.equalToSuperview().inset(Constant.topMargin) + make.trailing.equalToSuperview().inset(Constant.horizontalMargin) + } + + listCollectionView.snp.remakeConstraints { make in + make.top.equalTo(filterStackView.snp.bottom).offset(Constant.topMargin) + make.horizontalEdges.bottom.equalToSuperview() + } + } + } + + func selectSort(selectedType: SortType) { + sortButton.setAttributedTitle(.makeStyledString(font: .b_s_r, text: selectedType.rawValue, color: .primary700), for: .normal) + sortButton.tintColor = .primary700 + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListViewController.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListViewController.swift new file mode 100644 index 00000000..94896690 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionList/CollectionListViewController.swift @@ -0,0 +1,159 @@ +import MLSBookmarkFeatureInterface +import MLSCore +import MLSDesignSystem +import ReactorKit +import RxSwift +import UIKit + +final class CollectionListViewController: BaseViewController, View { + typealias Reactor = CollectionListReactor + + var disposeBag = DisposeBag() + private var selectedSortIndex = 0 + + private let addCollectionFactory: AddCollectionFactory + private let detailFactory: CollectionDetailFactory + private let sortedBottomSheetFactory: SortedBottomSheetFactory + + private var mainView = CollectionListView() + + init( + addCollectionFactory: AddCollectionFactory, + detailFactory: CollectionDetailFactory, + sortedBottomSheetFactory: SortedBottomSheetFactory + ) { + self.addCollectionFactory = addCollectionFactory + self.detailFactory = detailFactory + self.sortedBottomSheetFactory = sortedBottomSheetFactory + super.init() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + addViews() + setupConstraints() + configureUI() + } +} + +private extension CollectionListViewController { + func addViews() { + view.addSubview(mainView) + } + + func setupConstraints() { + mainView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + func configureUI() { + mainView.listCollectionView.collectionViewLayout = createListLayout() + mainView.listCollectionView.delegate = self + mainView.listCollectionView.dataSource = self + mainView.listCollectionView.register(CollectionListCell.self, forCellWithReuseIdentifier: CollectionListCell.identifier) + + addFloatingButton { [weak self] in + guard let self else { return } + let viewController = self.addCollectionFactory.make(collection: nil) + guard let vc = viewController as? AddCollectionViewController else { return } + vc.dismissed + .withUnretained(self) + .subscribe { owner, _ in + owner.reactor?.action.onNext(.completeAdding) + } + .disposed(by: self.disposeBag) + self.present(vc, animated: true) + } + } + + func createListLayout() -> UICollectionViewLayout { + CompositionalLayoutBuilder() + .section { _ in LayoutFactory.getCollectionListLayout() } + .build() + } +} + +extension CollectionListViewController { + func bind(reactor: Reactor) { + rx.viewWillAppear + .map { Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.sortButton.rx.tap + .map { .sortButtonTapped } + .bind(to: reactor.action) + .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 .detail(let collection): + let viewController = owner.detailFactory.make(collection: collection, onMoveToMain: { + if let tabBarController = owner.tabBarController as? BottomTabBarController { + tabBarController.selectTab(index: 0) + DictionaryTabRegistry.changeTab(index: 0) + } + }) + owner.tabBarController?.navigationController?.pushViewController(viewController, animated: true) + case .sortFilter: + let viewController = owner.sortedBottomSheetFactory.make( + sortedOptions: reactor.currentState.sortFilter, + selectedIndex: owner.selectedSortIndex + ) { index in + owner.selectedSortIndex = index + let selectedFilter = reactor.currentState.sortFilter[index] + reactor.action.onNext(.sortOptionSelected(selectedFilter)) + owner.mainView.selectSort(selectedType: selectedFilter) + } + owner.tabBarController?.presentModal(viewController) + default: + break + } + } + .disposed(by: disposeBag) + + reactor.state + .map(\.collectionList) + .withUnretained(self) + .observe(on: MainScheduler.instance) + .subscribe { owner, collectionList in + owner.mainView.updateView(isEmptyData: collectionList.isEmpty) + owner.mainView.listCollectionView.reloadData() + } + .disposed(by: disposeBag) + } +} + +extension CollectionListViewController: UICollectionViewDelegate, UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + reactor?.currentState.collectionList.count ?? 0 + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CollectionListCell.identifier, for: indexPath) as? CollectionListCell, + let item = reactor?.currentState.collectionList[indexPath.row] + else { + return UICollectionViewCell() + } + cell.inject(input: CollectionListCell.Input( + title: item.name, + count: item.recentBookmarks.count, + images: item.recentBookmarks.map { $0.imageUrl } + )) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + reactor?.action.onNext(.itemTapped(indexPath.row)) + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionSettingSheet/CollectionSettingFactoryImpl.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionSettingSheet/CollectionSettingFactoryImpl.swift new file mode 100644 index 00000000..f35ac896 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionSettingSheet/CollectionSettingFactoryImpl.swift @@ -0,0 +1,14 @@ +import MLSBookmarkFeatureInterface +import MLSCore +import MLSDesignSystem + +public final class CollectionSettingFactoryImpl: CollectionSettingFactory { + public init() {} + + public func make(setEditMenu: ((CollectionSettingMenu) -> Void)?) -> BaseViewController & ModalPresentable { + let viewController = CollectionSettingViewController() + viewController.reactor = CollectionSettingReactor() + viewController.setMenu = setEditMenu + return viewController + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionSettingSheet/CollectionSettingReactor.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionSettingSheet/CollectionSettingReactor.swift new file mode 100644 index 00000000..ebe9a5a9 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionSettingSheet/CollectionSettingReactor.swift @@ -0,0 +1,55 @@ +import MLSBookmarkFeatureInterface +import ReactorKit +import RxSwift + +final class CollectionSettingReactor: Reactor { + enum Route { + case none + case dismiss + case dismissWithMenu(CollectionSettingMenu) + } + + enum Action { + case cancelButtonTapped + case editBookmarkButtonTapped + case editNameButtonTapped + case deleteButtonTapped + } + + enum Mutation { + case navigateTo(Route) + } + + struct State { + @Pulse var route: Route = .none + var menu = CollectionSettingMenu.allCases + } + + var initialState: State + + init() { + self.initialState = State() + } + + func mutate(action: Action) -> Observable { + switch action { + case .cancelButtonTapped: + return .just(.navigateTo(.dismiss)) + case .editBookmarkButtonTapped: + return .just(.navigateTo(.dismissWithMenu(.editBookmark))) + case .editNameButtonTapped: + return .just(.navigateTo(.dismissWithMenu(.editName))) + case .deleteButtonTapped: + return .just(.navigateTo(.dismissWithMenu(.delete))) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case .navigateTo(let route): + newState.route = route + } + return newState + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionSettingSheet/CollectionSettingView.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionSettingSheet/CollectionSettingView.swift new file mode 100644 index 00000000..061934c4 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionSettingSheet/CollectionSettingView.swift @@ -0,0 +1,40 @@ +import MLSDesignSystem +import SnapKit +import UIKit + +final class CollectionSettingView: UIView { + private enum Constant { + static let topInset: CGFloat = 16 + static let tableViewInset: CGFloat = 14 + } + + let header: Header = { + Header(style: .filter, title: "컬렉션") + }() + + let menuTableView: UITableView = { + let view = UITableView() + view.isScrollEnabled = false + view.separatorStyle = .none + return view + }() + + init() { + super.init(frame: .zero) + addSubview(header) + addSubview(menuTableView) + + header.snp.makeConstraints { make in + make.top.equalToSuperview().inset(Constant.topInset) + make.horizontalEdges.equalToSuperview() + } + + menuTableView.snp.makeConstraints { make in + make.top.equalTo(header.snp.bottom).offset(Constant.tableViewInset) + make.horizontalEdges.bottom.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionSettingSheet/CollectionSettingViewController.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionSettingSheet/CollectionSettingViewController.swift new file mode 100644 index 00000000..bdf3b951 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/CollectionSettingSheet/CollectionSettingViewController.swift @@ -0,0 +1,92 @@ +import MLSBookmarkFeatureInterface +import MLSCore +import MLSDesignSystem +import ReactorKit +import RxCocoa +import RxSwift +import SnapKit +import UIKit + +final class CollectionSettingViewController: BaseViewController, ModalPresentable, View { + var modalHeight: CGFloat? = 284 + + typealias Reactor = CollectionSettingReactor + + var disposeBag = DisposeBag() + var setMenu: ((CollectionSettingMenu) -> Void)? + + private var mainView = CollectionSettingView() + + override func viewDidLoad() { + super.viewDidLoad() + view.addSubview(mainView) + mainView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + mainView.menuTableView.delegate = self + mainView.menuTableView.dataSource = self + } + + func bind(reactor: Reactor) { + mainView.header.firstIconButton.rx.tap + .map { Reactor.Action.cancelButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$route) } + .withUnretained(self) + .subscribe { owner, route in + switch route { + case .dismiss: + owner.dismissCurrentModal() + case .dismissWithMenu(let menu): + owner.setMenu?(menu) + owner.dismissCurrentModal() + default: + break + } + } + .disposed(by: disposeBag) + } +} + +extension CollectionSettingViewController: UITableViewDelegate, UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + reactor?.currentState.menu.count ?? 0 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell() + guard let item = reactor?.currentState.menu[indexPath.row] else { return cell } + cell.textLabel?.textAlignment = .center + cell.textLabel?.autoresizingMask = [.flexibleWidth, .flexibleHeight] + cell.textLabel?.attributedText = .makeStyledString(font: .b_m_r, text: item.title, color: item.titleColor) + if indexPath.row < (reactor?.currentState.menu.count ?? 0) - 1 { + let divider = UIView() + divider.backgroundColor = .lightGray.withAlphaComponent(0.3) + cell.contentView.addSubview(divider) + divider.snp.makeConstraints { make in + make.horizontalEdges.bottom.equalToSuperview() + make.height.equalTo(1) + } + } + cell.selectionStyle = .none + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let item = reactor?.currentState.menu[indexPath.row] else { return } + switch item { + case .editBookmark: reactor?.action.onNext(.editBookmarkButtonTapped) + case .editName: reactor?.action.onNext(.editNameButtonTapped) + case .delete: reactor?.action.onNext(.deleteButtonTapped) + case .cancel: reactor?.action.onNext(.cancelButtonTapped) + } + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + 54 + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/Common/AddFolderCell.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/Common/AddFolderCell.swift new file mode 100644 index 00000000..f9b35472 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/Common/AddFolderCell.swift @@ -0,0 +1,67 @@ +import MLSDesignSystem +import SnapKit +import UIKit + +final class AddFolderCell: UICollectionViewCell { + private enum Constant { + static let iconInset: CGFloat = 8 + static let radius: CGFloat = 8 + static let margin: CGFloat = 16 + static let buttonSize: CGFloat = 40 + } + + private lazy var addIconView: UIView = { + let view = UIView() + view.layer.cornerRadius = Constant.radius + view.backgroundColor = .primary100 + + view.addSubview(iconView) + iconView.snp.makeConstraints { make in + make.center.equalTo(view).inset(Constant.iconInset) + } + return view + }() + + private let iconView: UIImageView = { + let view = UIImageView() + view.image = DesignSystemAsset.image(named: "addIcon").withRenderingMode(.alwaysTemplate) + view.tintColor = .whiteMLS + return view + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.attributedText = .makeStyledString(font: .b_m_r, text: "새로운 컬렉션 추가하기", alignment: .left) + return label + }() + + private let divider: UIView = { + let view = UIView() + view.backgroundColor = .neutral100 + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(addIconView) + contentView.addSubview(titleLabel) + contentView.addSubview(divider) + + addIconView.snp.makeConstraints { make in + make.leading.verticalEdges.equalToSuperview().inset(Constant.margin) + make.size.equalTo(Constant.buttonSize) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(addIconView.snp.trailing).offset(Constant.margin) + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(Constant.margin) + } + divider.snp.makeConstraints { make in + make.height.equalTo(1) + make.horizontalEdges.bottom.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/Common/DictionaryListCell.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/Common/DictionaryListCell.swift new file mode 100644 index 00000000..ffac662e --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/Common/DictionaryListCell.swift @@ -0,0 +1,90 @@ +import MLSBookmarkFeatureInterface +import MLSCore +import MLSDesignSystem +import SnapKit +import UIKit + +final class DictionaryListCell: UICollectionViewCell { + private var onBookmarkTapped: (() -> Void)? + + let cellView = CardList() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(cellView) + cellView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func prepareForReuse() { + super.prepareForReuse() + onBookmarkTapped = nil + cellView.onIconTapped = nil + cellView.setMainText(text: "") + cellView.setSubText(text: nil) + cellView.setSelected(isSelected: false) + } +} + +extension DictionaryListCell { + struct Input { + let type: DictionaryItemType + let mainText: String + let subText: String? + let imageUrl: String + let isBookmarked: Bool + } + + 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 else { return } + 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) + } +} + +extension DictionaryItemType { + var backgroundColor: UIColor { + switch self { + case .item: .listItem + case .monster: .listMonster + case .map: .listMap + case .npc: .listNPC + case .quest: .listQuest + } + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/Common/FolderCell.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/Common/FolderCell.swift new file mode 100644 index 00000000..e159eca3 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/Common/FolderCell.swift @@ -0,0 +1,93 @@ +import MLSDesignSystem +import SnapKit +import UIKit + +final class FolderCell: UICollectionViewCell { + private enum Constant { + static let iconInset: CGFloat = 8 + static let radius: CGFloat = 8 + static let margin: CGFloat = 16 + static let iconSize: CGFloat = 24 + static let buttonSize: CGFloat = 40 + } + + private lazy var imageContainerView: UIView = { + let view = UIView() + view.layer.cornerRadius = Constant.radius + view.backgroundColor = .neutral200 + + view.addSubview(iconView) + iconView.snp.makeConstraints { make in + make.center.equalTo(view).inset(Constant.iconInset) + } + return view + }() + + private let iconView: UIImageView = { + let view = UIImageView() + view.image = DesignSystemAsset.image(named: "bookmark").withRenderingMode(.alwaysTemplate) + view.tintColor = .neutral300 + return view + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + return label + }() + + let checkBoxButton: UIButton = { + let button = UIButton() + button.setImage(DesignSystemAsset.image(named: "checkSquareFill").withRenderingMode(.alwaysTemplate), for: .normal) + button.tintColor = .neutral300 + button.isUserInteractionEnabled = false + return button + }() + + private let divider: UIView = { + let view = UIView() + view.backgroundColor = .neutral100 + return view + }() + + var isChecked: Bool = false { + didSet { + checkBoxButton.tintColor = isChecked ? .primary700 : .neutral300 + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(imageContainerView) + contentView.addSubview(titleLabel) + contentView.addSubview(checkBoxButton) + contentView.addSubview(divider) + + imageContainerView.snp.makeConstraints { make in + make.leading.verticalEdges.equalToSuperview().inset(Constant.margin) + make.size.equalTo(Constant.buttonSize) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(imageContainerView.snp.trailing).offset(Constant.margin) + make.centerY.equalToSuperview() + } + checkBoxButton.snp.makeConstraints { make in + make.leading.equalTo(titleLabel.snp.trailing).offset(Constant.margin) + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(Constant.margin) + make.size.equalTo(Constant.iconSize) + } + divider.snp.makeConstraints { make in + make.height.equalTo(1) + make.horizontalEdges.bottom.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func inject(title: String?) { + titleLabel.attributedText = .makeStyledString(font: .b_m_r, text: title, alignment: .left) + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/Common/PageTabbarCell.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/Common/PageTabbarCell.swift new file mode 100644 index 00000000..7a8e1d04 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeature/Presentation/Common/PageTabbarCell.swift @@ -0,0 +1,38 @@ +import MLSDesignSystem +import SnapKit +import UIKit + +final 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) + contentView.addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview() + make.centerY.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override var isSelected: Bool { + didSet { + titleLabel.font = isSelected ? .sub_m_b : .b_m_r + titleLabel.textColor = isSelected ? .textColor : .neutral600 + } + } + + func inject(title: String?) { + titleLabel.text = title + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/BookmarkResponse.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/BookmarkResponse.swift new file mode 100644 index 00000000..e9934cd5 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/BookmarkResponse.swift @@ -0,0 +1,17 @@ +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 + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/CollectionResponse.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/CollectionResponse.swift new file mode 100644 index 00000000..a69ddd5c --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/CollectionResponse.swift @@ -0,0 +1,13 @@ +public struct CollectionResponse: Equatable { + public let collectionId: Int + public var name: String + public let createdAt: [Int] + public var recentBookmarks: [BookmarkResponse] + + public init(collectionId: Int, name: String, createdAt: [Int], recentBookmarks: [BookmarkResponse]) { + self.collectionId = collectionId + self.name = name + self.createdAt = createdAt + self.recentBookmarks = recentBookmarks + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/CollectionSettingMenu.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/CollectionSettingMenu.swift new file mode 100644 index 00000000..c02404cc --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/CollectionSettingMenu.swift @@ -0,0 +1,24 @@ +import UIKit + +public enum CollectionSettingMenu: CaseIterable { + case editBookmark + case editName + case delete + case cancel + + public var title: String { + switch self { + case .editBookmark: return "컬렉션 북마크 수정" + case .editName: return "컬렉션 이름 수정" + case .delete: return "컬렉션 삭제" + case .cancel: return "취소" + } + } + + public var titleColor: UIColor { + switch self { + case .delete: return .red + default: return .black + } + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/DictionaryItemType.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/DictionaryItemType.swift new file mode 100644 index 00000000..f43d1d11 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/DictionaryItemType.swift @@ -0,0 +1,17 @@ +public enum DictionaryItemType: String { + case item + case monster + case map + case 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 + } + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/DictionaryMainViewType.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/DictionaryMainViewType.swift new file mode 100644 index 00000000..9ad16018 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/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/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/DictionaryType.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/DictionaryType.swift new file mode 100644 index 00000000..2789524f --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/DictionaryType.swift @@ -0,0 +1,49 @@ +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 .item: return "아이템" + case .monster: return "몬스터" + case .map: return "맵" + case .npc: return "NPC" + case .quest: return "퀘스트" + } + } + + public var bookmarkSortedFilter: [SortType] { + switch self { + case .total: return [.korean, .latest] + case .item: return [.korean, .levelDESC, .levelASC] + case .monster: return [.korean, .levelDESC, .levelASC] + case .map: return [.korean, .mostAppear] + case .npc: return [.korean] + case .quest: return [.korean, .levelLowest, .levelHighest] + case .collection: return [] + } + } + + public var isBookmarkSortHidden: Bool { + return bookmarkSortedFilter.count == 0 + } + + public var tabIndex: Int { + switch self { + case .total: return 0 + case .collection: return 1 + case .monster: return 2 + case .item: return 3 + case .map: return 4 + case .npc: return 5 + case .quest: return 6 + } + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/ItemFilterCriteria.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/ItemFilterCriteria.swift new file mode 100644 index 00000000..8fe61cd6 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/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/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/SortType.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/SortType.swift new file mode 100644 index 00000000..b5848888 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Entities/SortType.swift @@ -0,0 +1,37 @@ +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 .korean: return "name" + case .levelDESC, .levelASC: return "level" + case .expDESC, .expASC: return "exp" + case .latest: return "createdAt" + case .mostAppear: return "count" + case .levelLowest, .levelHighest: return "minLevel" + case .mostDrop: return "dropRate" + } + } + + public var direction: String { + switch self { + case .korean, .levelASC, .expASC, .latest, .mostAppear, .levelLowest, .mostDrop: + return "asc" + case .levelDESC, .expDESC, .levelHighest: + return "desc" + } + } + + public var sortParameter: String { + return "\(sortKey),\(direction)" + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/AddCollectionFactory.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/AddCollectionFactory.swift new file mode 100644 index 00000000..029875dd --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/AddCollectionFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol AddCollectionFactory { + func make(collection: CollectionResponse?) -> BaseViewController +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/BookmarkListFactory.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/BookmarkListFactory.swift new file mode 100644 index 00000000..4ffb7554 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/BookmarkListFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol BookmarkListFactory { + func make(type: DictionaryType, listType: DictionaryMainViewType) -> BaseViewController +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/BookmarkMainFactory.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/BookmarkMainFactory.swift new file mode 100644 index 00000000..f1e7b23a --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/BookmarkMainFactory.swift @@ -0,0 +1,7 @@ +import MLSCore + +import UIKit + +public protocol BookmarkMainFactory { + func make(bottomInset: CGFloat) -> BaseViewController +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/BookmarkModalFactory.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/BookmarkModalFactory.swift new file mode 100644 index 00000000..b26528a5 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/BookmarkModalFactory.swift @@ -0,0 +1,6 @@ +import MLSCore + +public protocol BookmarkModalFactory { + func make(bookmarkIds: [Int]) -> BaseViewController + func make(bookmarkIds: [Int], onComplete: ((Bool) -> Void)?) -> BaseViewController +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/BookmarkOnBoardingFactory.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/BookmarkOnBoardingFactory.swift new file mode 100644 index 00000000..71aa08ff --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/BookmarkOnBoardingFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol BookmarkOnBoardingFactory { + func make() -> BaseViewController +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/CollectionDetailFactory.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/CollectionDetailFactory.swift new file mode 100644 index 00000000..3d6a3f5c --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/CollectionDetailFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol CollectionDetailFactory { + func make(collection: CollectionResponse, onMoveToMain: (() -> Void)?) -> BaseViewController +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/CollectionEditFactory.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/CollectionEditFactory.swift new file mode 100644 index 00000000..e2986af9 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/CollectionEditFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol CollectionEditFactory { + func make(bookmarks: [BookmarkResponse]) -> BaseViewController +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/CollectionListFactory.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/CollectionListFactory.swift new file mode 100644 index 00000000..6217a045 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/CollectionListFactory.swift @@ -0,0 +1,5 @@ +import MLSCore + +public protocol CollectionListFactory { + func make() -> BaseViewController +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/CollectionSettingFactory.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/CollectionSettingFactory.swift new file mode 100644 index 00000000..549fcafb --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/CollectionSettingFactory.swift @@ -0,0 +1,6 @@ +import MLSCore +import MLSDesignSystem + +public protocol CollectionSettingFactory { + func make(setEditMenu: ((CollectionSettingMenu) -> Void)?) -> BaseViewController & ModalPresentable +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/DictionaryExternalFactories.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/DictionaryExternalFactories.swift new file mode 100644 index 00000000..a1ce47d7 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Factories/DictionaryExternalFactories.swift @@ -0,0 +1,43 @@ +import MLSCore +import MLSDesignSystem +import RxRelay + +// DictionaryFeature 모듈이 SPM으로 전환되기 전까지 임시로 정의하는 프로토콜들입니다. +// MLSDictionaryFeature 모듈이 생성되면 해당 모듈의 타입을 사용하도록 교체해야 합니다. + +public protocol DictionarySearchFactory { + func make() -> BaseViewController +} + +public protocol DictionaryNotificationFactory { + func make() -> BaseViewController +} + +public protocol DictionaryDetailFactory { + func make( + type: DictionaryType, + id: Int, + bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, + loginRelay: PublishRelay? + ) -> BaseViewController +} + +public protocol SortedBottomSheetFactory { + func make( + sortedOptions: [SortType], + selectedIndex: Int, + onSelectedIndex: @escaping (Int) -> Void + ) -> BaseViewController & ModalPresentable +} + +public protocol ItemFilterBottomSheetFactory { + func make(onFilterSelected: @escaping ([(String, String)]) -> Void) -> BaseViewController +} + +public protocol MonsterFilterBottomSheetFactory { + func make( + startLevel: Int, + endLevel: Int, + onFilterSelected: @escaping (Int, Int) -> Void + ) -> BaseViewController & ModalPresentable +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Repositories/BookmarkAuthRepository.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Repositories/BookmarkAuthRepository.swift new file mode 100644 index 00000000..35a32e84 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Repositories/BookmarkAuthRepository.swift @@ -0,0 +1,5 @@ +import RxSwift + +public protocol BookmarkAuthRepository { + func isLoggedIn() -> Observable +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Repositories/BookmarkRepository.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Repositories/BookmarkRepository.swift new file mode 100644 index 00000000..f3f31ff0 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Repositories/BookmarkRepository.swift @@ -0,0 +1,12 @@ +import RxSwift + +public protocol BookmarkRepository { + func setBookmark(resourceId: 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/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Repositories/BookmarkUserDefaultsRepository.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Repositories/BookmarkUserDefaultsRepository.swift new file mode 100644 index 00000000..099d0d7e --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Repositories/BookmarkUserDefaultsRepository.swift @@ -0,0 +1,6 @@ +import RxSwift + +public protocol BookmarkUserDefaultsRepository { + func hasVisitedOnboarding() -> Observable + func markOnboardingVisited() -> Completable +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Repositories/CollectionRepository.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Repositories/CollectionRepository.swift new file mode 100644 index 00000000..d839bb4b --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/Repositories/CollectionRepository.swift @@ -0,0 +1,10 @@ +import RxSwift + +public protocol CollectionRepository { + func fetchCollectionList(sort: String?) -> Observable<[CollectionResponse]> + func createCollectionList(name: String) -> Completable + func fetchCollection(id: Int) -> Observable<[BookmarkResponse]> + func updateCollectionName(collectionId: Int, name: String) -> Completable + func deleteCollection(collectionId: Int) -> Completable + func addCollectionAndBookmark(collectionIds: [Int], bookmarkIds: [Int]) -> Completable +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/UseCases/ParseItemFilterResultUseCase.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/UseCases/ParseItemFilterResultUseCase.swift new file mode 100644 index 00000000..13a68723 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureInterface/UseCases/ParseItemFilterResultUseCase.swift @@ -0,0 +1,3 @@ +public protocol ParseItemFilterResultUseCase { + func execute(results: [(String, String)]) -> ItemFilterCriteria +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockBookmarkAuthRepository.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockBookmarkAuthRepository.swift new file mode 100644 index 00000000..f7bef002 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockBookmarkAuthRepository.swift @@ -0,0 +1,12 @@ +import MLSBookmarkFeatureInterface +import RxSwift + +public final class MockBookmarkAuthRepository: BookmarkAuthRepository { + public var isLoggedInResult: Observable = .just(true) + + public init() {} + + public func isLoggedIn() -> Observable { + isLoggedInResult + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockBookmarkRepository.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockBookmarkRepository.swift new file mode 100644 index 00000000..1cc37895 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockBookmarkRepository.swift @@ -0,0 +1,47 @@ +import MLSBookmarkFeatureInterface +import RxSwift + +public final class MockBookmarkRepository: BookmarkRepository { + public var setBookmarkResult: Observable = .just(0) + public var deleteBookmarkResult: Observable = .just(nil) + public var fetchBookmarkResult: Observable<[BookmarkResponse]> = .just([]) + public var fetchMonsterBookmarkResult: Observable<[BookmarkResponse]> = .just([]) + public var fetchItemBookmarkResult: Observable<[BookmarkResponse]> = .just([]) + public var fetchNPCBookmarkResult: Observable<[BookmarkResponse]> = .just([]) + public var fetchQuestBookmarkResult: Observable<[BookmarkResponse]> = .just([]) + public var fetchMapBookmarkResult: Observable<[BookmarkResponse]> = .just([]) + + public init() {} + + public func setBookmark(resourceId: Int, type: DictionaryItemType) -> Observable { + setBookmarkResult + } + + public func deleteBookmark(bookmarkId: Int) -> Observable { + deleteBookmarkResult + } + + public func fetchBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + fetchBookmarkResult + } + + public func fetchMonsterBookmark(minLevel: Int?, maxLevel: Int?, sort: String?) -> Observable<[BookmarkResponse]> { + fetchMonsterBookmarkResult + } + + public func fetchItemBookmark(jobId: Int?, minLevel: Int?, maxLevel: Int?, categoryIds: [Int]?, sort: String?) -> Observable<[BookmarkResponse]> { + fetchItemBookmarkResult + } + + public func fetchNPCBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + fetchNPCBookmarkResult + } + + public func fetchQuestBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + fetchQuestBookmarkResult + } + + public func fetchMapBookmark(sort: String?) -> Observable<[BookmarkResponse]> { + fetchMapBookmarkResult + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockBookmarkUserDefaultsRepository.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockBookmarkUserDefaultsRepository.swift new file mode 100644 index 00000000..4a4e29f7 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockBookmarkUserDefaultsRepository.swift @@ -0,0 +1,17 @@ +import MLSBookmarkFeatureInterface +import RxSwift + +public final class MockBookmarkUserDefaultsRepository: BookmarkUserDefaultsRepository { + public var hasVisitedResult: Observable = .just(false) + public var markVisitedResult: Completable = .empty() + + public init() {} + + public func hasVisitedOnboarding() -> Observable { + hasVisitedResult + } + + public func markOnboardingVisited() -> Completable { + markVisitedResult + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockCollectionRepository.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockCollectionRepository.swift new file mode 100644 index 00000000..65a76757 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockCollectionRepository.swift @@ -0,0 +1,37 @@ +import MLSBookmarkFeatureInterface +import RxSwift + +public final class MockCollectionRepository: CollectionRepository { + public var fetchCollectionListResult: Observable<[CollectionResponse]> = .just([]) + public var createCollectionListResult: Completable = .empty() + public var fetchCollectionResult: Observable<[BookmarkResponse]> = .just([]) + public var updateCollectionNameResult: Completable = .empty() + public var deleteCollectionResult: Completable = .empty() + public var addCollectionAndBookmarkResult: Completable = .empty() + + public init() {} + + public func fetchCollectionList(sort: String?) -> Observable<[CollectionResponse]> { + fetchCollectionListResult + } + + public func createCollectionList(name: String) -> Completable { + createCollectionListResult + } + + public func fetchCollection(id: Int) -> Observable<[BookmarkResponse]> { + fetchCollectionResult + } + + public func updateCollectionName(collectionId: Int, name: String) -> Completable { + updateCollectionNameResult + } + + public func deleteCollection(collectionId: Int) -> Completable { + deleteCollectionResult + } + + public func addCollectionAndBookmark(collectionIds: [Int], bookmarkIds: [Int]) -> Completable { + addCollectionAndBookmarkResult + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockParseItemFilterResultUseCase.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockParseItemFilterResultUseCase.swift new file mode 100644 index 00000000..3dac15de --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/MockParseItemFilterResultUseCase.swift @@ -0,0 +1,11 @@ +import MLSBookmarkFeatureInterface + +public final class MockParseItemFilterResultUseCase: ParseItemFilterResultUseCase { + public var result: ItemFilterCriteria = ItemFilterCriteria(jobIds: [], startLevel: nil, endLevel: nil, categoryIds: []) + + public init() {} + + public func execute(results: [(String, String)]) -> ItemFilterCriteria { + result + } +} diff --git a/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/Stubs.swift b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/Stubs.swift new file mode 100644 index 00000000..81656be2 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Sources/MLSBookmarkFeatureTesting/Stubs.swift @@ -0,0 +1,74 @@ +import MLSAuthFeatureInterface +import MLSBookmarkFeatureInterface +import MLSCore +import MLSDesignSystem +import RxRelay +import UIKit + +// MARK: - Stub Modal ViewController + +public final class StubModalViewController: BaseViewController, ModalPresentable { + public var modalHeight: CGFloat? +} + +// MARK: - Login + +public final class StubLoginFactory: LoginFactory { + public init() {} + public func make(exitRoute: LoginExitRoute, onLoginCompleted: (() -> Void)?) -> BaseViewController { + BaseViewController() + } +} + +// MARK: - Dictionary External Factories + +public final class StubDictionarySearchFactory: DictionarySearchFactory { + public init() {} + public func make() -> BaseViewController { BaseViewController() } +} + +public final class StubDictionaryNotificationFactory: DictionaryNotificationFactory { + public init() {} + public func make() -> BaseViewController { BaseViewController() } +} + +public final class StubDictionaryDetailFactory: DictionaryDetailFactory { + public init() {} + public func make( + type: DictionaryType, + id: Int, + bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, + loginRelay: PublishRelay? + ) -> BaseViewController { + BaseViewController() + } +} + +public final class StubSortedBottomSheetFactory: SortedBottomSheetFactory { + public init() {} + public func make( + sortedOptions: [SortType], + selectedIndex: Int, + onSelectedIndex: @escaping (Int) -> Void + ) -> BaseViewController & ModalPresentable { + StubModalViewController() + } +} + +public final class StubItemFilterBottomSheetFactory: ItemFilterBottomSheetFactory { + public init() {} + public func make(onFilterSelected: @escaping ([(String, String)]) -> Void) -> BaseViewController { + BaseViewController() + } +} + +public final class StubMonsterFilterBottomSheetFactory: MonsterFilterBottomSheetFactory { + public init() {} + public func make( + startLevel: Int, + endLevel: Int, + onFilterSelected: @escaping (Int, Int) -> Void + ) -> BaseViewController & ModalPresentable { + StubModalViewController() + } +} diff --git a/MLS/MLSBookmarkFeature/Tests/MLSBookmarkFeatureTests/BookmarkListReactorTests.swift b/MLS/MLSBookmarkFeature/Tests/MLSBookmarkFeatureTests/BookmarkListReactorTests.swift new file mode 100644 index 00000000..a568d224 --- /dev/null +++ b/MLS/MLSBookmarkFeature/Tests/MLSBookmarkFeatureTests/BookmarkListReactorTests.swift @@ -0,0 +1,79 @@ +@testable import MLSBookmarkFeature +import MLSBookmarkFeatureInterface +import MLSBookmarkFeatureTesting +import RxBlocking +import XCTest + +final class BookmarkListReactorTests: XCTestCase { + var authRepository: MockBookmarkAuthRepository! + var bookmarkRepository: MockBookmarkRepository! + var parseUseCase: MockParseItemFilterResultUseCase! + + override func setUp() { + super.setUp() + authRepository = MockBookmarkAuthRepository() + bookmarkRepository = MockBookmarkRepository() + parseUseCase = MockParseItemFilterResultUseCase() + } + + func test_viewWillAppear_whenLoggedIn_fetchesItems() throws { + let expectedItems = [ + BookmarkResponse(name: "테스트", bookmarkId: 1, originalId: 100, imageUrl: nil, type: .item, level: nil) + ] + authRepository.isLoggedInResult = .just(true) + bookmarkRepository.fetchBookmarkResult = .just(expectedItems) + + let reactor = BookmarkListReactor( + type: .total, + authRepository: authRepository, + bookmarkRepository: bookmarkRepository, + parseItemFilterResultUseCase: parseUseCase + ) + + reactor.action.onNext(.viewWillAppear) + + let items = try reactor.state.map(\.items).toBlocking(timeout: 1).first() + XCTAssertEqual(items, expectedItems) + } + + func test_viewWillAppear_whenLoggedOut_itemsEmpty() throws { + authRepository.isLoggedInResult = .just(false) + + let reactor = BookmarkListReactor( + type: .total, + authRepository: authRepository, + bookmarkRepository: bookmarkRepository, + parseItemFilterResultUseCase: parseUseCase + ) + + reactor.action.onNext(.viewWillAppear) + + let isLogin = try reactor.state.map(\.isLogin).toBlocking(timeout: 1).first() + XCTAssertEqual(isLogin, false) + XCTAssertTrue(reactor.currentState.items.isEmpty) + } + + func test_toggleBookmark_removesItemAndEmitsDeleteEvent() throws { + let item = BookmarkResponse(name: "테스트", bookmarkId: 1, originalId: 100, imageUrl: nil, type: .item, level: nil) + authRepository.isLoggedInResult = .just(true) + bookmarkRepository.fetchBookmarkResult = .just([item]) + bookmarkRepository.deleteBookmarkResult = .empty() + + let reactor = BookmarkListReactor( + type: .total, + authRepository: authRepository, + bookmarkRepository: bookmarkRepository, + parseItemFilterResultUseCase: parseUseCase + ) + + reactor.action.onNext(.viewWillAppear) + reactor.action.onNext(.toggleBookmark(100)) + + let event = try reactor.state.map(\.uiEvent).toBlocking(timeout: 1).first() + if case .delete(let deletedItem) = event { + XCTAssertEqual(deletedItem.originalId, 100) + } else { + XCTFail("Expected delete event") + } + } +} diff --git a/MLS/MLSBookmarkFeatureExample/AppDelegate.swift b/MLS/MLSBookmarkFeatureExample/AppDelegate.swift new file mode 100644 index 00000000..eb30b59f --- /dev/null +++ b/MLS/MLSBookmarkFeatureExample/AppDelegate.swift @@ -0,0 +1,16 @@ +import MLSDesignSystem +import UIKit + +@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 { + UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {} +} diff --git a/MLS/MLSBookmarkFeatureExample/Assets.xcassets/AccentColor.colorset/Contents.json b/MLS/MLSBookmarkFeatureExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/MLS/MLSBookmarkFeatureExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git "a/MLS/MLSBookmarkFeatureExample/Assets.xcassets/AppIcon.appiconset/ChatGPT Image 2026\353\205\204 5\354\233\224 29\354\235\274 \354\230\244\354\240\204 10_33_07 (1).png" "b/MLS/MLSBookmarkFeatureExample/Assets.xcassets/AppIcon.appiconset/ChatGPT Image 2026\353\205\204 5\354\233\224 29\354\235\274 \354\230\244\354\240\204 10_33_07 (1).png" new file mode 100644 index 00000000..0e39b28a Binary files /dev/null and "b/MLS/MLSBookmarkFeatureExample/Assets.xcassets/AppIcon.appiconset/ChatGPT Image 2026\353\205\204 5\354\233\224 29\354\235\274 \354\230\244\354\240\204 10_33_07 (1).png" differ diff --git a/MLS/MLSBookmarkFeatureExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/MLS/MLSBookmarkFeatureExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..6834c3e4 --- /dev/null +++ b/MLS/MLSBookmarkFeatureExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "ChatGPT Image 2026년 5월 29일 오전 10_33_07 (1).png", + "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/MLSBookmarkFeatureExample/Assets.xcassets/Contents.json b/MLS/MLSBookmarkFeatureExample/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/MLS/MLSBookmarkFeatureExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/MLSBookmarkFeatureExample/Base.lproj/LaunchScreen.storyboard b/MLS/MLSBookmarkFeatureExample/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..865e9329 --- /dev/null +++ b/MLS/MLSBookmarkFeatureExample/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MLS/MLSBookmarkFeatureExample/Info.plist b/MLS/MLSBookmarkFeatureExample/Info.plist new file mode 100644 index 00000000..bc7411a1 --- /dev/null +++ b/MLS/MLSBookmarkFeatureExample/Info.plist @@ -0,0 +1,25 @@ + + + + + UILaunchStoryboardName + LaunchScreen + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/MLS/MLSBookmarkFeatureExample/MenuViewController.swift b/MLS/MLSBookmarkFeatureExample/MenuViewController.swift new file mode 100644 index 00000000..0a0f7e58 --- /dev/null +++ b/MLS/MLSBookmarkFeatureExample/MenuViewController.swift @@ -0,0 +1,131 @@ +import MLSBookmarkFeature +import MLSBookmarkFeatureInterface +import MLSBookmarkFeatureTesting +import MLSCore +import UIKit + +final class MenuViewController: UITableViewController { + + private struct MenuItem { + let title: String + let subtitle: String + let action: (UINavigationController) -> Void + } + + private lazy var items: [MenuItem] = [ + MenuItem( + title: "BookmarkMain", + subtitle: "북마크 메인 (탭 + 페이지)" + ) { nav in + let factory = DIContainer.resolve(type: BookmarkMainFactory.self) + let vc = factory.make(bottomInset: 0) + let child = UINavigationController(rootViewController: vc) + child.navigationBar.isHidden = true + child.modalPresentationStyle = .fullScreen + nav.present(child, animated: true) + }, + MenuItem( + title: "BookmarkList – 전체", + subtitle: "전체 북마크 리스트" + ) { nav in + let factory = DIContainer.resolve(type: BookmarkListFactory.self) + let vc = factory.make(type: .total, listType: .bookmark) + nav.pushViewController(vc, animated: true) + }, + MenuItem( + title: "BookmarkList – 아이템", + subtitle: "아이템 북마크 리스트 (필터 포함)" + ) { nav in + let factory = DIContainer.resolve(type: BookmarkListFactory.self) + let vc = factory.make(type: .item, listType: .bookmark) + nav.pushViewController(vc, animated: true) + }, + MenuItem( + title: "BookmarkList – 몬스터", + subtitle: "몬스터 북마크 리스트 (필터 포함)" + ) { nav in + let factory = DIContainer.resolve(type: BookmarkListFactory.self) + let vc = factory.make(type: .monster, listType: .bookmark) + nav.pushViewController(vc, animated: true) + }, + MenuItem( + title: "CollectionList", + subtitle: "컬렉션 목록" + ) { nav in + let factory = DIContainer.resolve(type: CollectionListFactory.self) + let vc = factory.make() + nav.pushViewController(vc, animated: true) + }, + MenuItem( + title: "CollectionDetail", + subtitle: "컬렉션 상세 (빈 컬렉션)" + ) { nav in + let factory = DIContainer.resolve(type: CollectionDetailFactory.self) + let stub = CollectionResponse( + collectionId: 1, + name: "내 컬렉션", + createdAt: [2024, 1, 1], + recentBookmarks: [] + ) + let vc = factory.make(collection: stub, onMoveToMain: nil) + nav.pushViewController(vc, animated: true) + }, + MenuItem( + title: "BookmarkModal", + subtitle: "컬렉션에 북마크 추가 모달" + ) { nav in + let factory = DIContainer.resolve(type: BookmarkModalFactory.self) + let vc = factory.make(bookmarkIds: [1, 2, 3]) + vc.modalPresentationStyle = .pageSheet + if let sheet = vc.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + } + nav.present(vc, animated: true) + }, + MenuItem( + title: "AddCollection", + subtitle: "새 컬렉션 추가 모달" + ) { nav in + let factory = DIContainer.resolve(type: AddCollectionFactory.self) + let vc = factory.make(collection: nil) + nav.present(vc, animated: true) + }, + MenuItem( + title: "BookmarkOnBoarding", + subtitle: "온보딩 화면" + ) { nav in + let factory = DIContainer.resolve(type: BookmarkOnBoardingFactory.self) + let vc = factory.make() + vc.modalPresentationStyle = .fullScreen + nav.present(vc, animated: true) + } + ] + + override func viewDidLoad() { + super.viewDidLoad() + title = "Bookmark Feature" + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + items.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) + let item = items[indexPath.row] + var config = cell.defaultContentConfiguration() + config.text = item.title + config.secondaryText = item.subtitle + cell.contentConfiguration = config + cell.accessoryType = .disclosureIndicator + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard let nav = navigationController else { return } + items[indexPath.row].action(nav) + } +} diff --git a/MLS/MLSBookmarkFeatureExample/SceneDelegate.swift b/MLS/MLSBookmarkFeatureExample/SceneDelegate.swift new file mode 100644 index 00000000..aefd3329 --- /dev/null +++ b/MLS/MLSBookmarkFeatureExample/SceneDelegate.swift @@ -0,0 +1,86 @@ +import MLSBookmarkFeature +import MLSBookmarkFeatureInterface +import MLSBookmarkFeatureTesting +import MLSCore +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + registerDependencies() + + let window = UIWindow(windowScene: windowScene) + window.backgroundColor = .white + self.window = window + + let menuVC = MenuViewController() + let nav = UINavigationController(rootViewController: menuVC) + window.rootViewController = nav + window.makeKeyAndVisible() + } + + private func registerDependencies() { + let mockBookmarkRepository = MockBookmarkRepository() + let mockCollectionRepository = MockCollectionRepository() + let mockAuthRepository = MockBookmarkAuthRepository() + let mockUserDefaultsRepository = MockBookmarkUserDefaultsRepository() + let mockParseUseCase = MockParseItemFilterResultUseCase() + + let addCollectionFactory = AddCollectionFactoryImpl(collectionRepository: mockCollectionRepository) + let bookmarkModalFactory = BookmarkModalFactoryImpl( + addCollectionFactory: addCollectionFactory, + collectionRepository: mockCollectionRepository + ) + let collectionSettingFactory = CollectionSettingFactoryImpl() + let collectionEditFactory = CollectionEditFactoryImpl(bookmarkModalFactory: bookmarkModalFactory) + let collectionDetailFactory = CollectionDetailFactoryImpl( + bookmarkModalFactory: bookmarkModalFactory, + collectionSettingFactory: collectionSettingFactory, + addCollectionFactory: addCollectionFactory, + collectionEditFactory: collectionEditFactory, + dictionaryDetailFactory: StubDictionaryDetailFactory(), + collectionRepository: mockCollectionRepository + ) + let collectionListFactory = CollectionListFactoryImpl( + collectionRepository: mockCollectionRepository, + addCollectionFactory: addCollectionFactory, + collectionDetailFactory: collectionDetailFactory, + sortedBottomSheetFactory: StubSortedBottomSheetFactory() + ) + let bookmarkListFactory = BookmarkListFactoryImpl( + itemFilterFactory: StubItemFilterBottomSheetFactory(), + monsterFilterFactory: StubMonsterFilterBottomSheetFactory(), + sortedFactory: StubSortedBottomSheetFactory(), + bookmarkModalFactory: bookmarkModalFactory, + loginFactory: StubLoginFactory(), + dictionaryDetailFactory: StubDictionaryDetailFactory(), + collectionEditFactory: collectionEditFactory, + authRepository: mockAuthRepository, + bookmarkRepository: mockBookmarkRepository, + parseItemFilterResultUseCase: mockParseUseCase + ) + let onBoardingFactory = BookmarkOnBoardingFactoryImpl() + + DIContainer.register(type: BookmarkMainFactory.self) { + BookmarkMainFactoryImpl( + authRepository: mockAuthRepository, + userDefaultsRepository: mockUserDefaultsRepository, + onBoardingFactory: onBoardingFactory, + bookmarkListFactory: bookmarkListFactory, + collectionListFactory: collectionListFactory, + searchFactory: StubDictionarySearchFactory(), + notificationFactory: StubDictionaryNotificationFactory(), + loginFactory: StubLoginFactory() + ) + } + DIContainer.register(type: BookmarkListFactory.self) { bookmarkListFactory } + DIContainer.register(type: CollectionListFactory.self) { collectionListFactory } + DIContainer.register(type: CollectionDetailFactory.self) { collectionDetailFactory } + DIContainer.register(type: BookmarkModalFactory.self) { bookmarkModalFactory } + DIContainer.register(type: AddCollectionFactory.self) { addCollectionFactory } + DIContainer.register(type: BookmarkOnBoardingFactory.self) { onBoardingFactory } + } +} diff --git a/MLS/MLSBookmarkFeatureExample/Stubs.swift b/MLS/MLSBookmarkFeatureExample/Stubs.swift new file mode 100644 index 00000000..c1474e27 --- /dev/null +++ b/MLS/MLSBookmarkFeatureExample/Stubs.swift @@ -0,0 +1 @@ +// Stubs are defined in MLSBookmarkFeatureTesting diff --git a/MLS/MLSCore/Sources/MLSCore/Utils/DictionaryTabControllable.swift b/MLS/MLSCore/Sources/MLSCore/Utils/DictionaryTabControllable.swift new file mode 100644 index 00000000..06ad3253 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Utils/DictionaryTabControllable.swift @@ -0,0 +1,15 @@ +public protocol DictionaryTabControllable: AnyObject { + func changeTab(index: Int) +} + +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) + } +}