From 442013cf837823df458b1e1afc0b003824ae47ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Kwon=20/=20=EA=B6=8C=EC=8A=B9=EC=9A=A9?= Date: Fri, 24 Nov 2023 00:01:41 +0900 Subject: [PATCH] =?UTF-8?q?[iOS/#130]=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Category Repository 함수들의 인자 변경 - Category 타입 인스턴스 전체가 아닌 필요한 내용만 받기로 변경 * feat: Category 타입 변경 - 순수 id가 필요, 따라서 Identifiable이 아닌 id 사용 * refactor: CategoryUseCase 함수 인자 변경 - 레포지토리와 마찬가지로 필요한 값만 받도록 변경, Category 타입에 대한 의존성을 없앰 * refactor: TimeInterval 대신 Int 사용 * refactor: CategorySettingItem을 Category 타입을 받아서 생성 * feat: CategoryViewModel 생성 * feat: 타이머 뷰 컨트롤러와 카테고리 관리 뷰 연결 * feat: 카테고리 추가 버튼 탭 시 하단 시트 present 구현 * refactor: 카테고리 생성 후 생성된 카테고리 ID 반환하도록 수정 * feat: 카테고리 읽어오기 기능 추가 * feat: 새로운 카테고리 추가 기능 구현 feat: 새로운 카테고리 추가 기능 구현 * feat: categoryTitleTextVie의 텍스트 변경할 수 있는 함수 추가 * feat: 카테고리 생성 / 추가를 구분하는 enum 작성 * feat: 카테고리 수정 기능 구현 --- .../DefaultCategoryRepository.swift | 20 ++- .../Repositories/CategoryRepository.swift | 4 +- .../UseCases/DefaultCategoryUseCase.swift | 12 +- .../UseCases/Protocol/CategoryUseCase.swift | 5 +- .../View/CategoryListCollectionViewCell.swift | 1 - .../View/CategorySettingFooterView.swift | 29 ++++ .../View/CategoryTitleTextView.swift | 9 +- .../CategoryModifyViewController.swift | 161 ++++++++++++++---- .../CategorySettingViewController.swift | 134 +++++++++++++-- .../ViewController/TimerViewController.swift | 30 ++-- .../ViewModel/CategoryViewModel.swift | 77 +++++++-- 11 files changed, 384 insertions(+), 98 deletions(-) diff --git a/iOS/FlipMate/FlipMate/Data/Repositories/DefaultCategoryRepository.swift b/iOS/FlipMate/FlipMate/Data/Repositories/DefaultCategoryRepository.swift index a4576b4..8b3e101 100644 --- a/iOS/FlipMate/FlipMate/Data/Repositories/DefaultCategoryRepository.swift +++ b/iOS/FlipMate/FlipMate/Data/Repositories/DefaultCategoryRepository.swift @@ -14,35 +14,37 @@ final class DefaultCategoryRepository: CategoryRepository { self.provider = provider } - func createCategory(_ newCategory: Category) async throws { + func createCategory(name: String, colorCode: String) async throws -> Int { let categoryDTO = CategoryRequestDTO( - name: newCategory.subject, - colorCode: newCategory.color) + name: name, + colorCode: colorCode) let endpoint = CategoryEndpoints.createCategory(categoryDTO) let createdCategory = try await provider.request(with: endpoint) - FMLogger.general.log("카테고리 생성 완료 : \(String(describing: createdCategory))") + FMLogger.general.log("카테고리 생성 완료") + return createdCategory.categoryID } func readCategories() async throws -> [Category] { let endpoint = CategoryEndpoints.fetchCategories() let categories = try await provider.request(with: endpoint) + FMLogger.general.log("카테고리 읽기 완료") return categories.map { dto in Category(id: dto.categoryID, color: dto.colorCode, subject: dto.name, studyTime: 0) } } - func updateCategory(id: Int, to newCategory: Category) async throws { + func updateCategory(id: Int, newName: String, newColorCode: String) async throws { let categoryDTO = CategoryRequestDTO( - name: newCategory.subject, - colorCode: newCategory.color) + name: newName, + colorCode: newColorCode) let endpoint = CategoryEndpoints.updateCategory(id: id, category: categoryDTO) let updatedCategory = try await provider.request(with: endpoint) - FMLogger.general.log("카테고리 업데이트 완료 : \(String(describing: updatedCategory))") + FMLogger.general.log("카테고리 업데이트 완료") } func deleteCategory(id: Int) async throws { let endpoint = CategoryEndpoints.deleteCategory(id: id) let status = try await provider.request(with: endpoint) - FMLogger.general.log("카테고리 삭제 완료 : \(String(describing: status))") + FMLogger.general.log("카테고리 삭제 완료") } } diff --git a/iOS/FlipMate/FlipMate/Domain/Repositories/CategoryRepository.swift b/iOS/FlipMate/FlipMate/Domain/Repositories/CategoryRepository.swift index 057f008..cfdf3b7 100644 --- a/iOS/FlipMate/FlipMate/Domain/Repositories/CategoryRepository.swift +++ b/iOS/FlipMate/FlipMate/Domain/Repositories/CategoryRepository.swift @@ -8,8 +8,8 @@ import Foundation protocol CategoryRepository { - func createCategory(_ newCategory: Category) async throws + func createCategory(name: String, colorCode: String) async throws -> Int func readCategories() async throws -> [Category] - func updateCategory(id: Int, to newCategory: Category) async throws + func updateCategory(id: Int, newName: String, newColorCode: String) async throws func deleteCategory(id: Int) async throws } diff --git a/iOS/FlipMate/FlipMate/Domain/UseCases/DefaultCategoryUseCase.swift b/iOS/FlipMate/FlipMate/Domain/UseCases/DefaultCategoryUseCase.swift index d899fea..546fe7d 100644 --- a/iOS/FlipMate/FlipMate/Domain/UseCases/DefaultCategoryUseCase.swift +++ b/iOS/FlipMate/FlipMate/Domain/UseCases/DefaultCategoryUseCase.swift @@ -14,12 +14,16 @@ class DefaultCategoryUseCase: CategoryUseCase { self.repository = repository } - func createCategory(with category: Category) async throws { - try await repository.createCategory(category) + func createCategory(name: String, colorCode: String) async throws -> Int { + try await repository.createCategory(name: name, colorCode: colorCode) } - func updateCategory(of id: Int, to category: Category) async throws { - try await repository.updateCategory(id: id, to: category) + func readCategory() async throws -> [Category] { + return try await repository.readCategories() + } + + func updateCategory(of id: Int, newName: String, newColorCode: String) async throws { + try await repository.updateCategory(id: id, newName: newName, newColorCode: newColorCode) } func deleteCategory(of id: Int) async throws { diff --git a/iOS/FlipMate/FlipMate/Domain/UseCases/Protocol/CategoryUseCase.swift b/iOS/FlipMate/FlipMate/Domain/UseCases/Protocol/CategoryUseCase.swift index 5ab9d59..19c9edc 100644 --- a/iOS/FlipMate/FlipMate/Domain/UseCases/Protocol/CategoryUseCase.swift +++ b/iOS/FlipMate/FlipMate/Domain/UseCases/Protocol/CategoryUseCase.swift @@ -8,7 +8,8 @@ import Foundation protocol CategoryUseCase { - func createCategory(with category: Category) async throws - func updateCategory(of id: Int, to category: Category) async throws + func createCategory(name: String, colorCode: String) async throws -> Int + func readCategory() async throws -> [Category] + func updateCategory(of id: Int, newName: String, newColorCode: String) async throws func deleteCategory(of id: Int) async throws } diff --git a/iOS/FlipMate/FlipMate/Presentation/TimerScene/View/CategoryListCollectionViewCell.swift b/iOS/FlipMate/FlipMate/Presentation/TimerScene/View/CategoryListCollectionViewCell.swift index 5867539..1276949 100644 --- a/iOS/FlipMate/FlipMate/Presentation/TimerScene/View/CategoryListCollectionViewCell.swift +++ b/iOS/FlipMate/FlipMate/Presentation/TimerScene/View/CategoryListCollectionViewCell.swift @@ -101,4 +101,3 @@ private extension CategoryListCollectionViewCell { #Preview { CategoryListCollectionViewCell() } - diff --git a/iOS/FlipMate/FlipMate/Presentation/TimerScene/View/CategorySettingFooterView.swift b/iOS/FlipMate/FlipMate/Presentation/TimerScene/View/CategorySettingFooterView.swift index a49f205..46cf25d 100644 --- a/iOS/FlipMate/FlipMate/Presentation/TimerScene/View/CategorySettingFooterView.swift +++ b/iOS/FlipMate/FlipMate/Presentation/TimerScene/View/CategorySettingFooterView.swift @@ -6,6 +6,7 @@ // import UIKit +import Combine final class CategorySettingFooterView: UICollectionReusableView { // MARK: - Constant @@ -28,15 +29,30 @@ final class CategorySettingFooterView: UICollectionReusableView { return button }() + private lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(footerViewSelected)) + private var subject = PassthroughSubject() + var cancellable: AnyCancellable? + // MARK: - init override init(frame: CGRect) { super.init(frame: frame) configureUI() + configureGestureRecognizers() } required init?(coder: NSCoder) { fatalError("Don't use storyboard") } + + override func prepareForReuse() { + super.prepareForReuse() + cancellable?.cancel() + } + + func tapPublisher() -> AnyPublisher { + return subject + .eraseToAnyPublisher() + } } // MARK: - UI Setting @@ -52,3 +68,16 @@ private extension CategorySettingFooterView { ]) } } + +private extension CategorySettingFooterView { + func configureGestureRecognizers() { + print("tapgesture added") + tapGestureRecognizer.cancelsTouchesInView = false + self.addGestureRecognizer(tapGestureRecognizer) + } + + @objc + func footerViewSelected() { + subject.send() + } +} diff --git a/iOS/FlipMate/FlipMate/Presentation/TimerScene/View/CategoryTitleTextView.swift b/iOS/FlipMate/FlipMate/Presentation/TimerScene/View/CategoryTitleTextView.swift index a1b937c..c13b9b3 100644 --- a/iOS/FlipMate/FlipMate/Presentation/TimerScene/View/CategoryTitleTextView.swift +++ b/iOS/FlipMate/FlipMate/Presentation/TimerScene/View/CategoryTitleTextView.swift @@ -72,12 +72,19 @@ private extension CategoryTitleTextView { textView.text = "" textView.textColor = .label } - +} + +extension CategoryTitleTextView { func text() -> String? { guard !isShowPlaceholder else { return nil } return textView.text } + + func setText(text: String) { + textView.text = text + } } + @available(iOS 17.0, *) #Preview { CategoryTitleTextView(placeholder: "HELLO") diff --git a/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewController/CategoryModifyViewController.swift b/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewController/CategoryModifyViewController.swift index cacb65f..69bc1fb 100644 --- a/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewController/CategoryModifyViewController.swift +++ b/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewController/CategoryModifyViewController.swift @@ -7,23 +7,35 @@ import UIKit +enum CategoryPurpose { + case create + case update + + var title: String { + switch self { + case .create: return "카테고리 추가" + case .update: return "카테고리 수정" + } + } +} + final class CategoryModifyViewController: BaseViewController { - private enum ConstantString { - static let title = "카테고리 수정" + private enum Constant { static let leftNavigationBarItemTitle = "닫기" static let rightNavigationBarItemTitle = "완료" - static let sectionNames: [String] = ["카테고리 명", "색상"] - static let placeHolders: [String] = ["카테고리 이름을 입력해주세요", "색상을 선택해주세요"] + static let sectionNames: [String] = ["카테고리 이름", "카테고리 색상"] + static let placeHolders: [String] = ["이름을 입력해주세요", "색상을 선택해주세요"] } override func touchesBegan(_ touches: Set, with event: UIEvent?) { view.endEditing(true) } + // MARK: UI Components private lazy var firstSectionTitleLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false - label.text = ConstantString.sectionNames.first + label.text = Constant.sectionNames.first label.font = FlipMateFont.mediumBold.font label.textColor = .label @@ -33,7 +45,7 @@ final class CategoryModifyViewController: BaseViewController { private lazy var secondSectionTitleLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false - label.text = ConstantString.sectionNames.last + label.text = Constant.sectionNames.last label.font = FlipMateFont.mediumBold.font label.textColor = .label @@ -41,10 +53,12 @@ final class CategoryModifyViewController: BaseViewController { }() private lazy var categoryTitleTextView: CategoryTitleTextView = { - let textView = CategoryTitleTextView(placeholder: ConstantString.placeHolders[0]) + let textView = CategoryTitleTextView(placeholder: Constant.placeHolders[0]) textView.translatesAutoresizingMaskIntoConstraints = false textView.layer.masksToBounds = true textView.layer.cornerRadius = 6 + textView.layer.borderColor = FlipMateColor.gray2.color?.cgColor + textView.layer.borderWidth = 1 return textView }() @@ -54,10 +68,27 @@ final class CategoryModifyViewController: BaseViewController { colorView.translatesAutoresizingMaskIntoConstraints = false colorView.layer.masksToBounds = true colorView.layer.cornerRadius = 6 + colorView.layer.borderColor = FlipMateColor.gray2.color?.cgColor + colorView.layer.borderWidth = 1 return colorView }() + private let viewModel: CategoryViewModelProtocol + private let purpose: CategoryPurpose + private let category: Category? + + init(viewModel: CategoryViewModelProtocol, purpose: CategoryPurpose, category: Category? = nil) { + self.viewModel = viewModel + self.purpose = purpose + self.category = category + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError() + } + // MARK: View LifeCycle override func viewDidLoad() { super.viewDidLoad() @@ -66,54 +97,85 @@ final class CategoryModifyViewController: BaseViewController { // MARK: Configure UI override func configureUI() { - view.backgroundColor = FlipMateColor.gray4.color - let subViews = [firstSectionTitleLabel, - categoryTitleTextView, - secondSectionTitleLabel, - categoryColorSelectView - ] - + view.backgroundColor = .systemBackground + + let subViews = [ + firstSectionTitleLabel, + categoryTitleTextView, + secondSectionTitleLabel, + categoryColorSelectView + ] + subViews.forEach { - view.addSubview($0) - } + view.addSubview($0) + } NSLayoutConstraint.activate([ - firstSectionTitleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 60), - firstSectionTitleLabel.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor, constant: 32) + firstSectionTitleLabel.topAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + firstSectionTitleLabel.leftAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.leftAnchor, constant: 32) ]) NSLayoutConstraint.activate([ - categoryTitleTextView.topAnchor.constraint(equalTo: firstSectionTitleLabel.bottomAnchor, constant: 12), - categoryTitleTextView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 30), - categoryTitleTextView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -30) + categoryTitleTextView.topAnchor.constraint( + equalTo: firstSectionTitleLabel.bottomAnchor, constant: 12), + categoryTitleTextView.leadingAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 30), + categoryTitleTextView.trailingAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -30) ]) NSLayoutConstraint.activate([ - secondSectionTitleLabel.topAnchor.constraint(equalTo: categoryTitleTextView.bottomAnchor, constant: 60), - secondSectionTitleLabel.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor, constant: 32) + secondSectionTitleLabel.topAnchor.constraint( + equalTo: categoryTitleTextView.bottomAnchor, constant: 60), + secondSectionTitleLabel.leftAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.leftAnchor, constant: 32) ]) NSLayoutConstraint.activate([ - categoryColorSelectView.topAnchor.constraint(equalTo: secondSectionTitleLabel.bottomAnchor, constant: 12), - categoryColorSelectView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 30), - categoryColorSelectView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -30), - categoryColorSelectView.heightAnchor.constraint(equalTo: categoryTitleTextView.heightAnchor) + categoryColorSelectView.topAnchor.constraint( + equalTo: secondSectionTitleLabel.bottomAnchor, constant: 12), + categoryColorSelectView.leadingAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 30), + categoryColorSelectView.trailingAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -30), + categoryColorSelectView.heightAnchor.constraint( + equalTo: categoryTitleTextView.heightAnchor) ]) + + if purpose == .update { + guard let category = category else { + FMLogger.general.error("가져온 카테고리 없음 에러") + return + } + categoryTitleTextView.setText(text: category.subject) + } } } // MARK: Navigation Bar private extension CategoryModifyViewController { func setUpNavigation() { - navigationItem.title = ConstantString.title + title = purpose.title navigationItem.largeTitleDisplayMode = .never - setupNavigationBarButton() } func setupNavigationBarButton() { - let closeButton = UIBarButtonItem(title: ConstantString.leftNavigationBarItemTitle, style: .plain, target: self, action: #selector(closeButtonTapped)) - let doneButton = UIBarButtonItem(title: ConstantString.rightNavigationBarItemTitle, style: .done, target: self, action: #selector(doneButtonTapped)) + let closeButton = UIBarButtonItem( + title: Constant.leftNavigationBarItemTitle, + style: .plain, + target: self, + action: #selector(closeButtonTapped)) + let doneButton = UIBarButtonItem( + title: Constant.rightNavigationBarItemTitle, + style: .done, + target: self, + action: #selector(doneButtonTapped)) + + navigationItem.leftBarButtonItem = closeButton + navigationItem.rightBarButtonItem = doneButton } } @@ -124,10 +186,37 @@ private extension CategoryModifyViewController { } @objc func doneButtonTapped(_ sender: UIBarButtonItem) { - // TODO: 완료 버튼 눌렸을 때 동작 + // TODO: 색깔 선택 기능 미구현 + if purpose == .create { + Task { + do { + guard let categoryTitle = categoryTitleTextView.text() else { + FMLogger.general.error("빈 제목, 추가할 수 없음") + return + } + try await viewModel.createCategory(name: categoryTitle, colorCode: "FFFFFFFF") + dismiss(animated: true) + } catch let error { + FMLogger.general.error("카테고리 추가 중 에러 \(error)") + } + } + } else { + Task { + do { + guard let categoryTitle = categoryTitleTextView.text() else { + FMLogger.general.error("빈 제목, 추가할 수 없음") + return + } + guard let category = category else { + FMLogger.general.error("가져온 카테고리 없음 에러") + return + } + try await viewModel.updateCategory(of: category.id, newName: categoryTitle, newColorCode: "FFFFFFFF") + dismiss(animated: true) + } catch let error { + FMLogger.general.error("카테고리 추가 중 에러 \(error)") + } + } + } } } -@available(iOS 17.0, *) -#Preview { - CategoryModifyViewController() -} diff --git a/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewController/CategorySettingViewController.swift b/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewController/CategorySettingViewController.swift index 74cb21e..a3147a6 100644 --- a/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewController/CategorySettingViewController.swift +++ b/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewController/CategorySettingViewController.swift @@ -6,13 +6,18 @@ // import UIKit +import Combine final class CategorySettingViewController: BaseViewController { - typealias CateogoryDataSource = UICollectionViewDiffableDataSource - typealias Snapshot = NSDiffableDataSourceSnapshot - + typealias CategoryDataSource + = UICollectionViewDiffableDataSource + typealias Snapshot + = NSDiffableDataSourceSnapshot + // MARK: - Properties - private var dataSource: CateogoryDataSource? + private var dataSource: CategoryDataSource? + private let viewModel: CategoryViewModelProtocol + private var cancellables = Set() // MARK: - UI Components private lazy var collectionView: UICollectionView = { @@ -23,11 +28,25 @@ final class CategorySettingViewController: BaseViewController { return collectionView }() + // MARK: - Initializers + init(viewModel: CategoryViewModelProtocol) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("can't use this view controller in storyboard") + } + // MARK: - Life cycle override func viewDidLoad() { super.viewDidLoad() setDataSource() + setDelegate() setSnapshot() + Task { + await readCategories() + } } // MARK: - Configure UI @@ -41,24 +60,84 @@ final class CategorySettingViewController: BaseViewController { collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) ]) } + + override func bind() { + viewModel.presentingCategoryModifyViewControllerPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.createCategoryButtonTapped() + } + .store(in: &cancellables) + + viewModel.tappedCategoryDataPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] category in + self?.updateCategoryTapped(with: category) + } + .store(in: &cancellables) + + viewModel.categoriesPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] categories in + guard let self = self else { return } + guard var snapShot = self.dataSource?.snapshot() else { return } + snapShot.deleteAllItems() + snapShot.appendSections([.categorySection([])]) + snapShot.appendItems(categories.map { CategorySettingItem.categoryCell($0) }) + self.dataSource?.apply(snapShot) + } + .store(in: &cancellables) + } +} + +// MARK: - ViewModel Functions +private extension CategorySettingViewController { + func readCategories() async { + do { + try await viewModel.readCategories() + } catch let error { + FMLogger.general.error("카테고리 읽는 중 에러 발생 : \(error)") + } + } +} + +// MARK: - CollectionViewDelegate +extension CategorySettingViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + viewModel.categoryTapped(at: indexPath.row) + } } // MARK: - DiffableDataSource private extension CategorySettingViewController { func setDataSource() { - dataSource = CateogoryDataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in - switch itemIdentifier { - case .categoryCell(let category): - let cell: CategoryListCollectionViewCell = collectionView.dequeueReusableCell(for: indexPath) - cell.updateUI(category: category) - return cell - } - }) + dataSource = CategoryDataSource( + collectionView: collectionView, + cellProvider: { collectionView, indexPath, itemIdentifier in + switch itemIdentifier { + case .categoryCell(let category): + let cell: CategoryListCollectionViewCell = collectionView + .dequeueReusableCell(for: indexPath) + cell.updateUI(category: category) + return cell + } + }) - dataSource?.supplementaryViewProvider = { collectionView, kind, indexPath in - let footer: CategorySettingFooterView = collectionView.dequeueReusableView(for: indexPath, kind: kind) - return footer - } + dataSource? + .supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in + let footer: CategorySettingFooterView = collectionView + .dequeueReusableView(for: indexPath, kind: kind) + footer.cancellable = footer.tapPublisher() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.viewModel.createCategoryTapped() + } + return footer + } + } + + func setDelegate() { + collectionView.delegate = self } func setSnapshot() { @@ -67,6 +146,20 @@ private extension CategorySettingViewController { snapshot.appendSections(sections) dataSource?.apply(snapshot) } + + func createCategoryButtonTapped() { + let presentingViewController = CategoryModifyViewController( + viewModel: self.viewModel, purpose: .create) + let navController = UINavigationController(rootViewController: presentingViewController) + present(navController, animated: true) + } + + func updateCategoryTapped(with category: Category) { + let presentingViewController = CategoryModifyViewController( + viewModel: self.viewModel, purpose: .update, category: category) + let navController = UINavigationController(rootViewController: presentingViewController) + present(navController, animated: true) + } } // MARK: - CompositionalLayout @@ -87,7 +180,8 @@ private extension CategorySettingViewController { func setCollectionViewLayout() -> UICollectionViewCompositionalLayout { return UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in - guard let sectionType = self?.dataSource?.snapshot().sectionIdentifiers[sectionIndex] else { return nil } + guard let sectionType = self?.dataSource?.snapshot() + .sectionIdentifiers[sectionIndex] else { return nil } return self?.makeLayoutSection(sectionType: sectionType) } } @@ -95,5 +189,9 @@ private extension CategorySettingViewController { @available(iOS 17.0, *) #Preview { - CategorySettingViewController() + CategorySettingViewController( + viewModel: CategoryViewModel( + useCase: DefaultCategoryUseCase( + repository: DefaultCategoryRepository( + provider: Provider(urlSession: URLSession.shared))))) } diff --git a/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewController/TimerViewController.swift b/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewController/TimerViewController.swift index 8ab048c..cc929c5 100644 --- a/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewController/TimerViewController.swift +++ b/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewController/TimerViewController.swift @@ -62,7 +62,7 @@ final class TimerViewController: BaseViewController { button.tintColor = FlipMateColor.gray1.color button.layer.borderWidth = 1.0 button.layer.borderColor = FlipMateColor.gray1.color?.cgColor - button.layer.cornerRadius = 8.0 + button.layer.cornerRadius = 8.0 button.addTarget(self, action: #selector(categorySettingButtonDidTapped), for: .touchUpInside) return button }() @@ -72,7 +72,7 @@ final class TimerViewController: BaseViewController { image.image = UIImage(resource: .instruction) return image }() - + // MARK: - View LifeCycles override func viewDidLoad() { super.viewDidLoad() @@ -89,7 +89,7 @@ final class TimerViewController: BaseViewController { deviceMotionManager.stopDeviceMotion() UIDevice.current.isProximityMonitoringEnabled = false } - + // MARK: - setup UI override func configureUI() { let subViews = [timerLabel, @@ -97,14 +97,14 @@ final class TimerViewController: BaseViewController { categoryListCollectionView, categorySettingButton, instructionImage - ] - + ] + subViews.forEach { - view.addSubview($0) - $0.translatesAutoresizingMaskIntoConstraints = false - } - - NSLayoutConstraint.activate([ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + } + + NSLayoutConstraint.activate([ timerLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), timerLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor) ]) @@ -149,7 +149,7 @@ final class TimerViewController: BaseViewController { self.startHapticFeedback(isFaceDown) } .store(in: &cancellables) - + timerViewModel.totalTimePublisher .receive(on: DispatchQueue.main) .sink { [weak self] totalTime in @@ -165,7 +165,7 @@ final class TimerViewController: BaseViewController { self.pushtCategorySettingViewController() } .store(in: &cancellables) - + deviceMotionManager.orientationDidChangePublisher .receive(on: DispatchQueue.main) .sink { [weak self] newOrientation in @@ -219,7 +219,11 @@ private extension TimerViewController { } func pushtCategorySettingViewController() { - let categorySettingViewController = CategorySettingViewController() + let categorySettingViewController = CategorySettingViewController( + viewModel: CategoryViewModel( + useCase: DefaultCategoryUseCase( + repository: DefaultCategoryRepository( + provider: Provider(urlSession: URLSession.shared))))) navigationController?.pushViewController(categorySettingViewController, animated: true) } } diff --git a/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewModel/CategoryViewModel.swift b/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewModel/CategoryViewModel.swift index d79bc43..c0889ea 100644 --- a/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewModel/CategoryViewModel.swift +++ b/iOS/FlipMate/FlipMate/Presentation/TimerScene/ViewModel/CategoryViewModel.swift @@ -9,37 +9,90 @@ import Foundation import Combine protocol CategoryViewModelInput { - func addCategory(_ sender: Category) - func removeCategory(_ sender: Int) + func createCategoryTapped() + func categoryTapped(at index: Int) + func createCategory(name: String, colorCode: String) async throws + func readCategories() async throws + func updateCategory(of id: Int, newName: String, newColorCode: String) async throws + func deleteCategory(of id: Int) async throws } protocol CategoryViewModelOutput { + var presentingCategoryModifyViewControllerPublisher: AnyPublisher { get } + var tappedCategoryDataPublisher: AnyPublisher { get } var categoriesPublisher: AnyPublisher<[Category], Never> { get } } typealias CategoryViewModelProtocol = CategoryViewModelInput & CategoryViewModelOutput final class CategoryViewModel: CategoryViewModelProtocol { - // MARK: properties + private var presentingCategoryModifyViewControllerSubject = PassthroughSubject() + private var tappedCategoryDataSubject = PassthroughSubject() private var categoriesSubject = CurrentValueSubject<[Category], Never>([]) + var categories = [Category]() + + private let useCase: CategoryUseCase + + init(useCase: CategoryUseCase) { + self.useCase = useCase + } + // MARK: Output + var presentingCategoryModifyViewControllerPublisher: AnyPublisher { + return presentingCategoryModifyViewControllerSubject + .eraseToAnyPublisher() + } + var tappedCategoryDataPublisher: AnyPublisher { + return tappedCategoryDataSubject + .eraseToAnyPublisher() + } + var categoriesPublisher: AnyPublisher<[Category], Never> { return categoriesSubject.eraseToAnyPublisher() } // MARK: Input - func addCategory(_ sender: Category) { - var currentCategories = categoriesSubject.value - currentCategories.append(sender) - categoriesSubject.send(currentCategories) + func createCategoryTapped() { + presentingCategoryModifyViewControllerSubject.send() + } + + func categoryTapped(at index: Int) { + tappedCategoryDataSubject.send(categories[index]) + } + + func createCategory(name: String, colorCode: String) async throws { + let newCategoryID = try await useCase.createCategory(name: name, colorCode: colorCode) + categories.append(Category(id: newCategoryID, color: colorCode, subject: name)) + categoriesSubject.send(categories) + } + + func readCategories() async throws { + categories = try await useCase.readCategory() + categoriesSubject.send(categories) + } + + func updateCategory(of id: Int, newName: String, newColorCode: String) async throws { + try await useCase.updateCategory(of: id, newName: newName, newColorCode: newColorCode) + guard let index = categories.firstIndex(where: { $0.id == id }) else { + FMLogger.general.error("일치하는 id를 가진 카테고리를 찾을 수 없음") + return + } + + categories[index] = Category(id: id, color: newColorCode, subject: newName) + print(categories) + categoriesSubject.send(categories) } - func removeCategory(_ sender: Int) { - var currentCategories = categoriesSubject.value - guard currentCategories.indices.contains(sender) else { return } - currentCategories.remove(at: sender) - categoriesSubject.send(currentCategories) + func deleteCategory(of id: Int) async throws { + try await useCase.deleteCategory(of: id) + guard let index = categories.firstIndex(where: { $0.id == id }) else { + FMLogger.general.error("일치하는 id를 가진 카테고리를 찾을 수 없음") + return + } + + categories.remove(at: index) + categoriesSubject.send(categories) } }