From b8b59c959c2cc017b11c449c0294040b00a4b3e8 Mon Sep 17 00:00:00 2001 From: mini-min <2alswo7@khu.ac.kr> Date: Thu, 12 Dec 2024 18:03:36 +0900 Subject: [PATCH 1/5] [Refactor] #237 - Refactoring EditClip Scene --- .../View/EditClipViewController.swift | 203 ++++++++------ .../ViewModel/EditClipViewModel.swift | 252 +++++++++++------- 2 files changed, 277 insertions(+), 178 deletions(-) diff --git a/TOASTER-iOS/Present/EditClip/View/EditClipViewController.swift b/TOASTER-iOS/Present/EditClip/View/EditClipViewController.swift index 749f8362..1c6a7097 100644 --- a/TOASTER-iOS/Present/EditClip/View/EditClipViewController.swift +++ b/TOASTER-iOS/Present/EditClip/View/EditClipViewController.swift @@ -5,6 +5,7 @@ // Created by 민 on 1/11/24. // +import Combine import UIKit import SnapKit @@ -12,27 +13,39 @@ import Then final class EditClipViewController: UIViewController { + // MARK: - Data Stream + + private let viewModel = EditClipViewModel() + private let cancelBag = CancelBag() + + private var requestClipList = PassthroughSubject() + private var requestDeleteClip = PassthroughSubject() + private var requestEditPriorityClip = PassthroughSubject() + // MARK: - UI Properties - private let viewModel = EditClipViewModel() private let editClipNoticeView = EditClipNoticeView() - private let editClipCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + private let editClipCollectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewFlowLayout() + ) private let editClipBottomSheetView = AddClipBottomSheetView() - private lazy var editClipBottom = ToasterBottomSheetViewController(bottomType: .white, - bottomTitle: "클립 이름 수정", - insertView: editClipBottomSheetView) + private lazy var editClipBottom = ToasterBottomSheetViewController( + bottomType: .white, + bottomTitle: "클립 이름 수정", + insertView: editClipBottomSheetView + ) // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() - + bindViewModels() setupStyle() setupHierarchy() setupLayout() setupDelegate() - setupViewModel() } override func viewWillAppear(_ animated: Bool) { @@ -52,6 +65,76 @@ extension EditClipViewController { // MARK: - Private Extensions private extension EditClipViewController { + func bindViewModels() { + let textFieldValueChanged = editClipBottomSheetView.textFieldValueChanged + .compactMap { ($0.object as? UITextField)?.text } + .asDriver() + + let changeClipButtonTapped = editClipBottomSheetView.addClipButtonTap + .compactMap { _ -> ClipNameEditModel? in + let id = self.viewModel.clipList.clips[self.viewModel.cellIndex].id + guard let text = self.editClipBottomSheetView.addClipTextField.text else { return nil } + return ClipNameEditModel(id: id, title: text) + } + .asDriver() + + let input = EditClipViewModel.Input( + requestClipList: requestClipList.asDriver(), + deleteClipButtonTapped: requestDeleteClip.asDriver(), + clipNameChanged: textFieldValueChanged, + changeClipNameButtonTapped: changeClipButtonTapped, + clipOrderedChanged: requestEditPriorityClip.asDriver() + ) + + let output = viewModel.transform(input, cancelBag: cancelBag) + + output.needToReload + .sink { [weak self] _ in + self?.editClipCollectionView.reloadData() + }.store(in: cancelBag) + + output.deleteClipResult + .sink { [weak self] _ in + self?.requestClipList.send() + self?.dismiss(animated: false) { + self?.showToastMessage( + width: 152, + status: .check, + message: StringLiterals.ToastMessage.completeDeleteClip + ) + } + }.store(in: cancelBag) + + output.duplicateClipName + .sink { [weak self] isDuplicate in + if isDuplicate { + self?.addHeightBottom() + self?.editClipBottomSheetView.changeTextField( + addButton: false, + border: true, + error: true, + clearButton: true + ) + self?.editClipBottomSheetView.setupMessage(message: "이미 같은 이름의 클립이 있어요") + } else { + self?.minusHeightBottom() + } + }.store(in: cancelBag) + + output.changeClipNameResult + .sink { [weak self] _ in + self?.requestClipList.send() + self?.dismiss(animated: true) { + self?.showToastMessage( + width: 157, + status: .check, + message: StringLiterals.ToastMessage.completeEditClip + ) + self?.editClipBottomSheetView.resetTextField() + } + }.store(in: cancelBag) + } + func setupStyle() { editClipCollectionView.do { $0.backgroundColor = .toasterBackground @@ -82,7 +165,13 @@ private extension EditClipViewController { } func setupNavigationBar() { - let type: ToasterNavigationType = ToasterNavigationType(hasBackButton: true, hasRightButton: false, mainTitle: StringOrImageType.string("CLIP 편집"), rightButton: StringOrImageType.string(""), rightButtonAction: {}) + let type: ToasterNavigationType = ToasterNavigationType( + hasBackButton: true, + hasRightButton: false, + mainTitle: StringOrImageType.string("CLIP 편집"), + rightButton: StringOrImageType.string(""), + rightButtonAction: {} + ) if let navigationController = navigationController as? ToasterNavigationController { navigationController.setupNavigationBar(forType: type) @@ -95,49 +184,6 @@ private extension EditClipViewController { editClipCollectionView.dragDelegate = self editClipCollectionView.dropDelegate = self } - - func setupViewModel() { - viewModel.setupDataChangeAction(changeAction: reloadCollectionView, - moveAction: moveBottomAction, - deleteAction: deleteClipAction, - editNameAction: editClipNameAction, - forUnAuthorizedAction: unAuthorizedAction) - } - - func reloadCollectionView() { - editClipCollectionView.reloadData() - } - - func moveBottomAction(isDuplicated: Bool) { - if isDuplicated { - addHeightBottom() - editClipBottomSheetView.changeTextField(addButton: false, border: true, error: true, clearButton: true) - editClipBottomSheetView.setupMessage(message: "이미 같은 이름의 클립이 있어요") - } else { - minusHeightBottom() - } - } - - func deleteClipAction() { - dismiss(animated: false) { - self.showToastMessage(width: 152, - status: .check, - message: StringLiterals.ToastMessage.completeDeleteClip) - } - } - - func editClipNameAction() { - showToastMessage(width: 157, status: .check, message: StringLiterals.ToastMessage.completeEditClip) - editClipBottomSheetView.resetTextField() - } - - func unAuthorizedAction() { - changeViewController(viewController: LoginViewController()) - } - - func popupDeleteButtonTapped(categoryID: Int, index: Int) { - viewModel.deleteCategoryAPI(deleteCategoryDto: categoryID) - } } // MARK: - CollectionView DataSource @@ -149,23 +195,27 @@ extension EditClipViewController: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: EditClipCollectionViewCell.className, for: indexPath) as? EditClipCollectionViewCell else { return UICollectionViewCell() } + if indexPath.row == 0 { - cell.configureCell(forModel: AllClipModel(id: 0, - title: "전체 클립", - toastCount: 0), - icon: .icPin24, - isFirst: true) + cell.configureCell( + forModel: AllClipModel(id: 0, title: "전체 클립", toastCount: 0), + icon: .icPin24, + isFirst: true) } else { - cell.configureCell(forModel: viewModel.clipList.clips[indexPath.item - 1], - icon: .icDelete28, - isFirst: false) + cell.configureCell( + forModel: viewModel.clipList.clips[indexPath.item - 1], + icon: .icDelete28, + isFirst: false + ) cell.leadingButtonTapped { - self.showPopup(forMainText: "‘\(self.viewModel.clipList.clips[indexPath.item - 1].title)’ 클립을 삭제하시겠어요?", - forSubText: "지금까지 저장된 모든 링크가 사라져요", - forLeftButtonTitle: StringLiterals.Button.close, - forRightButtonTitle: StringLiterals.Button.delete, - forRightButtonHandler: { self.popupDeleteButtonTapped(categoryID: self.viewModel.clipList.clips[indexPath.item - 1].id, - index: indexPath.item - 1) }) + self.showPopup( + forMainText: "‘\(self.viewModel.clipList.clips[indexPath.item - 1].title)’ 클립을 삭제하시겠어요?", + forSubText: "지금까지 저장된 모든 링크가 사라져요", + forLeftButtonTitle: StringLiterals.Button.close, + forRightButtonTitle: StringLiterals.Button.delete, + forRightButtonHandler: { self.requestDeleteClip.send(self.viewModel.clipList.clips[indexPath.item-1].id) + } + ) } cell.changeTitleButtonTapped { self.viewModel.cellIndex = indexPath.item - 1 @@ -235,18 +285,19 @@ extension EditClipViewController: UICollectionViewDropDelegate { // 0번째 인덱스 드랍이 아닌 경우, 배열과 컬뷰 아이템 삭제, 삽입, reload까지 진행 if destinationIndexPath.item != 0 { guard let item = coordinator.items.first, let sourceIndexPath = item.sourceIndexPath else { return } + collectionView.performBatchUpdates { let sourceItem = viewModel.clipList.clips.remove(at: sourceIndexPath.item - 1) viewModel.clipList.clips.insert(sourceItem, at: destinationIndexPath.item - 1) collectionView.deleteItems(at: [sourceIndexPath]) collectionView.insertItems(at: [destinationIndexPath]) coordinator.drop(item.dragItem, toItemAt: destinationIndexPath) - self.viewModel.patchEditPriorityCategoryAPI( - requestBody: ClipPriorityEditModel( - id: self.viewModel.clipList.clips[destinationIndexPath.item - 1].id, - priority: destinationIndexPath.item - 1 - ) + + let model = ClipPriorityEditModel( + id: self.viewModel.clipList.clips[destinationIndexPath.item - 1].id, + priority: destinationIndexPath.item - 1 ) + requestEditPriorityClip.send(model) } } } @@ -255,10 +306,6 @@ extension EditClipViewController: UICollectionViewDropDelegate { // MARK: - AddClipBottomSheetView Delegate extension EditClipViewController: AddClipBottomSheetViewDelegate { - func callCheckAPI(text: String) { - viewModel.getCheckCategoryAPI(categoryTitle: text) - } - func addHeightBottom() { editClipBottom.setupSheetHeightChanges(bottomHeight: 219) } @@ -266,12 +313,4 @@ extension EditClipViewController: AddClipBottomSheetViewDelegate { func minusHeightBottom() { editClipBottom.setupSheetHeightChanges(bottomHeight: 198) } - - func dismissButtonTapped(title: String) { - dismiss(animated: true) - viewModel.patchEditaNameCategoryAPI( - requestBody: ClipNameEditModel(id: viewModel.clipList.clips[viewModel.cellIndex].id, - title: title) - ) - } } diff --git a/TOASTER-iOS/Present/EditClip/ViewModel/EditClipViewModel.swift b/TOASTER-iOS/Present/EditClip/ViewModel/EditClipViewModel.swift index 53d07621..06441222 100644 --- a/TOASTER-iOS/Present/EditClip/ViewModel/EditClipViewModel.swift +++ b/TOASTER-iOS/Present/EditClip/ViewModel/EditClipViewModel.swift @@ -5,126 +5,186 @@ // Created by 민 on 2/8/24. // -import Foundation +import Combine +import UIKit -final class EditClipViewModel: NSObject { +final class EditClipViewModel: ViewModelType { - // MARK: - Properties + private var cancelBag = CancelBag() + var cellIndex: Int = 0 + var clipList: ClipModel = ClipModel(allClipToastCount: 0, clips: []) - typealias DataChangeAction = (Bool) -> Void - private var moveBottomAction: DataChangeAction? + // MARK: - Input State - typealias NormalChangeAction = () -> Void - private var dataChangeAction: NormalChangeAction? - private var deleteClipAction: NormalChangeAction? - private var editClipNameAction: NormalChangeAction? - private var unAuthorizedAction: NormalChangeAction? + struct Input { + let requestClipList: Driver + let deleteClipButtonTapped: Driver + let clipNameChanged: Driver + let changeClipNameButtonTapped: Driver + let clipOrderedChanged: Driver + } - // MARK: - Data + // MARK: - Output State - var cellIndex: Int = 0 - var clipList: ClipModel = ClipModel(allClipToastCount: 0, - clips: []) { - didSet { - dataChangeAction?() - } + struct Output { + let needToReload = PassthroughSubject() + let deleteClipResult = PassthroughSubject() + let duplicateClipName = PassthroughSubject() + let changeClipNameResult = PassthroughSubject() + } + + // MARK: - Cancellable Bag + + private var cancellables = Set() + + // MARK: - Method + + func transform(_ input: Input, cancelBag: CancelBag) -> Output { + let output = Output() + + input.requestClipList + .networkFlatMap(self) { context, _ in + context.getAllCategoryAPI() + } + .sink { [weak self] clipList in + self?.clipList = clipList + output.needToReload.send() + }.store(in: cancelBag) + + input.deleteClipButtonTapped + .networkFlatMap(self) { context, clipID in + context.deleteCategoryAPI(deleteCategoryDto: clipID) + } + .sink { _ in + output.deleteClipResult.send() + output.needToReload.send() + }.store(in: cancelBag) + + input.clipNameChanged + .debounce(for: 0.2, scheduler: RunLoop.main) + .removeDuplicates() + .networkFlatMap(self) { context, clipTitle in + context.getCheckCategoryAPI(categoryTitle: clipTitle) + } + .sink { isDuplicated in + output.duplicateClipName.send(isDuplicated) + }.store(in: cancelBag) + + input.changeClipNameButtonTapped + .networkFlatMap(self) { context, model in + context.patchEditNameCategoryAPI(requestBody: model) + } + .sink { isSuccess in + output.changeClipNameResult.send(isSuccess) + }.store(in: cancelBag) + + input.clipOrderedChanged + .networkFlatMap(self) { context, model in + context.patchEditPriorityCategoryAPI(requestBody: model) + } + .sink { _ in + output.needToReload.send() + }.store(in: cancelBag) + + return output } } -// MARK: - Extensions +// MARK: - Network -extension EditClipViewModel { - func setupDataChangeAction(changeAction: @escaping NormalChangeAction, - moveAction: @escaping DataChangeAction, - deleteAction: @escaping NormalChangeAction, - editNameAction: @escaping NormalChangeAction, - forUnAuthorizedAction: @escaping NormalChangeAction) { - dataChangeAction = changeAction - moveBottomAction = moveAction - deleteClipAction = deleteAction - editClipNameAction = editNameAction - unAuthorizedAction = forUnAuthorizedAction - } - - func getAllCategoryAPI() { - NetworkService.shared.clipService.getAllCategory { result in - switch result { - case .success(let response): - let allClipToastCount = response?.data.toastNumberInEntire - let clips = response?.data.categories.map { - AllClipModel(id: $0.categoryId, - title: $0.categoryTitle, - toastCount: $0.toastNum) +private extension EditClipViewModel { + func getAllCategoryAPI() -> AnyPublisher { + return Future { promise in + NetworkService.shared.clipService.getAllCategory { result in + switch result { + case .success(let response): + let allClipToastCount = response?.data.toastNumberInEntire + let clips = response?.data.categories.map { + AllClipModel(id: $0.categoryId, + title: $0.categoryTitle, + toastCount: $0.toastNum) + } + promise(.success( + ClipModel(allClipToastCount: allClipToastCount ?? 0, clips: clips ?? []) + )) + case .unAuthorized, .networkFail, .notFound: + promise(.failure(NetworkResult.unAuthorized)) + default: + return } - self.clipList = ClipModel(allClipToastCount: allClipToastCount ?? 0, - clips: clips ?? []) - case .unAuthorized, .networkFail, .notFound: - self.unAuthorizedAction?() - default: return } - } + }.eraseToAnyPublisher() } - func deleteCategoryAPI(deleteCategoryDto: Int) { - NetworkService.shared.clipService.deleteCategory( - deleteCategoryDto: deleteCategoryDto) { result in - switch result { - case .success: - self.getAllCategoryAPI() - self.deleteClipAction?() - case .unAuthorized, .networkFail, .notFound: - self.unAuthorizedAction?() - default: return + func deleteCategoryAPI(deleteCategoryDto: Int) -> AnyPublisher { + return Future { promise in + NetworkService.shared.clipService.deleteCategory(deleteCategoryDto: deleteCategoryDto) { result in + switch result { + case .success: + promise(.success(())) + case .unAuthorized, .networkFail, .notFound: + promise(.failure(NetworkResult.unAuthorized)) + default: + return + } } - } + }.eraseToAnyPublisher() } - func patchEditPriorityCategoryAPI(requestBody: ClipPriorityEditModel) { - NetworkService.shared.clipService.patchEditPriorityCategory( - requestBody: PatchEditPriorityCategoryRequestDTO( - categoryId: requestBody.id, - newPriority: requestBody.priority)) { [weak self] result in - switch result { - case .success: - self?.dataChangeAction?() - case .unAuthorized, .networkFail, .notFound: - self?.unAuthorizedAction?() - default: return + func patchEditPriorityCategoryAPI(requestBody: ClipPriorityEditModel) -> AnyPublisher { + return Future { promise in + NetworkService.shared.clipService.patchEditPriorityCategory( + requestBody: PatchEditPriorityCategoryRequestDTO( + categoryId: requestBody.id, + newPriority: requestBody.priority + ) + ) { result in + switch result { + case .success: + promise(.success(())) + case .unAuthorized, .networkFail, .notFound: + promise(.failure(NetworkResult.unAuthorized)) + default: + return + } } - } + }.eraseToAnyPublisher() } - - func patchEditaNameCategoryAPI(requestBody: ClipNameEditModel) { - NetworkService.shared.clipService.patchEditNameCategory( - requestBody: PatchEditNameCategoryRequestDTO( - categoryId: requestBody.id, - newTitle: requestBody.title)) { result in - switch result { - case .success: - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.editClipNameAction?() + + func patchEditNameCategoryAPI(requestBody: ClipNameEditModel) -> AnyPublisher { + return Future { promise in + NetworkService.shared.clipService.patchEditNameCategory( + requestBody: PatchEditNameCategoryRequestDTO( + categoryId: requestBody.id, + newTitle: requestBody.title + ) + ) { result in + switch result { + case .success: + promise(.success(true)) + case .unAuthorized, .networkFail, .notFound: + promise(.failure(NetworkResult.unAuthorized)) + default: + return } - self.getAllCategoryAPI() - case .unAuthorized, .networkFail, .notFound: - self.unAuthorizedAction?() - default: return } - } + }.eraseToAnyPublisher() } - - func getCheckCategoryAPI(categoryTitle: String) { - NetworkService.shared.clipService.getCheckCategory(categoryTitle: categoryTitle) { result in - switch result { - case .success(let response): - if let data = response?.data.isDupicated { - if categoryTitle.count != 16 { - self.moveBottomAction?(data) + + func getCheckCategoryAPI(categoryTitle: String) -> AnyPublisher { + return Future { promise in + NetworkService.shared.clipService.getCheckCategory(categoryTitle: categoryTitle) { result in + switch result { + case .success(let response): + if let data = response?.data.isDupicated, categoryTitle.count < 16 { + promise(.success(data)) } + case .unAuthorized, .networkFail, .notFound: + promise(.failure(NetworkResult.unAuthorized)) + default: + return } - case .unAuthorized, .networkFail, .notFound: - self.unAuthorizedAction?() - default: return } - } + }.eraseToAnyPublisher() } } From 8e32ab5ea7a4a0cc7d4f11782e47d16a6a23102a Mon Sep 17 00:00:00 2001 From: mini-min <2alswo7@khu.ac.kr> Date: Thu, 19 Dec 2024 14:51:16 +0900 Subject: [PATCH 2/5] [Refactor] #237 - Cleanup EditClip Code --- .../EditClip/View/EditClipViewController.swift | 2 +- .../EditClip/ViewModel/EditClipViewModel.swift | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/TOASTER-iOS/Present/EditClip/View/EditClipViewController.swift b/TOASTER-iOS/Present/EditClip/View/EditClipViewController.swift index 1c6a7097..c50b3560 100644 --- a/TOASTER-iOS/Present/EditClip/View/EditClipViewController.swift +++ b/TOASTER-iOS/Present/EditClip/View/EditClipViewController.swift @@ -218,7 +218,7 @@ extension EditClipViewController: UICollectionViewDataSource { ) } cell.changeTitleButtonTapped { - self.viewModel.cellIndex = indexPath.item - 1 + self.viewModel.setupCellIndex(indexPath.item - 1) self.editClipBottom.setupSheetPresentation(bottomHeight: 198) self.present(self.editClipBottom, animated: true) self.editClipBottomSheetView.setupTextField(message: self.viewModel.clipList.clips[indexPath.item - 1].title) diff --git a/TOASTER-iOS/Present/EditClip/ViewModel/EditClipViewModel.swift b/TOASTER-iOS/Present/EditClip/ViewModel/EditClipViewModel.swift index 06441222..ce9bf542 100644 --- a/TOASTER-iOS/Present/EditClip/ViewModel/EditClipViewModel.swift +++ b/TOASTER-iOS/Present/EditClip/ViewModel/EditClipViewModel.swift @@ -11,7 +11,8 @@ import UIKit final class EditClipViewModel: ViewModelType { private var cancelBag = CancelBag() - var cellIndex: Int = 0 + + private(set) var cellIndex: Int = 0 var clipList: ClipModel = ClipModel(allClipToastCount: 0, clips: []) // MARK: - Input State @@ -90,6 +91,14 @@ final class EditClipViewModel: ViewModelType { } } +// MARK: - Extension + +extension EditClipViewModel { + func setupCellIndex(_ index: Int) { + cellIndex = index + } +} + // MARK: - Network private extension EditClipViewModel { From 5c228ada3d8619d17c9fca6a014a1bc926cbbb1f Mon Sep 17 00:00:00 2001 From: mini-min <2alswo7@khu.ac.kr> Date: Thu, 19 Dec 2024 16:00:40 +0900 Subject: [PATCH 3/5] [Refactor] #237 - Exclude moving clips code --- .../DetailClipSegmentedControlView.swift | 31 +- .../Component/EditLinkBottomSheetView.swift | 93 ++- .../View/DetailClipViewController.swift | 405 ++++++------- .../ViewModel/DetailClipViewModel.swift | 566 ++++++++++++++---- 4 files changed, 690 insertions(+), 405 deletions(-) diff --git a/TOASTER-iOS/Present/DetailClip/View/Component/DetailClipSegmentedControlView.swift b/TOASTER-iOS/Present/DetailClip/View/Component/DetailClipSegmentedControlView.swift index 2fc062f0..d6aa2c90 100644 --- a/TOASTER-iOS/Present/DetailClip/View/Component/DetailClipSegmentedControlView.swift +++ b/TOASTER-iOS/Present/DetailClip/View/Component/DetailClipSegmentedControlView.swift @@ -10,31 +10,22 @@ import UIKit import SnapKit import Then -protocol DetailClipSegmentedDelegate: AnyObject { - func setupAllLink() - func setupReadLink() - func setupNotReadLink() -} - final class DetailClipSegmentedControlView: UIView { - // MARK: - Properties - - var detailClipSegmentedDelegate: DetailClipSegmentedDelegate? - // MARK: - UI Components private let readSegmentedControl = UISegmentedControl() + lazy var readSegmentedControlValueChanged = readSegmentedControl + .publisher(for: .valueChanged) + .map { _ in self.readSegmentedControl.selectedSegmentIndex } // MARK: - Life Cycles override init(frame: CGRect) { super.init(frame: frame) - setupStyle() setupHierarchy() setupLayout() - setupAddTarget() } @available(*, unavailable) @@ -76,20 +67,4 @@ private extension DetailClipSegmentedControlView { $0.leading.trailing.equalToSuperview().inset(20) } } - - func setupAddTarget() { - readSegmentedControl.addTarget(self, action: #selector(didChangeValue(_:)), for: .valueChanged) - } - - @objc - func didChangeValue(_ segment: UISegmentedControl) { - switch segment.selectedSegmentIndex { - case 0: - detailClipSegmentedDelegate?.setupAllLink() - case 1: - detailClipSegmentedDelegate?.setupReadLink() - default: - detailClipSegmentedDelegate?.setupNotReadLink() - } - } } diff --git a/TOASTER-iOS/Present/DetailClip/View/Component/EditLinkBottomSheetView.swift b/TOASTER-iOS/Present/DetailClip/View/Component/EditLinkBottomSheetView.swift index 4f74513e..146aff4c 100644 --- a/TOASTER-iOS/Present/DetailClip/View/Component/EditLinkBottomSheetView.swift +++ b/TOASTER-iOS/Present/DetailClip/View/Component/EditLinkBottomSheetView.swift @@ -10,19 +10,12 @@ import UIKit import SnapKit import Then -protocol EditLinkBottomSheetViewDelegate: AnyObject { - func dismissButtonTapped(title: String) - func addHeightBottom() - func minusHeightBottom() - func callCheckAPI(filter: DetailCategoryFilter) -} - final class EditLinkBottomSheetView: UIView { // MARK: - Properties - weak var editLinkBottomSheetViewDelegate: EditLinkBottomSheetViewDelegate? - private var confirmBottomSheetViewButtonAction: (() -> Void)? + // weak var editLinkBottomSheetViewDelegate: EditLinkBottomSheetViewDelegate? +// private var confirmBottomSheetViewButtonAction: (() -> Void)? private var isButtonClicked: Bool = false { didSet { @@ -30,17 +23,17 @@ final class EditLinkBottomSheetView: UIView { } } - private var isBorderColor: Bool = false { - didSet { - setupTextFieldBorder() - } - } +// private var isBorderColor: Bool = false { +// didSet { +// setupTextFieldBorder() +// } +// } - private var isError: Bool = false { - didSet { - setupErrorMessage() - } - } +// private var isError: Bool = false { +// didSet { +// setupErrorMessage() +// } +// } private var isClearButtonShow: Bool = true { didSet { @@ -50,11 +43,13 @@ final class EditLinkBottomSheetView: UIView { // MARK: - UI Components - let editClipTitleTextField = UITextField() + private(set) var editClipTitleTextField = UITextField() private let editClipButton = UIButton() private let errorMessage = UILabel() private let clearButton = UIButton() + lazy var editClipButtonTap = editClipButton.publisher(for: .touchUpInside) + // MARK: - Life Cycles override init(frame: CGRect) { @@ -87,8 +82,8 @@ extension EditLinkBottomSheetView { func changeTextField(addButton: Bool, border: Bool, error: Bool, clearButton: Bool) { isButtonClicked = addButton - isBorderColor = border - isError = error +// isBorderColor = border +// isError = error isClearButtonShow = clearButton } @@ -101,9 +96,9 @@ extension EditLinkBottomSheetView { editClipTitleTextField.placeholder = message } - func setupConfirmBottomSheetButtonAction(_ action: (() -> Void)?) { - confirmBottomSheetViewButtonAction = action - } +// func setupConfirmBottomSheetButtonAction(_ action: (() -> Void)?) { +// confirmBottomSheetViewButtonAction = action +// } } // MARK: - Private Extensions @@ -130,7 +125,7 @@ private extension EditLinkBottomSheetView { $0.setTitle(StringLiterals.Button.okay, for: .normal) $0.setTitleColor(.toasterWhite, for: .normal) $0.titleLabel?.font = .suitBold(size: 16) - $0.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + // $0.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) } errorMessage.do { @@ -192,25 +187,25 @@ private extension EditLinkBottomSheetView { } } - func setupTextFieldBorder() { - if isBorderColor { - editClipTitleTextField.layer.borderColor = UIColor.toasterError.cgColor - editClipTitleTextField.layer.borderWidth = 1.0 - } else { - editClipTitleTextField.layer.borderColor = UIColor.clear.cgColor - editClipTitleTextField.layer.borderWidth = 0.0 - } - } +// func setupTextFieldBorder() { +// if isBorderColor { +// editClipTitleTextField.layer.borderColor = UIColor.toasterError.cgColor +// editClipTitleTextField.layer.borderWidth = 1.0 +// } else { +// editClipTitleTextField.layer.borderColor = UIColor.clear.cgColor +// editClipTitleTextField.layer.borderWidth = 0.0 +// } +// } - func setupErrorMessage() { - if isError { - editLinkBottomSheetViewDelegate?.addHeightBottom() - errorMessage.isHidden = false - } else { - editLinkBottomSheetViewDelegate?.minusHeightBottom() - errorMessage.isHidden = true - } - } +// func setupErrorMessage() { +// if isError { +// editLinkBottomSheetViewDelegate?.addHeightBottom() +// errorMessage.isHidden = false +// } else { +// editLinkBottomSheetViewDelegate?.minusHeightBottom() +// errorMessage.isHidden = true +// } +// } func setupClearButton() { if isClearButtonShow { @@ -220,11 +215,11 @@ private extension EditLinkBottomSheetView { } } - @objc - func buttonTapped() { - confirmBottomSheetViewButtonAction?() - editLinkBottomSheetViewDelegate?.dismissButtonTapped(title: editClipTitleTextField.text ?? "") - } +// @objc +// func buttonTapped() { +// confirmBottomSheetViewButtonAction?() +// editLinkBottomSheetViewDelegate?.dismissButtonTapped(title: editClipTitleTextField.text ?? "") +// } @objc func clearButtonTapped() { diff --git a/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift b/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift index dd28b278..d506398e 100644 --- a/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift +++ b/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift @@ -13,30 +13,51 @@ import Then final class DetailClipViewController: UIViewController { - var indexNumber: Int? + // MARK: - Data Stream + + private let viewModel = DetailClipViewModel() + private var cancelBag = CancelBag() + + private var requestToastList = PassthroughSubject() + + private let requestClipList = PassthroughSubject() + private let selectedClipSubject = PassthroughSubject() + private let completeButtonSubject = PassthroughSubject() + private let requestDeleteToast = PassthroughSubject() + + private var indexNumber: Int? // MARK: - UI Properties - private let viewModel = DetailClipViewModel() - private let changeClipViewModel = ChangeClipViewModel() private let detailClipSegmentedControlView = DetailClipSegmentedControlView() private let detailClipEmptyView = DetailClipEmptyView() - private let detailClipListCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + private let detailClipListCollectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewFlowLayout() + ) - private lazy var linkOptionBottomSheetView = LinkOptionBottomSheetView(currentClipType: ClipType(categoryId: viewModel.categoryId)) - private lazy var optionBottom = ToasterBottomSheetViewController(bottomType: .gray, - bottomTitle: "더보기", - insertView: linkOptionBottomSheetView) + private lazy var linkOptionBottomSheetView = LinkOptionBottomSheetView( + currentClipType: ClipType(categoryId: viewModel.currentCategoryId) + ) + private lazy var optionBottom = ToasterBottomSheetViewController( + bottomType: .gray, + bottomTitle: "더보기", + insertView: linkOptionBottomSheetView + ) private let editLinkBottomSheetView = EditLinkBottomSheetView() - private lazy var editLinkBottom = ToasterBottomSheetViewController(bottomType: .white, - bottomTitle: "링크 제목 편집", - insertView: editLinkBottomSheetView) + private lazy var editLinkBottom = ToasterBottomSheetViewController( + bottomType: .white, + bottomTitle: "링크 제목 편집", + insertView: editLinkBottomSheetView + ) private let changeClipBottomSheetView = ChangeClipBottomSheetView() - private lazy var changeClipBottom = ToasterBottomSheetViewController(bottomType: .gray, - bottomTitle: "클립을 선택해 주세요", - insertView: changeClipBottomSheetView) + private lazy var changeClipBottom = ToasterBottomSheetViewController( + bottomType: .gray, + bottomTitle: "클립을 선택해 주세요", + insertView: changeClipBottomSheetView + ) private lazy var firstToolTip = ToasterTipView( title: "링크를 다른 클립으로\n이동할 수 있어요!", @@ -44,31 +65,22 @@ final class DetailClipViewController: UIViewController { sourceItem: linkOptionBottomSheetView.changeClipButtonLabel ) - private let changeClipSubject = PassthroughSubject() - private let selectedClipSubject = PassthroughSubject() - private let completeButtonSubject = PassthroughSubject() - - private var cancelBag = CancelBag() - // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() - + bindViewModels() setupStyle() setupHierarchy() setupLayout() setupRegisterCell() setupDelegate() - setupViewModel() - bindViewModels() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - setupNavigationBar() - setupAllLink() + setupToast() } } @@ -76,21 +88,140 @@ final class DetailClipViewController: UIViewController { extension DetailClipViewController { func setupCategory(id: Int, name: String) { - viewModel.categoryId = id - viewModel.categoryName = name - changeClipViewModel.setupCategory(id) + viewModel.setupCategory(id) + viewModel.setupCategoryName(name) } } // MARK: - Private Extensions private extension DetailClipViewController { + func bindViewModels() { + let segmentValueChanged = detailClipSegmentedControlView.readSegmentedControlValueChanged + .asDriver() + + let textFieldValueChanged = editLinkBottomSheetView.editClipButtonTap + .map { _ in + ( + self.viewModel.currentToastId, + self.editLinkBottomSheetView.editClipTitleTextField.text ?? "" + ) + } + .asDriver() + + let input = DetailClipViewModel.Input( + requestToast: requestToastList.asDriver(), + changeSegmentIndex: segmentValueChanged, + editToastTitleButtonTap: textFieldValueChanged, + changeClipButtonTap: requestClipList.asDriver(), + selectedClip: selectedClipSubject.asDriver(), + changeClipCompleteButtonTap: completeButtonSubject.asDriver(), + deleteToastButtonTap: requestDeleteToast.asDriver() + ) + + let output = viewModel.transform(input, cancelBag: cancelBag) + + output.loadToToastList + .receive(on: DispatchQueue.main) + .sink { [weak self] isHidden in + guard let self else { return } + detailClipListCollectionView.reloadData() + detailClipEmptyView.isHidden = isHidden + }.store(in: cancelBag) + + output.toastNameChanged + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + setupToast() + self.editLinkBottomSheetView.resetTextField() + self.dismiss(animated: true) { [weak self] in + self?.showToastMessage( + width: 152, + status: .check, + message: StringLiterals.ToastMessage.completeEditTitle + ) + } + }.store(in: cancelBag) + + output.loadToClipData + .receive(on: DispatchQueue.main) + .sink { [weak self] clipData in + guard let self else { return } + + self.dismiss(animated: true) { + // 이동할 클립이 2개 이상일 때 (전체클립 제외) + if let data = clipData { + self.dismiss(animated: true) { + self.changeClipBottom.setupSheetPresentation(bottomHeight: self.viewModel.collectionViewHeight + 180) + self.present(self.changeClipBottom, animated: true) + } + + self.changeClipBottomSheetView.dataSourceHandler = { data } + self.changeClipBottomSheetView.reloadChangeClipBottom() + + } else { // 현재 클립이 1개 존재할 때 (전체클립 제외) + DispatchQueue.main.asyncAfter(deadline: .now()) { + self.showToastMessage(width: 284, + status: .warning, + message: "이동할 클립을 하나 이상 생성해 주세요") + } + } + } + }.store(in: cancelBag) + + output.deleteToastComplete + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + setupToast() + self.dismiss(animated: true) { [weak self] in + self?.showToastMessage( + width: 152, + status: .check, + message: StringLiterals.ToastMessage.completeDeleteLink + ) + } + }.store(in: cancelBag) + +// +// output.isCompleteButtonEnable +// .receive(on: DispatchQueue.main) +// .sink { [weak self] result in +// self?.changeClipBottomSheetView.updateCompleteButtonUI(result) +// } +// .store(in: cancelBag) +// +// output.changeCategoryResult +// .receive(on: DispatchQueue.main) +// .sink { [weak self] result in +// guard let self else { return } +// if result == true { +// let categoryFilter = DetailCategoryFilter.allCases[viewModel.getViewModelProperty(dataType: .segmentIndex) as? Int ?? 0] +// +// self.changeClipBottom.dismiss(animated: true) { +// if self.viewModel.categoryId == 0 { +// self.viewModel.getDetailAllCategoryAPI(filter: categoryFilter) +// } else { +// self.viewModel.getDetailCategoryAPI(categoryID: self.viewModel.categoryId, filter: categoryFilter) +// } +// } +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { +// self.showToastMessage(width: 152, +// status: .check, +// message: "링크 이동 완료") +// } +// } +// } +// .store(in: cancelBag) + } + func setupStyle() { view.backgroundColor = .toasterBackground detailClipListCollectionView.backgroundColor = .toasterBackground detailClipEmptyView.isHidden = false - editLinkBottomSheetView.editLinkBottomSheetViewDelegate = self - + // editLinkBottomSheetView.editLinkBottomSheetViewDelegate = self } func setupHierarchy() { @@ -124,107 +255,30 @@ private extension DetailClipViewController { func setupDelegate() { detailClipListCollectionView.delegate = self detailClipListCollectionView.dataSource = self - detailClipSegmentedControlView.detailClipSegmentedDelegate = self - viewModel.delegate = self - changeClipBottomSheetView.delegate = self - } - - func setupViewModel() { - viewModel.setupDataChangeAction(changeAction: reloadCollectionView, - forUnAuthorizedAction: unAuthorizedAction, - editNameAction: editLinkTitleAction) - } - - func reloadCollectionView(isHidden: Bool) { - detailClipListCollectionView.reloadData() - detailClipEmptyView.isHidden = isHidden - } - - func unAuthorizedAction() { - changeViewController(viewController: LoginViewController()) - } - - func editLinkTitleAction() { - editLinkBottomSheetView.resetTextField() + + + // changeClipBottomSheetView.delegate = self } func setupNavigationBar() { - let type: ToasterNavigationType = ToasterNavigationType(hasBackButton: true, - hasRightButton: false, - mainTitle: StringOrImageType.string(viewModel.categoryName), - rightButton: StringOrImageType.string("어쩌구"), rightButtonAction: {}) + let type: ToasterNavigationType = ToasterNavigationType( + hasBackButton: true, + hasRightButton: false, + mainTitle: StringOrImageType.string(viewModel.currentCategoryName), + rightButton: StringOrImageType.string("어쩌구"), rightButtonAction: {} + ) if let navigationController = navigationController as? ToasterNavigationController { navigationController.setupNavigationBar(forType: type) } } - func bindViewModels() { - let input = ChangeClipViewModel.Input( - changeButtonTap: changeClipSubject.eraseToAnyPublisher(), - selectedClip: selectedClipSubject.eraseToAnyPublisher(), - completeButtonTap: completeButtonSubject.eraseToAnyPublisher() - ) - - let output = changeClipViewModel.transform(input, cancelBag: cancelBag) - - output.clipData - .receive(on: DispatchQueue.main) - .sink { [weak self] clipData in - guard let self else { return } - - self.dismiss(animated: true) { - // 이동할 클립이 2개 이상일 때 (전체클립 제외) - if let data = clipData { - self.dismiss(animated: true) { - self.changeClipBottom.setupSheetPresentation(bottomHeight: self.changeClipViewModel.collectionViewHeight + 180) - self.present(self.changeClipBottom, animated: true) - } - - self.changeClipBottomSheetView.dataSourceHandler = { data } - self.changeClipBottomSheetView.reloadChangeClipBottom() - - } else { // 현재 클립이 1개 존재할 때 (전체클립 제외) - DispatchQueue.main.asyncAfter(deadline: .now()) { - self.showToastMessage(width: 284, - status: .warning, - message: "이동할 클립을 하나 이상 생성해 주세요") - } - } - } - } - .store(in: cancelBag) - - output.isCompleteButtonEnable - .receive(on: DispatchQueue.main) - .sink { [weak self] result in - self?.changeClipBottomSheetView.updateCompleteButtonUI(result) - } - .store(in: cancelBag) - - output.changeCategoryResult - .receive(on: DispatchQueue.main) - .sink { [weak self] result in - guard let self else { return } - if result == true { - let categoryFilter = DetailCategoryFilter.allCases[viewModel.getViewModelProperty(dataType: .segmentIndex) as? Int ?? 0] - - self.changeClipBottom.dismiss(animated: true) { - if self.viewModel.categoryId == 0 { - self.viewModel.getDetailAllCategoryAPI(filter: categoryFilter) - } else { - self.viewModel.getDetailCategoryAPI(categoryID: self.viewModel.categoryId, filter: categoryFilter) - } - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.showToastMessage(width: 152, - status: .check, - message: "링크 이동 완료") - } - } - } - .store(in: cancelBag) + func setupToast() { + if viewModel.currentCategoryId == 0 { + requestToastList.send(true) + } else { + requestToastList.send(false) + } } func setupToolTip() { @@ -253,7 +307,7 @@ extension DetailClipViewController: UICollectionViewDataSource { } cell.detailClipListCollectionViewCellDelegate = self - if viewModel.categoryId == 0 { + if viewModel.currentCategoryId == 0 { cell.configureCell(forModel: viewModel.toastList, index: indexPath.item, isClipHidden: false) } else { cell.configureCell(forModel: viewModel.toastList, index: indexPath.item, isClipHidden: true) @@ -269,15 +323,12 @@ extension DetailClipViewController: UICollectionViewDataSource { // "클립이동" 클릭 시 linkOptionBottomSheetView.setupChangeClipBottomSheetButtonAction { - self.changeClipSubject.send() + self.requestClipList.send() } // "삭제" 클릭 시 linkOptionBottomSheetView.setupDeleteLinkBottomSheetButtonAction { - self.viewModel.deleteLinkAPI(toastId: self.viewModel.toastId) - self.dismiss(animated: true) { [weak self] in - self?.showToastMessage(width: 152, status: .check, message: StringLiterals.ToastMessage.completeDeleteLink) - } + self.requestDeleteToast.send(self.viewModel.currentToastId) } return cell } @@ -310,9 +361,11 @@ extension DetailClipViewController: UICollectionViewDelegate { indexNumber = indexPath.item let nextVC = LinkWebViewController() nextVC.hidesBottomBarWhenPushed = true - nextVC.setupDataBind(linkURL: viewModel.toastList.toastList[indexPath.item].url, - isRead: viewModel.toastList.toastList[indexPath.item].isRead, - id: viewModel.toastList.toastList[indexPath.item].id) + nextVC.setupDataBind( + linkURL: viewModel.toastList.toastList[indexPath.item].url, + isRead: viewModel.toastList.toastList[indexPath.item].isRead, + id: viewModel.toastList.toastList[indexPath.item].id + ) self.navigationController?.pushViewController(nextVC, animated: true) } } @@ -341,90 +394,24 @@ extension DetailClipViewController: UICollectionViewDelegateFlowLayout { } } -// MARK: - DetailClipSegmented Delegate - -extension DetailClipViewController: DetailClipSegmentedDelegate { - func setupAllLink() { - viewModel.segmentIndex = 0 - if viewModel.categoryId == 0 { - viewModel.getDetailAllCategoryAPI(filter: .all) - } else { - viewModel.getDetailCategoryAPI(categoryID: viewModel.categoryId, filter: .all) - } - } - - func setupReadLink() { - viewModel.segmentIndex = 1 - if viewModel.categoryId == 0 { - viewModel.getDetailAllCategoryAPI(filter: .read) - } else { - viewModel.getDetailCategoryAPI(categoryID: viewModel.categoryId, filter: .read) - } - } - - func setupNotReadLink() { - viewModel.segmentIndex = 2 - if viewModel.categoryId == 0 { - viewModel.getDetailAllCategoryAPI(filter: .unread) - } else { - viewModel.getDetailCategoryAPI(categoryID: viewModel.categoryId, filter: .unread) - } - } -} - // MARK: - DetailClipListCollectionViewCell Delegate extension DetailClipViewController: DetailClipListCollectionViewCellDelegate { func modifiedButtonTapped(toastId: Int) { - viewModel.toastId = toastId - changeClipViewModel.setupToastId(toastId) - optionBottom.setupSheetPresentation(bottomHeight: viewModel.categoryId == 0 ? 226 : 280) + viewModel.setupToastId(toastId) + optionBottom.setupSheetPresentation(bottomHeight: viewModel.currentCategoryId == 0 ? 226 : 280) present(optionBottom, animated: true) - if viewModel.categoryId != 0 { setupToolTip() } + if viewModel.currentCategoryId != 0 { setupToolTip() } } } -// MARK: - EditLinkBottomSheetView Delegate -extension DetailClipViewController: EditLinkBottomSheetViewDelegate { - func callCheckAPI(filter: DetailCategoryFilter) { - viewModel.getDetailAllCategoryAPI(filter: filter) - } - - func addHeightBottom() { - editLinkBottom.setupSheetHeightChanges(bottomHeight: 219) - } - - func minusHeightBottom() { - editLinkBottom.setupSheetHeightChanges(bottomHeight: 198) - } - - func dismissButtonTapped(title: String) { - viewModel.patchEditLinkTitleAPI(toastId: viewModel.toastId, - title: title) - dismiss(animated: true) { [weak self] in - self?.showToastMessage(width: 152, status: .check, message: StringLiterals.ToastMessage.completeEditTitle) - } - } -} - -extension DetailClipViewController: PatchClipDelegate { - func patchEnd() { - viewModel.getDetailCategoryAPI( - categoryID: self.viewModel.categoryId, - filter: DetailCategoryFilter.allCases[self.viewModel.segmentIndex] - ) { - self.detailClipListCollectionView.reloadData() - } - } -} - -extension DetailClipViewController: ChangeClipBottomSheetViewDelegate { - func didSelectClip(selectClipId: Int) { - selectedClipSubject.send(selectClipId) - } - - func completButtonTap() { - completeButtonSubject.send() - } -} +//extension DetailClipViewController: ChangeClipBottomSheetViewDelegate { +// func didSelectClip(selectClipId: Int) { +// selectedClipSubject.send(selectClipId) +// } +// +// func completButtonTap() { +// completeButtonSubject.send() +// } +//} diff --git a/TOASTER-iOS/Present/DetailClip/ViewModel/DetailClipViewModel.swift b/TOASTER-iOS/Present/DetailClip/ViewModel/DetailClipViewModel.swift index c965d22f..2789dbe7 100644 --- a/TOASTER-iOS/Present/DetailClip/ViewModel/DetailClipViewModel.swift +++ b/TOASTER-iOS/Present/DetailClip/ViewModel/DetailClipViewModel.swift @@ -5,155 +5,483 @@ // Created by 민 on 2/8/24. // -import Foundation +import Combine +import UIKit -protocol PatchClipDelegate: AnyObject { - func patchEnd() -} - -final class DetailClipViewModel: NSObject { +final class DetailClipViewModel: ViewModelType { + + private var cancelBag = CancelBag() + + private(set) var toastList: DetailClipModel = DetailClipModel(allToastCount: 0, toastList: []) - // MARK: - Properties + private(set) var currentToastId: Int = 0 + private(set) var currentCategoryId: Int = 0 + private(set) var currentCategoryName: String = "" + private(set) var segmentIndex: Int = 0 + private(set) var linkTitle: String = "" + + private(set) var botomHeigth: CGFloat = 0 + private(set) var collectionViewHeight: CGFloat = 0 + + // MARK: - Input State + + struct Input { + let requestToast: Driver + let changeSegmentIndex: Driver + let editToastTitleButtonTap: Driver<(Int, String)> + let changeClipButtonTap: Driver + let selectedClip: Driver + let changeClipCompleteButtonTap: Driver + let deleteToastButtonTap: Driver + } - typealias DataChangeAction = (Bool) -> Void - private var dataChangeAction: DataChangeAction? + // MARK: - Output State - typealias NormalChangeAction = () -> Void - private var unAuthorizedAction: NormalChangeAction? - private var editLinkTitleAction: NormalChangeAction? + struct Output { + let loadToToastList = PassthroughSubject() + let toastNameChanged = PassthroughSubject() + let loadToClipData = PassthroughSubject<[SelectClipModel]?, Never>() + let isCompleteButtonEnable = PassthroughSubject() + let deleteToastComplete = PassthroughSubject() + } + + // MARK: - Method + + func transform(_ input: Input, cancelBag: CancelBag) -> Output { + let output = Output() + + input.requestToast + .networkFlatMap(self) { context, isAll in + if isAll { + context.getDetailAllCategoryAPI(filter: .all) + } else { + context.getDetailCategoryAPI(categoryID: self.currentCategoryId, filter: .all) + } + } + .sink { [weak self] toasts in + self?.toastList = toasts + output.loadToToastList.send(!toasts.toastList.isEmpty) + }.store(in: cancelBag) + + input.changeSegmentIndex + .networkFlatMap(self) { context, index in + if self.currentCategoryId == 0 { + switch index { + case 0: + context.getDetailAllCategoryAPI(filter: .all) + case 1: + context.getDetailAllCategoryAPI(filter: .read) + default: + context.getDetailAllCategoryAPI(filter: .unread) + } + } else { + switch index { + case 0: + context.getDetailCategoryAPI(categoryID: self.currentCategoryId, filter: .all) + case 1: + context.getDetailCategoryAPI(categoryID: self.currentCategoryId, filter: .read) + default: + context.getDetailCategoryAPI(categoryID: self.currentCategoryId, filter: .unread) + } + } + } + .sink { [weak self] toasts in + self?.toastList = toasts + output.loadToToastList.send(!toasts.toastList.isEmpty) + }.store(in: cancelBag) + + input.editToastTitleButtonTap + .networkFlatMap(self) { context, toast in + context.patchEditLinkTitleAPI(toastId: toast.0, title: toast.1) + } + .sink { [weak self] isSuccess in + output.toastNameChanged.send(isSuccess) + output.loadToToastList.send(self?.currentCategoryId == 0) + }.store(in: cancelBag) + + input.selectedClip + .networkFlatMap(self) { context, _ in + context.getAllCategoryAPI() + .map { [weak self] result -> [SelectClipModel]? in + guard let self = self else { return [] } + if result.count < 2 { return nil } // 2개 이하일 경우 nil 반환 + let sortedResult = self.sortCurrentCategoryToTop(result) + self.collectionViewHeight = self.calculateCollectionViewHeight(numberOfItems: sortedResult.count) + return sortedResult + } + } + .sink { model in + output.loadToClipData.send(model) + }.store(in: cancelBag) + + input.changeClipCompleteButtonTap + .zip(input.selectedClip) { _, selectedClip in + return selectedClip + } + .networkFlatMap(self) { context, selectClip in + context.patchChangeCategory(categoryId: selectClip) + } + .sink { _ in + // output.loadToToastList.send() + }.store(in: cancelBag) + + input.deleteToastButtonTap + .networkFlatMap(self) { context, toastId in + context.deleteLinkAPI(toastId: toastId) + } + .sink { [weak self] _ in + output.loadToToastList.send(self?.currentCategoryId == 0) + output.deleteToastComplete.send() + }.store(in: cancelBag) + + +// +// /// 이동할 클립을 선택 시 버튼의 UI 를 변경하는 동작 +// let isCompleteButtonEnable = Publishers.Merge( +// input.changeButtonTap.map { false }, // bottomSheet 열릴 때 false +// input.selectedClip.map { _ in true } // 클립 선택 시 true +// ).eraseToAnyPublisher() + + + return output + } - weak var delegate: PatchClipDelegate? + func setupCategory(_ id: Int) { + currentCategoryId = id + } - // MARK: - Data + func setupCategoryName(_ name: String) { + currentCategoryName = name + } - var toastId: Int = 0 - var categoryId: Int = 0 - var categoryName: String = "" - var segmentIndex: Int = 0 - var linkTitle: String = "" + func setupToastId(_ id: Int) { + currentToastId = id + } +} + +// MARK: - private Extensions + +private extension DetailClipViewModel { - private(set) var toastList: DetailClipModel = DetailClipModel(allToastCount: 0, toastList: []) { - didSet { - dataChangeAction?(!toastList.toastList.isEmpty) + /// 현재 카테고리를 최상단에 위치하도록 정렬하는 메서드 + func sortCurrentCategoryToTop(_ clipDataList: [SelectClipModel]) -> [SelectClipModel] { + guard let currentCategoryIndex = clipDataList.firstIndex(where: { $0.id == currentCategoryId }) else { + return clipDataList } + + var tempClipDataList = clipDataList + let currentCategoryData = tempClipDataList.remove(at: currentCategoryIndex) + tempClipDataList.insert(currentCategoryData, at: 0) + + calculateBottomSheetHeight(clipDataList.count) + + return tempClipDataList + } + + func calculateBottomSheetHeight(_ count: Int) { + botomHeigth = CGFloat(count * 54 + 184 + 3) + } + + func calculateCollectionViewHeight(numberOfItems: Int) -> CGFloat { + let cellHeight: CGFloat = 54 + let lineSpacing: CGFloat = 1 + + // 마지막 셀 다음에는 간격이 없으므로 (numberOfItems - 1) + let totalHeight = (cellHeight * CGFloat(numberOfItems)) + (lineSpacing * CGFloat(numberOfItems - 1)) + print("높이:", totalHeight) + return totalHeight } } -// MARK: - Extensions +// MARK: - Network -extension DetailClipViewModel { - func setupDataChangeAction(changeAction: @escaping DataChangeAction, - forUnAuthorizedAction: @escaping NormalChangeAction, - editNameAction: @escaping NormalChangeAction) { - dataChangeAction = changeAction - unAuthorizedAction = forUnAuthorizedAction - editLinkTitleAction = editNameAction +private extension DetailClipViewModel { + func getDetailAllCategoryAPI(filter: DetailCategoryFilter) -> AnyPublisher { + return Future { promise in + NetworkService.shared.clipService.getDetailAllCategory(filter: filter) { result in + switch result { + case .success(let response): + let allToastCount = response?.data.allToastNum + let toasts = response?.data.toastListDto.map { + ToastListModel( + id: $0.toastId, + title: $0.toastTitle, + url: $0.linkUrl, + isRead: $0.isRead, + clipTitle: $0.categoryTitle, + imageURL: $0.thumbnailUrl + ) + } + let detailClipModel = DetailClipModel( + allToastCount: allToastCount ?? 0, + toastList: toasts ?? [] + ) + promise(.success(detailClipModel)) + case .unAuthorized, .networkFail, .notFound: + promise(.failure(NetworkResult.unAuthorized)) + default: + return + } + } + }.eraseToAnyPublisher() } - func getViewModelProperty(dataType: DetailClipPropertyType) -> Any { - switch dataType { - case .toastId: - return toastId - case .categoryId: - return categoryId - case .categoryName: - return categoryName - case .segmentIndex: - return segmentIndex - case .linkTitle: - return linkTitle - } + func getDetailCategoryAPI(categoryID: Int, + filter: DetailCategoryFilter) -> AnyPublisher { + return Future { promise in + NetworkService.shared.clipService.getDetailCategory(categoryID: categoryID, filter: filter) { result in + switch result { + case .success(let response): + let allToastCount = response?.data.allToastNum + let toasts = response?.data.toastListDto.map { + ToastListModel( + id: $0.toastId, + title: $0.toastTitle, + url: $0.linkUrl, + isRead: $0.isRead, + clipTitle: $0.categoryTitle, + imageURL: $0.thumbnailUrl + ) + } + let detailClipModel = DetailClipModel( + allToastCount: allToastCount ?? 0, + toastList: toasts ?? [] + ) + promise(.success(detailClipModel)) + case .unAuthorized, .networkFail, .notFound: + promise(.failure(NetworkResult.unAuthorized)) + default: + return + } + } + }.eraseToAnyPublisher() } - func getDetailAllCategoryAPI(filter: DetailCategoryFilter) { - NetworkService.shared.clipService.getDetailAllCategory(filter: filter) { result in - switch result { - case .success(let response): - let allToastCount = response?.data.allToastNum - let toasts = response?.data.toastListDto.map { - ToastListModel(id: $0.toastId, - title: $0.toastTitle, - url: $0.linkUrl, - isRead: $0.isRead, - clipTitle: $0.categoryTitle, - imageURL: $0.thumbnailUrl) + func patchEditLinkTitleAPI(toastId: Int, title: String) -> AnyPublisher { + return Future { promise in + NetworkService.shared.toastService.patchEditLinkTitle( + requestBody: PatchEditLinkTitleRequestDTO( + toastId: toastId, + title: title + ) + ) { result in + switch result { + case .success: + promise(.success(true)) + case .unAuthorized, .networkFail, .notFound: + promise(.failure(NetworkResult.unAuthorized)) + default: + return } - self.toastList = DetailClipModel(allToastCount: allToastCount ?? 0, - toastList: toasts ?? []) - case .unAuthorized, .networkFail, .notFound: - self.unAuthorizedAction?() - default: return } - } + }.eraseToAnyPublisher() } - func getDetailCategoryAPI(categoryID: Int, - filter: DetailCategoryFilter, - completion: (() -> Void)? = nil) { - NetworkService.shared.clipService.getDetailCategory(categoryID: categoryID, filter: filter) { result in - switch result { - case .success(let response): - let allToastCount = response?.data.allToastNum - let toasts = response?.data.toastListDto.map { - ToastListModel(id: $0.toastId, - title: $0.toastTitle, - url: $0.linkUrl, - isRead: $0.isRead, - clipTitle: $0.categoryTitle, - imageURL: $0.thumbnailUrl) + func deleteLinkAPI(toastId: Int) -> AnyPublisher { + return Future { promise in + NetworkService.shared.toastService.deleteLink(toastId: toastId) { result in + switch result { + case .success: + promise(.success(())) + case .unAuthorized, .networkFail, .notFound: + promise(.failure(NetworkResult.unAuthorized)) + default: + return } - self.toastList = DetailClipModel(allToastCount: allToastCount ?? 0, - toastList: toasts ?? []) - completion?() - case .unAuthorized, .networkFail, .notFound: - self.unAuthorizedAction?() - default: return } - } + }.eraseToAnyPublisher() } - func deleteLinkAPI(toastId: Int) { - NetworkService.shared.toastService.deleteLink(toastId: toastId) { result in - switch result { - case .success: - if self.categoryId == 0 { - switch self.segmentIndex { - case 0: self.getDetailAllCategoryAPI(filter: .all) - case 1: self.getDetailAllCategoryAPI(filter: .read) - default: self.getDetailAllCategoryAPI(filter: .unread) - } - } else { - switch self.segmentIndex { - case 0: self.getDetailCategoryAPI(categoryID: self.categoryId, filter: .all) { - } - case 1: self.getDetailCategoryAPI(categoryID: self.categoryId, filter: .read) { - } - default: self.getDetailCategoryAPI(categoryID: self.categoryId, filter: .unread) { - } - } + func getAllCategoryAPI() -> AnyPublisher<[SelectClipModel], Error> { + return Future<[SelectClipModel], Error> { promise in + NetworkService.shared.clipService.getAllCategory { result in + switch result { + case .success(let response): + let clipDataList = response?.data.categories.map { category in + SelectClipModel( + id: category.categoryId, + title: category.categoryTitle, + clipCount: category.toastNum + ) + } ?? [] + promise(.success(clipDataList)) + case .unAuthorized, .networkFail, .notFound: + promise(.failure(NetworkResult.unAuthorized)) + default: + break } - case .unAuthorized, .networkFail, .notFound: - self.unAuthorizedAction?() - default: return } - } + }.eraseToAnyPublisher() } - func patchEditLinkTitleAPI(toastId: Int, title: String) { - NetworkService.shared.toastService.patchEditLinkTitle( - requestBody: PatchEditLinkTitleRequestDTO( - toastId: toastId, - title: title)) { result in - switch result { - case .success: - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.editLinkTitleAction?() + func patchChangeCategory(categoryId: Int) -> AnyPublisher { + let requestDTO = PatchChangeCategoryRequestDTO(toastId: currentToastId, categoryId: categoryId) + + return Future { promise in + NetworkService.shared.toastService.patchChangeCategory(requestBody: requestDTO) { result in + switch result { + case .success: + promise(.success(true)) + case .unAuthorized, .networkFail, .notFound, .serverErr: + promise(.failure(NetworkResult.unAuthorized)) + default: + break } - self.delegate?.patchEnd() - case .unAuthorized, .networkFail, .notFound: - self.unAuthorizedAction?() - default: return } - } + + }.eraseToAnyPublisher() } } + +//import Foundation +// +//protocol PatchClipDelegate: AnyObject { +// func patchEnd() +//} +// +//final class DetailClipViewModel: NSObject { +// +// // MARK: - Properties +// +// typealias DataChangeAction = (Bool) -> Void +// private var dataChangeAction: DataChangeAction? +// +// typealias NormalChangeAction = () -> Void +// private var unAuthorizedAction: NormalChangeAction? +// private var editLinkTitleAction: NormalChangeAction? +// +// weak var delegate: PatchClipDelegate? +// +// // MARK: - Data +// +// var toastId: Int = 0 +// var categoryId: Int = 0 +// var categoryName: String = "" +// var segmentIndex: Int = 0 +// var linkTitle: String = "" +// +// private(set) var toastList: DetailClipModel = DetailClipModel(allToastCount: 0, toastList: []) { +// didSet { +// dataChangeAction?(!toastList.toastList.isEmpty) +// } +// } +//} +// +//// MARK: - Extensions +// +//extension DetailClipViewModel { +// func setupDataChangeAction(changeAction: @escaping DataChangeAction, +// forUnAuthorizedAction: @escaping NormalChangeAction, +// editNameAction: @escaping NormalChangeAction) { +// dataChangeAction = changeAction +// unAuthorizedAction = forUnAuthorizedAction +// editLinkTitleAction = editNameAction +// } +// +// func getViewModelProperty(dataType: DetailClipPropertyType) -> Any { +// switch dataType { +// case .toastId: +// return toastId +// case .categoryId: +// return categoryId +// case .categoryName: +// return categoryName +// case .segmentIndex: +// return segmentIndex +// case .linkTitle: +// return linkTitle +// } +// } +// +// func getDetailAllCategoryAPI(filter: DetailCategoryFilter) { +// NetworkService.shared.clipService.getDetailAllCategory(filter: filter) { result in +// switch result { +// case .success(let response): +// let allToastCount = response?.data.allToastNum +// let toasts = response?.data.toastListDto.map { +// ToastListModel(id: $0.toastId, +// title: $0.toastTitle, +// url: $0.linkUrl, +// isRead: $0.isRead, +// clipTitle: $0.categoryTitle, +// imageURL: $0.thumbnailUrl) +// } +// self.toastList = DetailClipModel(allToastCount: allToastCount ?? 0, +// toastList: toasts ?? []) +// case .unAuthorized, .networkFail, .notFound: +// self.unAuthorizedAction?() +// default: return +// } +// } +// } +// +// func getDetailCategoryAPI(categoryID: Int, +// filter: DetailCategoryFilter, +// completion: (() -> Void)? = nil) { +// NetworkService.shared.clipService.getDetailCategory(categoryID: categoryID, filter: filter) { result in +// switch result { +// case .success(let response): +// let allToastCount = response?.data.allToastNum +// let toasts = response?.data.toastListDto.map { +// ToastListModel(id: $0.toastId, +// title: $0.toastTitle, +// url: $0.linkUrl, +// isRead: $0.isRead, +// clipTitle: $0.categoryTitle, +// imageURL: $0.thumbnailUrl) +// } +// self.toastList = DetailClipModel(allToastCount: allToastCount ?? 0, +// toastList: toasts ?? []) +// completion?() +// case .unAuthorized, .networkFail, .notFound: +// self.unAuthorizedAction?() +// default: return +// } +// } +// } +// +// func deleteLinkAPI(toastId: Int) { +// NetworkService.shared.toastService.deleteLink(toastId: toastId) { result in +// switch result { +// case .success: +// if self.categoryId == 0 { +// switch self.segmentIndex { +// case 0: self.getDetailAllCategoryAPI(filter: .all) +// case 1: self.getDetailAllCategoryAPI(filter: .read) +// default: self.getDetailAllCategoryAPI(filter: .unread) +// } +// } else { +// switch self.segmentIndex { +// case 0: self.getDetailCategoryAPI(categoryID: self.categoryId, filter: .all) { +// } +// case 1: self.getDetailCategoryAPI(categoryID: self.categoryId, filter: .read) { +// } +// default: self.getDetailCategoryAPI(categoryID: self.categoryId, filter: .unread) { +// } +// } +// } +// case .unAuthorized, .networkFail, .notFound: +// self.unAuthorizedAction?() +// default: return +// } +// } +// } +// +// func patchEditLinkTitleAPI(toastId: Int, title: String) { +// NetworkService.shared.toastService.patchEditLinkTitle( +// requestBody: PatchEditLinkTitleRequestDTO( +// toastId: toastId, +// title: title)) { result in +// switch result { +// case .success: +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { +// self.editLinkTitleAction?() +// } +// self.delegate?.patchEnd() +// case .unAuthorized, .networkFail, .notFound: +// self.unAuthorizedAction?() +// default: return +// } +// } +// } +//} From 09a4b93a0b3de76e0a3f956eea50995a13ee4fef Mon Sep 17 00:00:00 2001 From: mini-min <2alswo7@khu.ac.kr> Date: Thu, 19 Dec 2024 17:11:22 +0900 Subject: [PATCH 4/5] [Refactor] #237 - merge ChangeClipViewModel --- TOASTER-iOS.xcodeproj/project.pbxproj | 4 - .../View/DetailClipViewController.swift | 97 ++++----- .../ViewModel/ChangeClipViewModel.swift | 152 ------------- .../ViewModel/DetailClipViewModel.swift | 200 +++--------------- 4 files changed, 79 insertions(+), 374 deletions(-) delete mode 100644 TOASTER-iOS/Present/DetailClip/ViewModel/ChangeClipViewModel.swift diff --git a/TOASTER-iOS.xcodeproj/project.pbxproj b/TOASTER-iOS.xcodeproj/project.pbxproj index 2db6be2d..86a121e5 100644 --- a/TOASTER-iOS.xcodeproj/project.pbxproj +++ b/TOASTER-iOS.xcodeproj/project.pbxproj @@ -182,7 +182,6 @@ 3FEA674D2CB51BFC00675805 /* PatchChangeCategoryResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEA674C2CB51BFC00675805 /* PatchChangeCategoryResponseDTO.swift */; }; 3FEA674E2CB51E6D00675805 /* PatchChangeCategoryRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEA674A2CB51BBC00675805 /* PatchChangeCategoryRequestDTO.swift */; }; 3FEA67502CB6522F00675805 /* ChangeClipBottomSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEA674F2CB6522F00675805 /* ChangeClipBottomSheetView.swift */; }; - 3FEA67522CB663B100675805 /* ChangeClipViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEA67512CB663B100675805 /* ChangeClipViewModel.swift */; }; 3FEA67532CB677A400675805 /* PatchChangeCategoryResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEA674C2CB51BFC00675805 /* PatchChangeCategoryResponseDTO.swift */; }; 3FF02B302CAFE6600074332E /* ViewModelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8334CF9F2CA6E2D200319922 /* ViewModelType.swift */; }; 3FF2BF092BA17492001D7DC1 /* ToasterShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3FF2BEFF2BA17492001D7DC1 /* ToasterShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -447,7 +446,6 @@ 3FEA674A2CB51BBC00675805 /* PatchChangeCategoryRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchChangeCategoryRequestDTO.swift; sourceTree = ""; }; 3FEA674C2CB51BFC00675805 /* PatchChangeCategoryResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchChangeCategoryResponseDTO.swift; sourceTree = ""; }; 3FEA674F2CB6522F00675805 /* ChangeClipBottomSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeClipBottomSheetView.swift; sourceTree = ""; }; - 3FEA67512CB663B100675805 /* ChangeClipViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeClipViewModel.swift; sourceTree = ""; }; 3FF2BEFF2BA17492001D7DC1 /* ToasterShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ToasterShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 3FF2BF062BA17492001D7DC1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6B0E85D82B564913001BC15F /* RemindTimerAddViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindTimerAddViewModel.swift; sourceTree = ""; }; @@ -831,7 +829,6 @@ children = ( 39A843C92B74512B007A4D75 /* DetailClipViewModel.swift */, 39A843CD2B745B3A007A4D75 /* DetailClipPropertyType.swift */, - 3FEA67512CB663B100675805 /* ChangeClipViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -2191,7 +2188,6 @@ 83D80DCC2CC1059000DD5410 /* RecentLinkModel.swift in Sources */, 3F82C3212CADA19300492EEE /* Publisher+UIButton.swift in Sources */, 6B6AE6AC2B3FF6F7000E2366 /* UIColor+.swift in Sources */, - 3FEA67522CB663B100675805 /* ChangeClipViewModel.swift in Sources */, 6BC493682B45D7B100544249 /* ToasterNavigationController.swift in Sources */, 6BE6DA492B50ADC2008B06FA /* NetworkService.swift in Sources */, 6B6AE69C2B3FF5CC000E2366 /* HomeViewController.swift in Sources */, diff --git a/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift b/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift index d506398e..a1bff91d 100644 --- a/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift +++ b/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift @@ -14,7 +14,7 @@ import Then final class DetailClipViewController: UIViewController { // MARK: - Data Stream - + private let viewModel = DetailClipViewModel() private var cancelBag = CancelBag() @@ -162,9 +162,11 @@ private extension DetailClipViewController { } else { // 현재 클립이 1개 존재할 때 (전체클립 제외) DispatchQueue.main.asyncAfter(deadline: .now()) { - self.showToastMessage(width: 284, - status: .warning, - message: "이동할 클립을 하나 이상 생성해 주세요") + self.showToastMessage( + width: 284, + status: .warning, + message: "이동할 클립을 하나 이상 생성해 주세요" + ) } } } @@ -183,49 +185,45 @@ private extension DetailClipViewController { ) } }.store(in: cancelBag) - -// -// output.isCompleteButtonEnable -// .receive(on: DispatchQueue.main) -// .sink { [weak self] result in -// self?.changeClipBottomSheetView.updateCompleteButtonUI(result) -// } -// .store(in: cancelBag) -// -// output.changeCategoryResult -// .receive(on: DispatchQueue.main) -// .sink { [weak self] result in -// guard let self else { return } -// if result == true { -// let categoryFilter = DetailCategoryFilter.allCases[viewModel.getViewModelProperty(dataType: .segmentIndex) as? Int ?? 0] -// -// self.changeClipBottom.dismiss(animated: true) { -// if self.viewModel.categoryId == 0 { -// self.viewModel.getDetailAllCategoryAPI(filter: categoryFilter) -// } else { -// self.viewModel.getDetailCategoryAPI(categoryID: self.viewModel.categoryId, filter: categoryFilter) -// } -// } -// -// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -// self.showToastMessage(width: 152, -// status: .check, -// message: "링크 이동 완료") -// } -// } -// } -// .store(in: cancelBag) + + output.isCompleteButtonEnable + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + self?.changeClipBottomSheetView.updateCompleteButtonUI(result) + } + .store(in: cancelBag) + + output.changeCategoryResult + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self else { return } + if result == true { + let categoryFilter = DetailCategoryFilter.allCases[viewModel.getViewModelProperty(dataType: .segmentIndex) as? Int ?? 0] + + self.changeClipBottom.dismiss(animated: true) { + self.setupToast() + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.showToastMessage( + width: 152, + status: .check, + message: "링크 이동 완료" + ) + } + } + } + .store(in: cancelBag) } func setupStyle() { view.backgroundColor = .toasterBackground detailClipListCollectionView.backgroundColor = .toasterBackground detailClipEmptyView.isHidden = false - // editLinkBottomSheetView.editLinkBottomSheetViewDelegate = self } func setupHierarchy() { - view.addSubviews(detailClipSegmentedControlView, + view.addSubviews(detailClipSegmentedControlView, detailClipListCollectionView, detailClipEmptyView) } @@ -255,9 +253,7 @@ private extension DetailClipViewController { func setupDelegate() { detailClipListCollectionView.delegate = self detailClipListCollectionView.dataSource = self - - - // changeClipBottomSheetView.delegate = self + changeClipBottomSheetView.delegate = self } func setupNavigationBar() { @@ -405,13 +401,12 @@ extension DetailClipViewController: DetailClipListCollectionViewCellDelegate { } } - -//extension DetailClipViewController: ChangeClipBottomSheetViewDelegate { -// func didSelectClip(selectClipId: Int) { -// selectedClipSubject.send(selectClipId) -// } -// -// func completButtonTap() { -// completeButtonSubject.send() -// } -//} +extension DetailClipViewController: ChangeClipBottomSheetViewDelegate { + func didSelectClip(selectClipId: Int) { + selectedClipSubject.send(selectClipId) + } + + func completButtonTap() { + completeButtonSubject.send() + } +} diff --git a/TOASTER-iOS/Present/DetailClip/ViewModel/ChangeClipViewModel.swift b/TOASTER-iOS/Present/DetailClip/ViewModel/ChangeClipViewModel.swift deleted file mode 100644 index e34b0b40..00000000 --- a/TOASTER-iOS/Present/DetailClip/ViewModel/ChangeClipViewModel.swift +++ /dev/null @@ -1,152 +0,0 @@ -// -// ChangeClipViewModel.swift -// TOASTER-iOS -// -// Created by ParkJunHyuk on 10/9/24. -// - -import Combine -import Foundation - -final class ChangeClipViewModel: ViewModelType { - - private(set) var currentToastId: Int = 0 - private(set) var currentCategoryId: Int = 0 - private(set) var botomHeigth: CGFloat = 0 - private(set) var collectionViewHeight: CGFloat = 0 - - struct Input { - let changeButtonTap: AnyPublisher - let selectedClip: AnyPublisher - let completeButtonTap: AnyPublisher - } - - struct Output { - let clipData: AnyPublisher<[SelectClipModel]?, Never> - let isCompleteButtonEnable: AnyPublisher - let changeCategoryResult: AnyPublisher - } - - func transform(_ input: Input, cancelBag: CancelBag) -> Output { - - /// 클립이동 버튼이 눌렸을때 동작 - let clipDataPublisher = input.changeButtonTap - .networkFlatMap(self) { context, _ in - context.getAllCategoryAPI() - .map { [weak self] result -> [SelectClipModel]? in - guard let self = self else { return [] } - if result.count < 2 { return nil } // 2개 이하일 경우 nil 반환 - let sortedResult = self.sortCurrentCategoryToTop(result) - self.collectionViewHeight = self.calculateCollectionViewHeight(numberOfItems: sortedResult.count) - return sortedResult - } - } - - /// 이동할 클립을 선택 시 버튼의 UI 를 변경하는 동작 - let isCompleteButtonEnable = Publishers.Merge( - input.changeButtonTap.map { false }, // bottomSheet 열릴 때 false - input.selectedClip.map { _ in true } // 클립 선택 시 true - ).eraseToAnyPublisher() - - /// 완료 버튼이 눌렸을때 동작 - let changeCategoryResult = input.completeButtonTap - .zip(input.selectedClip) { _, selectedClip in - return selectedClip - } - .networkFlatMap(self) { context, selectClip in - context.patchChagneCategory(categoryId: selectClip) - } - - return Output( - clipData: clipDataPublisher, - isCompleteButtonEnable: isCompleteButtonEnable, - changeCategoryResult: changeCategoryResult - ) - } - - func setupCategory(_ id: Int) { - currentCategoryId = id - } - - func setupToastId(_ id: Int) { - currentToastId = id - } -} - -// MARK: - private Extensions - -private extension ChangeClipViewModel { - /// 현재 카테고리를 최상단에 위치하도록 정렬하는 메서드 - func sortCurrentCategoryToTop(_ clipDataList: [SelectClipModel]) -> [SelectClipModel] { - guard let currentCategoryIndex = clipDataList.firstIndex(where: { $0.id == currentCategoryId }) else { - return clipDataList - } - - var tempClipDataList = clipDataList - let currentCategoryData = tempClipDataList.remove(at: currentCategoryIndex) - tempClipDataList.insert(currentCategoryData, at: 0) - - calculateBottomSheetHeight(clipDataList.count) - - return tempClipDataList - } - - func calculateBottomSheetHeight(_ count: Int) { - botomHeigth = CGFloat(count * 54 + 184 + 3) - } - - func calculateCollectionViewHeight(numberOfItems: Int) -> CGFloat { - let cellHeight: CGFloat = 54 - let lineSpacing: CGFloat = 1 - - // 마지막 셀 다음에는 간격이 없으므로 (numberOfItems - 1) - let totalHeight = (cellHeight * CGFloat(numberOfItems)) + (lineSpacing * CGFloat(numberOfItems - 1)) - print("높이:", totalHeight) - return totalHeight - } -} - -// MARK: - API Extensions - -extension ChangeClipViewModel { - func getAllCategoryAPI() -> AnyPublisher<[SelectClipModel], Error> { - return Future<[SelectClipModel], Error> { promise in - NetworkService.shared.clipService.getAllCategory { result in - switch result { - case .success(let response): - let clipDataList = response?.data.categories.map { category in - SelectClipModel( - id: category.categoryId, - title: category.categoryTitle, - clipCount: category.toastNum - ) - } ?? [] - - promise(.success(clipDataList)) - case .unAuthorized, .networkFail, .notFound: - promise(.failure(NetworkResult.unAuthorized)) - default: - break - } - } - }.eraseToAnyPublisher() - } - - func patchChagneCategory(categoryId: Int) -> AnyPublisher { - let requestDTO = PatchChangeCategoryRequestDTO(toastId: currentToastId, categoryId: categoryId) - - return Future { promise in - NetworkService.shared.toastService.patchChangeCategory(requestBody: requestDTO) { result in - switch result { - case .success: - promise(.success(true)) - case .unAuthorized, .networkFail, .notFound, .serverErr: - promise(.failure(NetworkResult.unAuthorized)) - default: - break - } - } - - }.eraseToAnyPublisher() - } -} diff --git a/TOASTER-iOS/Present/DetailClip/ViewModel/DetailClipViewModel.swift b/TOASTER-iOS/Present/DetailClip/ViewModel/DetailClipViewModel.swift index 2789dbe7..d6dc2a3f 100644 --- a/TOASTER-iOS/Present/DetailClip/ViewModel/DetailClipViewModel.swift +++ b/TOASTER-iOS/Present/DetailClip/ViewModel/DetailClipViewModel.swift @@ -42,6 +42,7 @@ final class DetailClipViewModel: ViewModelType { let toastNameChanged = PassthroughSubject() let loadToClipData = PassthroughSubject<[SelectClipModel]?, Never>() let isCompleteButtonEnable = PassthroughSubject() + let changeCategoryResult = PassthroughSubject() let deleteToastComplete = PassthroughSubject() } @@ -62,7 +63,7 @@ final class DetailClipViewModel: ViewModelType { self?.toastList = toasts output.loadToToastList.send(!toasts.toastList.isEmpty) }.store(in: cancelBag) - + input.changeSegmentIndex .networkFlatMap(self) { context, index in if self.currentCategoryId == 0 { @@ -99,7 +100,7 @@ final class DetailClipViewModel: ViewModelType { output.loadToToastList.send(self?.currentCategoryId == 0) }.store(in: cancelBag) - input.selectedClip + input.changeClipButtonTap .networkFlatMap(self) { context, _ in context.getAllCategoryAPI() .map { [weak self] result -> [SelectClipModel]? in @@ -114,6 +115,14 @@ final class DetailClipViewModel: ViewModelType { output.loadToClipData.send(model) }.store(in: cancelBag) + Publishers.Merge( + input.changeClipButtonTap.map { false }, + input.selectedClip.map { _ in true } + ) + .sink { isEnabled in + output.isCompleteButtonEnable.send(isEnabled) + }.store(in: cancelBag) + input.changeClipCompleteButtonTap .zip(input.selectedClip) { _, selectedClip in return selectedClip @@ -121,8 +130,8 @@ final class DetailClipViewModel: ViewModelType { .networkFlatMap(self) { context, selectClip in context.patchChangeCategory(categoryId: selectClip) } - .sink { _ in - // output.loadToToastList.send() + .sink { result in + output.changeCategoryResult.send(result) }.store(in: cancelBag) input.deleteToastButtonTap @@ -134,18 +143,13 @@ final class DetailClipViewModel: ViewModelType { output.deleteToastComplete.send() }.store(in: cancelBag) - -// -// /// 이동할 클립을 선택 시 버튼의 UI 를 변경하는 동작 -// let isCompleteButtonEnable = Publishers.Merge( -// input.changeButtonTap.map { false }, // bottomSheet 열릴 때 false -// input.selectedClip.map { _ in true } // 클립 선택 시 true -// ).eraseToAnyPublisher() - - return output } - +} + +// MARK: - Extension Methods + +extension DetailClipViewModel { func setupCategory(_ id: Int) { currentCategoryId = id } @@ -157,6 +161,21 @@ final class DetailClipViewModel: ViewModelType { func setupToastId(_ id: Int) { currentToastId = id } + + func getViewModelProperty(dataType: DetailClipPropertyType) -> Any { + switch dataType { + case .toastId: + return currentToastId + case .categoryId: + return currentCategoryId + case .categoryName: + return currentCategoryName + case .segmentIndex: + return segmentIndex + case .linkTitle: + return linkTitle + } + } } // MARK: - private Extensions @@ -332,156 +351,3 @@ private extension DetailClipViewModel { }.eraseToAnyPublisher() } } - -//import Foundation -// -//protocol PatchClipDelegate: AnyObject { -// func patchEnd() -//} -// -//final class DetailClipViewModel: NSObject { -// -// // MARK: - Properties -// -// typealias DataChangeAction = (Bool) -> Void -// private var dataChangeAction: DataChangeAction? -// -// typealias NormalChangeAction = () -> Void -// private var unAuthorizedAction: NormalChangeAction? -// private var editLinkTitleAction: NormalChangeAction? -// -// weak var delegate: PatchClipDelegate? -// -// // MARK: - Data -// -// var toastId: Int = 0 -// var categoryId: Int = 0 -// var categoryName: String = "" -// var segmentIndex: Int = 0 -// var linkTitle: String = "" -// -// private(set) var toastList: DetailClipModel = DetailClipModel(allToastCount: 0, toastList: []) { -// didSet { -// dataChangeAction?(!toastList.toastList.isEmpty) -// } -// } -//} -// -//// MARK: - Extensions -// -//extension DetailClipViewModel { -// func setupDataChangeAction(changeAction: @escaping DataChangeAction, -// forUnAuthorizedAction: @escaping NormalChangeAction, -// editNameAction: @escaping NormalChangeAction) { -// dataChangeAction = changeAction -// unAuthorizedAction = forUnAuthorizedAction -// editLinkTitleAction = editNameAction -// } -// -// func getViewModelProperty(dataType: DetailClipPropertyType) -> Any { -// switch dataType { -// case .toastId: -// return toastId -// case .categoryId: -// return categoryId -// case .categoryName: -// return categoryName -// case .segmentIndex: -// return segmentIndex -// case .linkTitle: -// return linkTitle -// } -// } -// -// func getDetailAllCategoryAPI(filter: DetailCategoryFilter) { -// NetworkService.shared.clipService.getDetailAllCategory(filter: filter) { result in -// switch result { -// case .success(let response): -// let allToastCount = response?.data.allToastNum -// let toasts = response?.data.toastListDto.map { -// ToastListModel(id: $0.toastId, -// title: $0.toastTitle, -// url: $0.linkUrl, -// isRead: $0.isRead, -// clipTitle: $0.categoryTitle, -// imageURL: $0.thumbnailUrl) -// } -// self.toastList = DetailClipModel(allToastCount: allToastCount ?? 0, -// toastList: toasts ?? []) -// case .unAuthorized, .networkFail, .notFound: -// self.unAuthorizedAction?() -// default: return -// } -// } -// } -// -// func getDetailCategoryAPI(categoryID: Int, -// filter: DetailCategoryFilter, -// completion: (() -> Void)? = nil) { -// NetworkService.shared.clipService.getDetailCategory(categoryID: categoryID, filter: filter) { result in -// switch result { -// case .success(let response): -// let allToastCount = response?.data.allToastNum -// let toasts = response?.data.toastListDto.map { -// ToastListModel(id: $0.toastId, -// title: $0.toastTitle, -// url: $0.linkUrl, -// isRead: $0.isRead, -// clipTitle: $0.categoryTitle, -// imageURL: $0.thumbnailUrl) -// } -// self.toastList = DetailClipModel(allToastCount: allToastCount ?? 0, -// toastList: toasts ?? []) -// completion?() -// case .unAuthorized, .networkFail, .notFound: -// self.unAuthorizedAction?() -// default: return -// } -// } -// } -// -// func deleteLinkAPI(toastId: Int) { -// NetworkService.shared.toastService.deleteLink(toastId: toastId) { result in -// switch result { -// case .success: -// if self.categoryId == 0 { -// switch self.segmentIndex { -// case 0: self.getDetailAllCategoryAPI(filter: .all) -// case 1: self.getDetailAllCategoryAPI(filter: .read) -// default: self.getDetailAllCategoryAPI(filter: .unread) -// } -// } else { -// switch self.segmentIndex { -// case 0: self.getDetailCategoryAPI(categoryID: self.categoryId, filter: .all) { -// } -// case 1: self.getDetailCategoryAPI(categoryID: self.categoryId, filter: .read) { -// } -// default: self.getDetailCategoryAPI(categoryID: self.categoryId, filter: .unread) { -// } -// } -// } -// case .unAuthorized, .networkFail, .notFound: -// self.unAuthorizedAction?() -// default: return -// } -// } -// } -// -// func patchEditLinkTitleAPI(toastId: Int, title: String) { -// NetworkService.shared.toastService.patchEditLinkTitle( -// requestBody: PatchEditLinkTitleRequestDTO( -// toastId: toastId, -// title: title)) { result in -// switch result { -// case .success: -// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -// self.editLinkTitleAction?() -// } -// self.delegate?.patchEnd() -// case .unAuthorized, .networkFail, .notFound: -// self.unAuthorizedAction?() -// default: return -// } -// } -// } -//} From 77cb6fba0468222d55c63b918383787d73e3be85 Mon Sep 17 00:00:00 2001 From: mini-min <2alswo7@khu.ac.kr> Date: Thu, 19 Dec 2024 17:24:29 +0900 Subject: [PATCH 5/5] [Refactor] #237 - cleanup VM Code --- .../Component/EditLinkBottomSheetView.swift | 66 ++++--------------- .../View/DetailClipViewController.swift | 48 +++++++------- .../ViewModel/DetailClipViewModel.swift | 15 ----- 3 files changed, 37 insertions(+), 92 deletions(-) diff --git a/TOASTER-iOS/Present/DetailClip/View/Component/EditLinkBottomSheetView.swift b/TOASTER-iOS/Present/DetailClip/View/Component/EditLinkBottomSheetView.swift index 146aff4c..dab64907 100644 --- a/TOASTER-iOS/Present/DetailClip/View/Component/EditLinkBottomSheetView.swift +++ b/TOASTER-iOS/Present/DetailClip/View/Component/EditLinkBottomSheetView.swift @@ -14,27 +14,12 @@ final class EditLinkBottomSheetView: UIView { // MARK: - Properties - // weak var editLinkBottomSheetViewDelegate: EditLinkBottomSheetViewDelegate? -// private var confirmBottomSheetViewButtonAction: (() -> Void)? - private var isButtonClicked: Bool = false { didSet { setupButtonColor() } } -// private var isBorderColor: Bool = false { -// didSet { -// setupTextFieldBorder() -// } -// } - -// private var isError: Bool = false { -// didSet { -// setupErrorMessage() -// } -// } - private var isClearButtonShow: Bool = true { didSet { setupClearButton() @@ -77,13 +62,11 @@ extension EditLinkBottomSheetView { func resetTextField() { editClipTitleTextField.text = nil editClipTitleTextField.becomeFirstResponder() - isButtonClicked = true + isButtonClicked = false } - func changeTextField(addButton: Bool, border: Bool, error: Bool, clearButton: Bool) { + func changeTextField(addButton: Bool, clearButton: Bool) { isButtonClicked = addButton -// isBorderColor = border -// isError = error isClearButtonShow = clearButton } @@ -95,10 +78,6 @@ extension EditLinkBottomSheetView { editClipTitleTextField.text = message editClipTitleTextField.placeholder = message } - -// func setupConfirmBottomSheetButtonAction(_ action: (() -> Void)?) { -// confirmBottomSheetViewButtonAction = action -// } } // MARK: - Private Extensions @@ -108,9 +87,13 @@ private extension EditLinkBottomSheetView { backgroundColor = .toasterWhite editClipTitleTextField.do { - $0.attributedPlaceholder = NSAttributedString(string: StringLiterals.Placeholder.addClip, - attributes: [.foregroundColor: UIColor.gray400, - .font: UIFont.suitRegular(size: 16)]) + $0.attributedPlaceholder = NSAttributedString( + string: StringLiterals.Placeholder.addClip, + attributes: [ + .foregroundColor: UIColor.gray400, + .font: UIFont.suitRegular(size: 16) + ] + ) $0.addPadding(left: 14, right: 44) $0.backgroundColor = .gray50 $0.textColor = .black900 @@ -125,7 +108,6 @@ private extension EditLinkBottomSheetView { $0.setTitle(StringLiterals.Button.okay, for: .normal) $0.setTitleColor(.toasterWhite, for: .normal) $0.titleLabel?.font = .suitBold(size: 16) - // $0.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) } errorMessage.do { @@ -187,26 +169,6 @@ private extension EditLinkBottomSheetView { } } -// func setupTextFieldBorder() { -// if isBorderColor { -// editClipTitleTextField.layer.borderColor = UIColor.toasterError.cgColor -// editClipTitleTextField.layer.borderWidth = 1.0 -// } else { -// editClipTitleTextField.layer.borderColor = UIColor.clear.cgColor -// editClipTitleTextField.layer.borderWidth = 0.0 -// } -// } - -// func setupErrorMessage() { -// if isError { -// editLinkBottomSheetViewDelegate?.addHeightBottom() -// errorMessage.isHidden = false -// } else { -// editLinkBottomSheetViewDelegate?.minusHeightBottom() -// errorMessage.isHidden = true -// } -// } - func setupClearButton() { if isClearButtonShow { clearButton.isHidden = false @@ -215,12 +177,6 @@ private extension EditLinkBottomSheetView { } } -// @objc -// func buttonTapped() { -// confirmBottomSheetViewButtonAction?() -// editLinkBottomSheetViewDelegate?.dismissButtonTapped(title: editClipTitleTextField.text ?? "") -// } - @objc func clearButtonTapped() { resetTextField() @@ -233,9 +189,9 @@ extension EditLinkBottomSheetView: UITextFieldDelegate { func textFieldDidChangeSelection(_ textField: UITextField) { let currentText = textField.text ?? "" if currentText.isEmpty { - changeTextField(addButton: false, border: false, error: false, clearButton: false) + changeTextField(addButton: false, clearButton: false) } else { - changeTextField(addButton: true, border: false, error: false, clearButton: true) + changeTextField(addButton: true, clearButton: true) } } } diff --git a/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift b/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift index a1bff91d..5562fb45 100644 --- a/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift +++ b/TOASTER-iOS/Present/DetailClip/View/DetailClipViewController.swift @@ -172,20 +172,6 @@ private extension DetailClipViewController { } }.store(in: cancelBag) - output.deleteToastComplete - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self else { return } - setupToast() - self.dismiss(animated: true) { [weak self] in - self?.showToastMessage( - width: 152, - status: .check, - message: StringLiterals.ToastMessage.completeDeleteLink - ) - } - }.store(in: cancelBag) - output.isCompleteButtonEnable .receive(on: DispatchQueue.main) .sink { [weak self] result in @@ -198,8 +184,6 @@ private extension DetailClipViewController { .sink { [weak self] result in guard let self else { return } if result == true { - let categoryFilter = DetailCategoryFilter.allCases[viewModel.getViewModelProperty(dataType: .segmentIndex) as? Int ?? 0] - self.changeClipBottom.dismiss(animated: true) { self.setupToast() } @@ -214,6 +198,20 @@ private extension DetailClipViewController { } } .store(in: cancelBag) + + output.deleteToastComplete + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + setupToast() + self.dismiss(animated: true) { [weak self] in + self?.showToastMessage( + width: 152, + status: .check, + message: StringLiterals.ToastMessage.completeDeleteLink + ) + } + }.store(in: cancelBag) } func setupStyle() { @@ -333,14 +331,20 @@ extension DetailClipViewController: UICollectionViewDataSource { guard let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: ClipCollectionHeaderView.className, for: indexPath) as? ClipCollectionHeaderView else { return UICollectionReusableView() } headerView.isDetailClipView(isHidden: true) if viewModel.segmentIndex == 0 { - headerView.setupDataBind(title: "전체", - count: viewModel.toastList.toastList.count) + headerView.setupDataBind( + title: "전체", + count: viewModel.toastList.toastList.count + ) } else if viewModel.segmentIndex == 1 { - headerView.setupDataBind(title: "열람", - count: viewModel.toastList.toastList.count) + headerView.setupDataBind( + title: "열람", + count: viewModel.toastList.toastList.count + ) } else { - headerView.setupDataBind(title: "미열람", - count: viewModel.toastList.toastList.count) + headerView.setupDataBind( + title: "미열람", + count: viewModel.toastList.toastList.count + ) } return headerView } diff --git a/TOASTER-iOS/Present/DetailClip/ViewModel/DetailClipViewModel.swift b/TOASTER-iOS/Present/DetailClip/ViewModel/DetailClipViewModel.swift index d6dc2a3f..5a6e3b59 100644 --- a/TOASTER-iOS/Present/DetailClip/ViewModel/DetailClipViewModel.swift +++ b/TOASTER-iOS/Present/DetailClip/ViewModel/DetailClipViewModel.swift @@ -161,21 +161,6 @@ extension DetailClipViewModel { func setupToastId(_ id: Int) { currentToastId = id } - - func getViewModelProperty(dataType: DetailClipPropertyType) -> Any { - switch dataType { - case .toastId: - return currentToastId - case .categoryId: - return currentCategoryId - case .categoryName: - return currentCategoryName - case .segmentIndex: - return segmentIndex - case .linkTitle: - return linkTitle - } - } } // MARK: - private Extensions