diff --git a/Projects/Domains/BaseDomain/Project.swift b/Projects/Domains/BaseDomain/Project.swift index 7278b4a70..d98d0b1d0 100644 --- a/Projects/Domains/BaseDomain/Project.swift +++ b/Projects/Domains/BaseDomain/Project.swift @@ -8,14 +8,14 @@ let project = Project.module( .interface( module: .domain(.BaseDomain), dependencies: [ - .Project.Module.ThirdPartyLib + .Project.Module.ThirdPartyLib, + .Project.Module.ErrorModule, ] ), .implements( module: .domain(.BaseDomain), dependencies: [ .Project.Module.Utility, - .Project.Module.ErrorModule, .Project.Module.KeychainModule, .domain(target: .BaseDomain, type: .interface) ] diff --git a/Projects/Domains/PlayListDomain/Interface/DataSource/RemotePlayListDataSource.swift b/Projects/Domains/PlayListDomain/Interface/DataSource/RemotePlayListDataSource.swift index d223c5fd9..28377d5de 100644 --- a/Projects/Domains/PlayListDomain/Interface/DataSource/RemotePlayListDataSource.swift +++ b/Projects/Domains/PlayListDomain/Interface/DataSource/RemotePlayListDataSource.swift @@ -11,6 +11,6 @@ public protocol RemotePlayListDataSource { func fetchPlaylistSongs(id: String) -> Single<[SongEntity]> func updatePlaylist(key: String, songs: [String]) -> Completable func addSongIntoPlayList(key: String, songs: [String]) -> Single - func removeSongs(key: String, songs: [String]) -> Single + func removeSongs(key: String, songs: [String]) -> Completable func uploadImage(key: String, model: UploadImageType) -> Single } diff --git a/Projects/Domains/PlayListDomain/Interface/Entity/AddSongEntity.swift b/Projects/Domains/PlayListDomain/Interface/Entity/AddSongEntity.swift index a1042ed0f..c39da12f6 100644 --- a/Projects/Domains/PlayListDomain/Interface/Entity/AddSongEntity.swift +++ b/Projects/Domains/PlayListDomain/Interface/Entity/AddSongEntity.swift @@ -1,22 +1,14 @@ -// -// AddSongEntity.swift -// DomainModule -// -// Created by yongbeomkwak on 2023/03/14. -// Copyright © 2023 yongbeomkwak. All rights reserved. -// - import Foundation public struct AddSongEntity: Equatable { public init( - added_songs_length: Int, + addedSongCount: Int, duplicated: Bool ) { - self.added_songs_length = added_songs_length + self.addedSongCount = addedSongCount self.duplicated = duplicated } - public let added_songs_length: Int + public let addedSongCount: Int public let duplicated: Bool } diff --git a/Projects/Domains/PlayListDomain/Interface/Entity/RecommendPlayListEntity.swift b/Projects/Domains/PlayListDomain/Interface/Entity/RecommendPlayListEntity.swift index 033477283..ea4d32fdb 100644 --- a/Projects/Domains/PlayListDomain/Interface/Entity/RecommendPlayListEntity.swift +++ b/Projects/Domains/PlayListDomain/Interface/Entity/RecommendPlayListEntity.swift @@ -8,7 +8,7 @@ import Foundation -public struct RecommendPlayListEntity: Equatable { +public struct RecommendPlayListEntity: Hashable, Equatable { public init( key: String, title: String, @@ -26,4 +26,13 @@ public struct RecommendPlayListEntity: Equatable { public let key, title, image: String public let `private`: Bool public let count: Int + private let id = UUID() + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public static func == (_ lhs: Self, _ rhs: Self) -> Bool { + lhs.id == rhs.id + } } diff --git a/Projects/Domains/PlayListDomain/Interface/Repository/PlayListRepository.swift b/Projects/Domains/PlayListDomain/Interface/Repository/PlayListRepository.swift index 794cf4811..a86617540 100644 --- a/Projects/Domains/PlayListDomain/Interface/Repository/PlayListRepository.swift +++ b/Projects/Domains/PlayListDomain/Interface/Repository/PlayListRepository.swift @@ -11,6 +11,6 @@ public protocol PlayListRepository { func fetchPlaylistSongs(id: String) -> Single<[SongEntity]> func updatePlayList(key: String, songs: [String]) -> Completable func addSongIntoPlayList(key: String, songs: [String]) -> Single - func removeSongs(key: String, songs: [String]) -> Single + func removeSongs(key: String, songs: [String]) -> Completable func uploadImage(key: String, model: UploadImageType) -> Single } diff --git a/Projects/Domains/PlayListDomain/Interface/UseCase/RemoveSongsUseCase.swift b/Projects/Domains/PlayListDomain/Interface/UseCase/RemoveSongsUseCase.swift index b3b229823..43d13f61b 100644 --- a/Projects/Domains/PlayListDomain/Interface/UseCase/RemoveSongsUseCase.swift +++ b/Projects/Domains/PlayListDomain/Interface/UseCase/RemoveSongsUseCase.swift @@ -3,5 +3,5 @@ import Foundation import RxSwift public protocol RemoveSongsUseCase { - func execute(key: String, songs: [String]) -> Single + func execute(key: String, songs: [String]) -> Completable } diff --git a/Projects/Domains/PlayListDomain/Project.swift b/Projects/Domains/PlayListDomain/Project.swift index 390876aa6..98c082839 100644 --- a/Projects/Domains/PlayListDomain/Project.swift +++ b/Projects/Domains/PlayListDomain/Project.swift @@ -23,6 +23,9 @@ let project = Project.module( .tests( module: .domain(.PlayListDomain), dependencies: [.domain(target: .PlayListDomain)] - ) + ), + .testing(module: .domain(.PlayListDomain), dependencies: [ + .domain(target: .PlayListDomain, type: .interface) + ]) ] ) diff --git a/Projects/Domains/PlayListDomain/Sources/API/PlayListAPI.swift b/Projects/Domains/PlayListDomain/Sources/API/PlayListAPI.swift index 59badd764..d02e3b108 100644 --- a/Projects/Domains/PlayListDomain/Sources/API/PlayListAPI.swift +++ b/Projects/Domains/PlayListDomain/Sources/API/PlayListAPI.swift @@ -60,10 +60,7 @@ extension PlayListAPI: WMAPI { return "/create" case let .fetchPlaylistSongs(key: key), let .addSongIntoPlayList(key: key, _), let .updatePlaylist(key: key, _), - let .removeSongs( - key: key, - _ - ): + let .removeSongs(key: key, _): return "/\(key)/songs" case let .uploadImage(key: key, _): @@ -117,10 +114,9 @@ extension PlayListAPI: WMAPI { switch model { case let .default(imageName: data): datas.append(MultipartFormData( - provider: .data("default".data(using: .utf8)!), - name: "type", - fileName: "ㅅ" + provider: .data("default".data(using: .utf8)!), name: "type" )) + datas.append(MultipartFormData(provider: .data(data.data(using: .utf8)!), name: "imageName")) case let .custom(imageName: data): @@ -128,8 +124,7 @@ extension PlayListAPI: WMAPI { datas.append(MultipartFormData( provider: .data(data), name: "imageFile", - fileName: "image.jpeg", - mimeType: "image/jpeg, image/jpg, image/png" + fileName: "image.jpeg" )) } return .uploadMultipart(datas) diff --git a/Projects/Domains/PlayListDomain/Sources/DataSource/RemotePlayListDataSourceImpl.swift b/Projects/Domains/PlayListDomain/Sources/DataSource/RemotePlayListDataSourceImpl.swift index 01263daa4..c4901d8e1 100644 --- a/Projects/Domains/PlayListDomain/Sources/DataSource/RemotePlayListDataSourceImpl.swift +++ b/Projects/Domains/PlayListDomain/Sources/DataSource/RemotePlayListDataSourceImpl.swift @@ -47,10 +47,9 @@ public final class RemotePlayListDataSourceImpl: BaseRemoteDataSource Single { + public func removeSongs(key: String, songs: [String]) -> Completable { request(.removeSongs(key: key, songs: songs)) - .map(BaseResponseDTO.self) - .map { $0.toDomain() } + .asCompletable() } public func uploadImage(key: String, model: UploadImageType) -> Single { diff --git a/Projects/Domains/PlayListDomain/Sources/Repository/PlaylistRepositoryImpl.swift b/Projects/Domains/PlayListDomain/Sources/Repository/PlaylistRepositoryImpl.swift index bed837e8d..114d52dd2 100644 --- a/Projects/Domains/PlayListDomain/Sources/Repository/PlaylistRepositoryImpl.swift +++ b/Projects/Domains/PlayListDomain/Sources/Repository/PlaylistRepositoryImpl.swift @@ -40,7 +40,7 @@ public final class PlayListRepositoryImpl: PlayListRepository { remotePlayListDataSource.addSongIntoPlayList(key: key, songs: songs) } - public func removeSongs(key: String, songs: [String]) -> RxSwift.Single { + public func removeSongs(key: String, songs: [String]) -> Completable { remotePlayListDataSource.removeSongs(key: key, songs: songs) } diff --git a/Projects/Domains/PlayListDomain/Sources/ResponseDTO/AddSongResponseDTO.swift b/Projects/Domains/PlayListDomain/Sources/ResponseDTO/AddSongResponseDTO.swift index 603ef05de..467f3df87 100644 --- a/Projects/Domains/PlayListDomain/Sources/ResponseDTO/AddSongResponseDTO.swift +++ b/Projects/Domains/PlayListDomain/Sources/ResponseDTO/AddSongResponseDTO.swift @@ -9,7 +9,7 @@ public struct AddSongResponseDTO: Decodable { public extension AddSongResponseDTO { func toDomain() -> AddSongEntity { AddSongEntity( - added_songs_length: addedSongCount, + addedSongCount: addedSongCount, duplicated: isDuplicatedSongsExist ) } diff --git a/Projects/Domains/PlayListDomain/Sources/UseCase/RemoveSongsUseCaseImpl.swift b/Projects/Domains/PlayListDomain/Sources/UseCase/RemoveSongsUseCaseImpl.swift index 5413fc61e..f6b6b55b3 100644 --- a/Projects/Domains/PlayListDomain/Sources/UseCase/RemoveSongsUseCaseImpl.swift +++ b/Projects/Domains/PlayListDomain/Sources/UseCase/RemoveSongsUseCaseImpl.swift @@ -20,7 +20,7 @@ public struct RemoveSongsUseCaseImpl: RemoveSongsUseCase { self.playListRepository = playListRepository } - public func execute(key: String, songs: [String]) -> Single { + public func execute(key: String, songs: [String]) -> Completable { return playListRepository.removeSongs(key: key, songs: songs) } } diff --git a/Projects/Domains/PlayListDomain/Testing/FetchPlayListUseCaseStub.swift b/Projects/Domains/PlayListDomain/Testing/FetchPlayListUseCaseStub.swift new file mode 100644 index 000000000..63a674833 --- /dev/null +++ b/Projects/Domains/PlayListDomain/Testing/FetchPlayListUseCaseStub.swift @@ -0,0 +1,141 @@ +import PlayListDomainInterface +import RxSwift + +final class FetchPlayListUseCaseStub: FetchRecommendPlayListUseCase { + var fetchData = [ + RecommendPlayListEntity( + key: "best", + title: "베스트", + image: "https://cdn.wakmusic.xyz/playlist/best_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "carol", + title: "캐롤", + image: "https://cdn.wakmusic.xyz/playlist/carol_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "competition", + title: "경쟁", + image: "https://cdn.wakmusic.xyz/playlist/competition_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "best", + title: "베스트", + image: "https://cdn.wakmusic.xyz/playlist/best_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "carol", + title: "캐롤", + image: "https://cdn.wakmusic.xyz/playlist/carol_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "competition", + title: "경쟁", + image: "https://cdn.wakmusic.xyz/playlist/competition_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "best", + title: "베스트", + image: "https://cdn.wakmusic.xyz/playlist/best_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "carol", + title: "캐롤", + image: "https://cdn.wakmusic.xyz/playlist/carol_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "competition", + title: "경쟁", + image: "https://cdn.wakmusic.xyz/playlist/competition_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "best", + title: "베스트", + image: "https://cdn.wakmusic.xyz/playlist/best_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "carol", + title: "캐롤", + image: "https://cdn.wakmusic.xyz/playlist/carol_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "competition", + title: "경쟁", + image: "https://cdn.wakmusic.xyz/playlist/competition_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "best", + title: "베스트", + image: "https://cdn.wakmusic.xyz/playlist/best_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "carol", + title: "캐롤", + image: "https://cdn.wakmusic.xyz/playlist/carol_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "competition", + title: "경쟁", + image: "https://cdn.wakmusic.xyz/playlist/competition_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "best", + title: "베스트", + image: "https://cdn.wakmusic.xyz/playlist/best_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "carol", + title: "캐롤", + image: "https://cdn.wakmusic.xyz/playlist/carol_1.png", + private: false, + count: 0 + ), + RecommendPlayListEntity( + key: "competition", + title: "경쟁", + image: "https://cdn.wakmusic.xyz/playlist/competition_1.png", + private: false, + count: 0 + ) + ] + + func execute() -> Single<[RecommendPlayListEntity]> { + return Single.create { [fetchData] single in + + single(.success(fetchData)) + return Disposables.create() + } + } +} diff --git a/Projects/Features/BaseFeature/Sources/ViewModels/ContainSongsViewModel.swift b/Projects/Features/BaseFeature/Sources/ViewModels/ContainSongsViewModel.swift index 13520ad27..724296f44 100644 --- a/Projects/Features/BaseFeature/Sources/ViewModels/ContainSongsViewModel.swift +++ b/Projects/Features/BaseFeature/Sources/ViewModels/ContainSongsViewModel.swift @@ -103,10 +103,10 @@ public final class ContainSongsViewModel: ViewModelType { if entity.duplicated { return BaseEntity( status: 200, - description: "\(entity.added_songs_length)곡이 내 리스트에 담겼습니다. 중복 곡은 제외됩니다." + description: "\(entity.addedSongCount)곡이 내 리스트에 담겼습니다. 중복 곡은 제외됩니다." ) } else { - return BaseEntity(status: 200, description: "\(entity.added_songs_length)곡이 내 리스트에 담겼습니다.") + return BaseEntity(status: 200, description: "\(entity.addedSongCount)곡이 내 리스트에 담겼습니다.") } } .bind(to: output.showToastMessage) diff --git a/Projects/Features/PlaylistFeature/Sources/PlayListDetailViewModel.swift b/Projects/Features/PlaylistFeature/Sources/PlayListDetailViewModel.swift deleted file mode 100644 index 42de22ce6..000000000 --- a/Projects/Features/PlaylistFeature/Sources/PlayListDetailViewModel.swift +++ /dev/null @@ -1,370 +0,0 @@ -// -// SearchViewModel.swift -// SearchFeature -// -// Created by yongbeomkwak on 2023/01/05. -// Copyright © 2023 yongbeomkwak. All rights reserved. -// - -import AuthDomainInterface -import BaseDomainInterface -import BaseFeature -import ErrorModule -import Foundation -import PlayListDomainInterface -import RxCocoa -import RxRelay -import RxSwift -import SongsDomainInterface -import Utility - -public final class PlayListDetailViewModel: ViewModelType { - var type: PlayListType! - var id: String! - var key: String? - var fetchPlayListDetailUseCase: FetchPlayListDetailUseCase! - var updatePlaylistUseCase: UpdatePlaylistUseCase! - var removeSongsUseCase: RemoveSongsUseCase! - private let logoutUseCase: any LogoutUseCase - var disposeBag = DisposeBag() - - public struct Input { - let itemMoved: PublishSubject = PublishSubject() - let playListNameLoad: BehaviorRelay = BehaviorRelay(value: "") - let cancelEdit: PublishSubject = PublishSubject() - let runEditing: PublishSubject = PublishSubject() - let songTapped: PublishSubject = PublishSubject() - let allSongSelected: PublishSubject = PublishSubject() - let tapRemoveSongs: PublishSubject = PublishSubject() - let state: BehaviorRelay = BehaviorRelay(value: EditState(isEditing: false, force: false)) - let groupPlayTapped: PublishSubject = PublishSubject() - } - - public struct Output { - let headerInfo: PublishRelay = PublishRelay() - let dataSource: BehaviorRelay<[PlayListDetailSectionModel]> = BehaviorRelay(value: []) - let backUpdataSource: BehaviorRelay<[PlayListDetailSectionModel]> = BehaviorRelay(value: []) - let indexOfSelectedSongs: BehaviorRelay<[Int]> = BehaviorRelay(value: []) - let songEntityOfSelectedSongs: BehaviorRelay<[SongEntity]> = BehaviorRelay(value: []) - let refreshPlayList: BehaviorRelay = BehaviorRelay(value: ()) - let groupPlaySongs: PublishSubject<[SongEntity]> = PublishSubject() - let showErrorToast: PublishRelay = PublishRelay() - let onLogout: PublishRelay - } - - public init( - id: String, - type: PlayListType, - fetchPlayListDetailUseCase: FetchPlayListDetailUseCase, - updatePlaylistUseCase: UpdatePlaylistUseCase, - removeSongsUseCase: RemoveSongsUseCase, - logoutUseCase: any LogoutUseCase - ) { - self.id = id - self.type = type - self.fetchPlayListDetailUseCase = fetchPlayListDetailUseCase - self.updatePlaylistUseCase = updatePlaylistUseCase - self.removeSongsUseCase = removeSongsUseCase - self.logoutUseCase = logoutUseCase - } - - deinit { - DEBUG_LOG("❌ \(Self.self) 소멸") - } - - public func transform(from input: Input) -> Output { - let logoutRelay = PublishRelay() - - let output = Output(onLogout: logoutRelay) - - output.refreshPlayList - .flatMap { [weak self] () -> Observable in - guard let self = self else { return Observable.empty() } - return self.fetchPlayListDetailUseCase.execute(id: self.id, type: self.type) - .catchAndReturn( - PlayListDetailEntity( - key: "", - title: "", - songs: [], - image: "", - private: false - ) - ) - .asObservable() - .do(onNext: { [weak self] model in - guard let self = self else { return } - output.headerInfo.accept( - PlayListHeaderModel( - title: model.title, - songCount: "\(model.songs.count)곡", - image: model.image - ) - ) - self.key = model.key - }) - } - .map { [PlayListDetailSectionModel(model: 0, items: $0.songs)] } - .bind(to: output.dataSource, output.backUpdataSource) - .disposed(by: disposeBag) - - input.playListNameLoad - .skip(1) - .withLatestFrom(output.headerInfo) { ($0, $1) } - .map { PlayListHeaderModel(title: $0.0, songCount: $0.1.songCount, image: $0.1.image) - } - .bind(to: output.headerInfo) - .disposed(by: disposeBag) - - input.runEditing - .withLatestFrom(output.dataSource) - .filter { !($0.first?.items ?? []).isEmpty } - .map { $0.first?.items.map { $0.id } ?? [] } - .do(onNext: { _ in - output.indexOfSelectedSongs.accept([]) // 바텀 Tab 내려가게 하기 위해 - output.songEntityOfSelectedSongs.accept([]) // 바텀 Tab 내려가게 하기 위해 - }) - .filter { (ids: [String]) -> Bool in - let beforeIds: [String] = output.backUpdataSource.value.first?.items.map { $0.id } ?? [] - let elementsEqual: Bool = beforeIds.elementsEqual(ids) - DEBUG_LOG(elementsEqual ? "❌ 변경된 내용이 없습니다." : "✅ 리스트가 변경되었습니다.") - return elementsEqual == false - } - .flatMap { [weak self] (songs: [String]) -> Completable in - guard let self = self, let key = self.key else { - return .empty() - } - return self.updatePlaylistUseCase.execute(key: key, songs: songs) - .catch { [logoutUseCase] (error: Error) in - let wmError = error.asWMError - - if wmError == .tokenExpired { - logoutRelay.accept(wmError) - return logoutUseCase.execute() - .andThen(.never()) - } else { - output.showErrorToast.accept(BaseEntity( - status: 0, - description: error.asWMError.errorDescription ?? "" - )) - } - - return .never() - } - } - .subscribe(onCompleted: { - output.refreshPlayList.accept(()) - NotificationCenter.default.post(name: .playListRefresh, object: nil) // 바깥 플리 업데이트 - }) - .disposed(by: disposeBag) - - input.cancelEdit - .withLatestFrom(output.backUpdataSource) - .bind(to: output.dataSource) - .disposed(by: disposeBag) - - input.songTapped - .withLatestFrom(output.indexOfSelectedSongs, resultSelector: { index, selectedSongs -> [Int] in - if selectedSongs.contains(index) { - guard let removeTargetIndex = selectedSongs.firstIndex(where: { $0 == index }) - else { return selectedSongs } - var newSelectedSongs = selectedSongs - newSelectedSongs.remove(at: removeTargetIndex) - return newSelectedSongs - } else { - return selectedSongs + [index] - } - }) - .map { $0.sorted { $0 < $1 } } - .bind(to: output.indexOfSelectedSongs) - .disposed(by: disposeBag) - - input.allSongSelected - .withLatestFrom(output.dataSource) { ($0, $1) } - .map { flag, dataSource -> [Int] in - return flag ? Array(0 ..< dataSource.first!.items.count) : [] - } - .bind(to: output.indexOfSelectedSongs) - .disposed(by: disposeBag) - - input.tapRemoveSongs - .withLatestFrom(output.songEntityOfSelectedSongs) - .map { (entities: [SongEntity]) -> [String] in - return entities.map { $0.id } - } - .flatMap { [weak self] (songs: [String]) -> Observable in - guard let self = self, let key = self.key else { - return Observable.empty() - } - return self.removeSongsUseCase.execute(key: key, songs: songs) - .catch { [logoutUseCase] (error: Error) in - let wmError = error.asWMError - if wmError == .tokenExpired { - logoutRelay.accept(wmError) - return logoutUseCase.execute() - .andThen(.never()) - } else { - return Single.create { single in - single(.success(BaseEntity( - status: 0, - description: error.asWMError.errorDescription ?? "" - ))) - return Disposables.create {} - } - } - } - .asObservable() - } - .subscribe(onNext: { model in - output.showErrorToast.accept((model.status == 200) ? BaseEntity( - status: 200, - description: "리스트에서 삭제되었습니다." - ) : model) - output.refreshPlayList.accept(()) - output.indexOfSelectedSongs.accept([]) - output.songEntityOfSelectedSongs.accept([]) - NotificationCenter.default.post(name: .playListRefresh, object: nil) - }).disposed(by: disposeBag) - - input.itemMoved - .subscribe(onNext: { itemMovedEvent in - let source = itemMovedEvent.sourceIndex.row - let dest = itemMovedEvent.destinationIndex.row - var curr = output.dataSource.value.first?.items ?? [] - let tmp = curr[source] - - curr.remove(at: source) - curr.insert(tmp, at: dest) - - let newModel = [PlayListDetailSectionModel(model: 0, items: curr)] - output.dataSource.accept(newModel) - - // 꼭 먼저 데이터 소스를 갱신 해야합니다 - - var indexs = output.indexOfSelectedSongs.value // 현재 선택된 인덱스 모음 - let limit = curr.count - 1 // 마지막 인덱스 - - let sourceIsSelected: Bool = indexs.contains(where: { $0 == source }) // 선택된 것을 움직 였는지 ? - - if sourceIsSelected { - // 선택된 인덱스 배열 안에 source(시작점)이 있다는 뜻은 선택된 것을 옮긴다는 뜻 - - let pos = indexs.firstIndex(where: { $0 == source })! - indexs.remove(at: pos) - // 그러므로 일단 지워준다. - } - - indexs = indexs - .map { i -> Int in - // i: 현재 저장된 인덱스들을 순회함 - if source < i && i > dest { - // 옮기기 시작한 위치와 도착한 위치가 i를 기준으로 앞일 때 아무 영향 없음 - return i - } - if source < i && i <= dest { - /* 옮기기 시작한 위치는 i 앞 - 도착한 위치가 i또는 i 뒤일 경우 - i는 앞으로 한 칸 가야함 - */ - return i - 1 - } - if i < source && dest <= i { - /* 옮기기 시작한 위치는 i 뒤 - 도착한 위치가 i또는 i 앞일 경우 - i는 뒤로 한칸 가야함 - - ** 단 옮겨질 위치가 배열의 끝일 경우는 그대로 있음 - */ - return i + 1 - } - if source > i && i < dest { - /* 옮기기 시작한 위치는 i 뒤 - 도착한 위치가 i 뒤 일경우 - - 아무 영향 없음 - */ - return i - } - return i - } - if sourceIsSelected { // 선택된 것을 건드렸으므로 dest 인덱스로 갱신하여 넣어준다 - indexs.append(dest) - } - - indexs.sort() - - DEBUG_LOG("sourceIndexPath: \(source)") - DEBUG_LOG("destIndexPath: \(dest)") - DEBUG_LOG("dataSource: \(curr.map { $0.title })") - DEBUG_LOG("indexs: \(indexs)") - output.indexOfSelectedSongs.accept(indexs) - - }).disposed(by: disposeBag) - - input.groupPlayTapped - .withLatestFrom(output.dataSource) { ($0, $1) } - .map { type, dataSourc -> [SongEntity] in - guard let songs = dataSourc.first?.items as? [SongEntity] else { - return [] - } - switch type { - case .allPlay: - return songs - case .shufflePlay: - return songs.shuffled() - } - } - .bind(to: output.groupPlaySongs) - .disposed(by: disposeBag) - - output.indexOfSelectedSongs - .withLatestFrom(output.dataSource) { ($0, $1) } - .map { selectedSongs, dataSource -> [PlayListDetailSectionModel] in - var realData = dataSource.first?.items ?? [] - realData.indices.forEach { - realData[$0].isSelected = false - } - selectedSongs.forEach { i in - realData[i].isSelected = true - } - return [PlayListDetailSectionModel(model: 0, items: realData)] - } - .bind(to: output.dataSource) - .disposed(by: disposeBag) - - output.indexOfSelectedSongs - .withLatestFrom(output.dataSource) { ($0, $1) } - .map { indexOfSelectedSongs, dataSource -> [SongEntity] in - let song = dataSource.first?.items ?? [] - return indexOfSelectedSongs.map { - SongEntity( - id: song[$0].id, - title: song[$0].title, - artist: song[$0].artist, - remix: song[$0].remix, - reaction: song[$0].reaction, - views: song[$0].views, - last: song[$0].last, - date: song[$0].date - ) - } - } - .bind(to: output.songEntityOfSelectedSongs) - .disposed(by: disposeBag) - - NotificationCenter.default.rx.notification(.playListNameRefresh) - .map { notification -> String in - guard let obj = notification.object as? String else { - return "" - } - return obj - } - .do(onNext: { _ in - input.state.accept(EditState(isEditing: false, force: true)) - input.cancelEdit.onNext(()) - }) - .bind(to: input.playListNameLoad) - .disposed(by: disposeBag) - - return output - } -} diff --git a/Projects/Features/PlaylistFeature/Testing/PlaylistDetailFactoryStub.swift b/Projects/Features/PlaylistFeature/Testing/PlaylistDetailFactoryStub.swift new file mode 100644 index 000000000..3b58a5325 --- /dev/null +++ b/Projects/Features/PlaylistFeature/Testing/PlaylistDetailFactoryStub.swift @@ -0,0 +1,9 @@ +import PlaylistFeatureInterface +import UIKit + +public final class PlaylistDetailFactoryStub: PlaylistDetailFactory { + #warning("뷰컨은 어떻게 만들어낼 지 ??") + public func makeView(id: String, isCustom: Bool) -> UIViewController { + return UIViewController() + } +} diff --git a/Projects/Features/PlaylistFeature/Testing/Testing.swift b/Projects/Features/PlaylistFeature/Testing/Testing.swift deleted file mode 100644 index b1853ce60..000000000 --- a/Projects/Features/PlaylistFeature/Testing/Testing.swift +++ /dev/null @@ -1 +0,0 @@ -// This is for Tuist diff --git a/Projects/Features/SearchFeature/Demo/Sources/AppDelegate.swift b/Projects/Features/SearchFeature/Demo/Sources/AppDelegate.swift index b3e971a31..f750042e0 100644 --- a/Projects/Features/SearchFeature/Demo/Sources/AppDelegate.swift +++ b/Projects/Features/SearchFeature/Demo/Sources/AppDelegate.swift @@ -1,4 +1,7 @@ import Inject +@testable import PlayListDomainTesting +@testable import PlaylistFeatureTesting +@testable import SearchFeature import UIKit @main @@ -10,8 +13,17 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { window = UIWindow(frame: UIScreen.main.bounds) + + let fetchPlayListUseCase: FetchPlayListUseCaseStub = .init() + + let component = + WakmusicRecommendViewController( + playlistDetailFactory: PlaylistDetailFactoryStub(), + reactor: WakmusicRecommendReactor(fetchRecommendPlayListUseCase: fetchPlayListUseCase) + ) + let viewController = Inject.ViewControllerHost( - UINavigationController(rootViewController: UIViewController()) + UINavigationController(rootViewController: component) ) window?.rootViewController = viewController window?.makeKeyAndVisible() diff --git a/Projects/Features/SearchFeature/Project.swift b/Projects/Features/SearchFeature/Project.swift index e789df15e..3ed8d7ce7 100644 --- a/Projects/Features/SearchFeature/Project.swift +++ b/Projects/Features/SearchFeature/Project.swift @@ -21,8 +21,15 @@ let project = Project.module( ] ) ), + .tests(module: .feature(.SearchFeature), dependencies: [ + .feature(target: .SearchFeature), + .domain(target: .PlayListDomain, type: .testing) + ]), + .demo(module: .feature(.SearchFeature), dependencies: [ - .feature(target: .SearchFeature) + .feature(target: .SearchFeature), + .domain(target: .PlayListDomain, type: .testing), + .feature(target: .PlaylistFeature, type: .testing) ]) ] ) diff --git a/Projects/Features/SearchFeature/Sources/Components/BeforeSearchComponent.swift b/Projects/Features/SearchFeature/Sources/Components/BeforeSearchComponent.swift index 4ad4a6830..f61ed0ab1 100644 --- a/Projects/Features/SearchFeature/Sources/Components/BeforeSearchComponent.swift +++ b/Projects/Features/SearchFeature/Sources/Components/BeforeSearchComponent.swift @@ -1,11 +1,3 @@ -// -// BeforeSearchComponent.swift -// SearchFeature -// -// Created by yongbeomkwak on 2023/02/10. -// Copyright © 2023 yongbeomkwak. All rights reserved. -// - import BaseFeature import BaseFeatureInterface import Foundation diff --git a/Projects/Features/SearchFeature/Sources/Components/WakmusicRecommendComponent.swift b/Projects/Features/SearchFeature/Sources/Components/WakmusicRecommendComponent.swift new file mode 100644 index 000000000..de82823dd --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/Components/WakmusicRecommendComponent.swift @@ -0,0 +1,23 @@ +import Foundation +import NeedleFoundation +import PlayListDomainInterface +import PlaylistFeatureInterface +import UIKit + +public protocol WakmusicRecommendDependency: Dependency { + var fetchRecommendPlayListUseCase: any FetchRecommendPlayListUseCase { get } + var playlistDetailFacotry: any PlaylistDetailFactory { get } +} + +public final class WakmusicRecommendComponent: Component { + public func makeView() -> UIViewController { + let reactor = WakmusicRecommendReactor( + fetchRecommendPlayListUseCase: dependency.fetchRecommendPlayListUseCase + ) + + return WakmusicRecommendViewController( + playlistDetailFactory: dependency.playlistDetailFacotry, + reactor: reactor + ) + } +} diff --git a/Projects/Features/SearchFeature/Sources/CompositionalLayout/Enum/Section.swift b/Projects/Features/SearchFeature/Sources/CompositionalLayout/Enum/Section.swift index 7bb83e585..e28911808 100644 --- a/Projects/Features/SearchFeature/Sources/CompositionalLayout/Enum/Section.swift +++ b/Projects/Features/SearchFeature/Sources/CompositionalLayout/Enum/Section.swift @@ -5,3 +5,7 @@ internal enum Section: Int { case recommend case popularList } + +internal enum RecommendSection: Hashable { + case main +} diff --git a/Projects/Features/SearchFeature/Sources/CompositionalLayout/Layout/RecommendCollectionViewLayout.swift b/Projects/Features/SearchFeature/Sources/CompositionalLayout/Layout/RecommendCollectionViewLayout.swift new file mode 100644 index 000000000..9310dfd6d --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/CompositionalLayout/Layout/RecommendCollectionViewLayout.swift @@ -0,0 +1,39 @@ +import UIKit +import Utility + +final class RecommendCollectionViewLayout: UICollectionViewCompositionalLayout { + init() { + super.init { _, _ in + + return RecommendCollectionViewLayout.configureLayout() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension RecommendCollectionViewLayout { + private static func configureLayout() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(0.5), + heightDimension: .fractionalHeight(1.0) + ) + var item: NSCollectionLayoutItem = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .fractionalWidth(0.25) + ) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + group.interItemSpacing = .fixed(8.0) + group.contentInsets = .init(top: 8.0, leading: 20, bottom: 8.0, trailing: 20) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: .zero, bottom: .zero, trailing: .zero) + + return section + } +} diff --git a/Projects/Features/SearchFeature/Sources/Reactors/BeforeSearchReactor.swift b/Projects/Features/SearchFeature/Sources/Reactors/BeforeSearchReactor.swift index 3a6f6e7fa..2b8eaa063 100644 --- a/Projects/Features/SearchFeature/Sources/Reactors/BeforeSearchReactor.swift +++ b/Projects/Features/SearchFeature/Sources/Reactors/BeforeSearchReactor.swift @@ -84,6 +84,7 @@ public final class BeforeSearchReactor: Reactor { extension BeforeSearchReactor { func fetchRecommend() -> Observable { return .concat([ + .just(.updateLoadingState(true)), fetchRecommendPlayListUseCase .execute() .asObservable() diff --git a/Projects/Features/SearchFeature/Sources/Reactors/WakmusicRecommendReactor.swift b/Projects/Features/SearchFeature/Sources/Reactors/WakmusicRecommendReactor.swift new file mode 100644 index 000000000..34d6f0d87 --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/Reactors/WakmusicRecommendReactor.swift @@ -0,0 +1,76 @@ +import Foundation +import LogManager +import PlayListDomainInterface +import ReactorKit +import RxSwift + +final class WakmusicRecommendReactor: Reactor { + private var disposeBag: DisposeBag = DisposeBag() + private var fetchRecommendPlayListUseCase: any FetchRecommendPlayListUseCase + + var initialState: State + + enum Action { + case viewDidLoad + } + + enum Mutation { + case updateDataSource([RecommendPlayListEntity]) + case updateLodingState(Bool) + } + + struct State { + var dataSource: [RecommendPlayListEntity] + var isLoading: Bool + } + + func mutate(action: Action) -> Observable { + switch action { + case .viewDidLoad: + return updateDataSource() + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case let .updateDataSource(dataSource): + newState.dataSource = dataSource + case let .updateLodingState(isLoading): + newState.isLoading = isLoading + } + + return newState + } + + init(fetchRecommendPlayListUseCase: any FetchRecommendPlayListUseCase) { + LogManager.printDebug("✅ \(Self.self)") + self.fetchRecommendPlayListUseCase = fetchRecommendPlayListUseCase + self.initialState = State( + dataSource: [], + isLoading: true + ) + } + + deinit { + LogManager.printDebug("❌ \(Self.self)") + } +} + +extension WakmusicRecommendReactor { + func updateDataSource() -> Observable { + return .concat([ + .just(.updateLodingState(true)), + fetchRecommendPlayListUseCase + .execute() + .asObservable() + .map { Mutation.updateDataSource($0) }, + .just(.updateLodingState(false)) + ]) + } + + func updateLoadnigState(isLoading: Bool) -> Observable { + return .just(.updateLodingState(isLoading)) + } +} diff --git a/Projects/Features/SearchFeature/Sources/ViewControllers/BeforeSearchContentViewController.swift b/Projects/Features/SearchFeature/Sources/ViewControllers/BeforeSearchContentViewController.swift index 7c7713737..681946c71 100644 --- a/Projects/Features/SearchFeature/Sources/ViewControllers/BeforeSearchContentViewController.swift +++ b/Projects/Features/SearchFeature/Sources/ViewControllers/BeforeSearchContentViewController.swift @@ -98,6 +98,7 @@ public final class BeforeSearchContentViewController: BaseReactorViewController< let sharedState = reactor.state.share(replay: 2) sharedState.map(\.isLoading) + .distinctUntilChanged() .withUnretained(self) .bind(onNext: { onwer, isLoading in if isLoading { @@ -110,6 +111,7 @@ public final class BeforeSearchContentViewController: BaseReactorViewController< // 검색 전, 최근 검색어 스위칭 sharedState.map(\.showRecommend) + .distinctUntilChanged() .withUnretained(self) .bind { owner, flag in owner.tableView.isHidden = flag @@ -311,7 +313,7 @@ extension BeforeSearchContentViewController { // MARK: CollectionView Deleagte extension BeforeSearchContentViewController: UICollectionViewDelegate { public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard let model = dataSource.itemIdentifier(for: indexPath) as? BeforeVcDataSoruce else { + guard let model = dataSource.itemIdentifier(for: indexPath) else { return } diff --git a/Projects/Features/SearchFeature/Sources/ViewControllers/WakmusicRecommendViewController.swift b/Projects/Features/SearchFeature/Sources/ViewControllers/WakmusicRecommendViewController.swift new file mode 100644 index 000000000..6130701dc --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/ViewControllers/WakmusicRecommendViewController.swift @@ -0,0 +1,153 @@ +import BaseFeature +import DesignSystem +import LogManager +import PlayListDomainInterface +import PlaylistFeatureInterface +import UIKit +import Utility + +final class WakmusicRecommendViewController: BaseReactorViewController { + private let wmNavigationbarView = WMNavigationBarView().then { + $0.setTitle("Hello") + } + + private let dismissButton = UIButton().then { + let dismissImage = DesignSystemAsset.Navigation.back.image + $0.setImage(dismissImage, for: .normal) + } + + private lazy var collectionView: UICollectionView = createCollectionView().then { + $0.backgroundColor = .black + } + + private lazy var dataSource: UICollectionViewDiffableDataSource = + createDataSource() + + private let playlistDetailFactory: any PlaylistDetailFactory + + init(playlistDetailFactory: any PlaylistDetailFactory, reactor: WakmusicRecommendReactor) { + self.playlistDetailFactory = playlistDetailFactory + super.init(reactor: reactor) + } + + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .white + reactor?.action.onNext(.viewDidLoad) + } + + override func addView() { + super.addView() + self.view.addSubviews(wmNavigationbarView, collectionView) + } + + override func setLayout() { + super.setLayout() + + wmNavigationbarView.snp.makeConstraints { + $0.top.equalToSuperview().offset(STATUS_BAR_HEGHIT()) + $0.horizontalEdges.equalToSuperview() + $0.height.equalTo(48) + } + wmNavigationbarView.setLeftViews([dismissButton]) + + collectionView.snp.makeConstraints { + $0.top.equalTo(wmNavigationbarView.snp.bottom) + $0.horizontalEdges.equalToSuperview() + $0.bottom.equalToSuperview() + } + } + + override func bind(reactor: WakmusicRecommendReactor) { + super.bind(reactor: reactor) + collectionView.delegate = self + } + + override func bindState(reactor: WakmusicRecommendReactor) { + super.bindState(reactor: reactor) + let sharedState = reactor.state.share() + + sharedState.map(\.dataSource) + .distinctUntilChanged() + .withUnretained(self) + .bind(onNext: { owner, recommendPlaylist in + + var snapShot = owner.dataSource.snapshot(for: .main) + snapShot.append(recommendPlaylist) + owner.dataSource.apply(snapShot, to: .main) + + }) + .disposed(by: disposeBag) + + sharedState.map(\.isLoading) + .distinctUntilChanged() + .withUnretained(self) + .bind { owner, isLoading in + if isLoading { + owner.indicator.startAnimating() + } else { + owner.indicator.stopAnimating() + } + } + .disposed(by: disposeBag) + } +} + +extension WakmusicRecommendViewController { + private func createCollectionView() -> UICollectionView { + return UICollectionView(frame: .zero, collectionViewLayout: RecommendCollectionViewLayout()) + } + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let recommendCellRegistration = UICollectionView.CellRegistration< + RecommendPlayListCell, + RecommendPlayListEntity + >(cellNib: UINib( + nibName: "RecommendPlayListCell", + bundle: BaseFeatureResources.bundle + )) { cell, indexPath, item in + cell.update( + model: item + ) + } + let dataSource = UICollectionViewDiffableDataSource< + RecommendSection, + RecommendPlayListEntity + >(collectionView: collectionView) { + ( + collectionView: UICollectionView, indexPath: IndexPath, item: RecommendPlayListEntity + ) -> UICollectionViewCell? in + + return + collectionView.dequeueConfiguredReusableCell( + using: recommendCellRegistration, + for: indexPath, + item: item + ) + } + + return dataSource + } + + private func initDataSource() { + // initial data + var snapshot = NSDiffableDataSourceSnapshot() + + snapshot.appendSections([.main]) + + dataSource.apply(snapshot, animatingDifferences: false) + } +} + +extension WakmusicRecommendViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let model = dataSource.itemIdentifier(for: indexPath) else { + return + } + + self.navigationController?.pushViewController( + playlistDetailFactory.makeView(id: model.key, isCustom: false), + animated: true + ) + } +} diff --git a/Projects/Features/SearchFeature/Tests/TargetTests.swift b/Projects/Features/SearchFeature/Tests/WakmusicRecommendTests.swift similarity index 85% rename from Projects/Features/SearchFeature/Tests/TargetTests.swift rename to Projects/Features/SearchFeature/Tests/WakmusicRecommendTests.swift index 147c29d2f..792c6e875 100644 --- a/Projects/Features/SearchFeature/Tests/TargetTests.swift +++ b/Projects/Features/SearchFeature/Tests/WakmusicRecommendTests.swift @@ -1,6 +1,8 @@ +import Nimble +import Quick import XCTest -class TargetTests: XCTestCase { +class WakmusicRecommendTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. }