diff --git a/MemorialHouse/MHApplication/MHApplication/Source/App/SceneDelegate.swift b/MemorialHouse/MHApplication/MHApplication/Source/App/SceneDelegate.swift index 55bf0177..040df041 100644 --- a/MemorialHouse/MHApplication/MHApplication/Source/App/SceneDelegate.swift +++ b/MemorialHouse/MHApplication/MHApplication/Source/App/SceneDelegate.swift @@ -80,11 +80,18 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { MHLogger.error("\(error.localizedDescription)") } } - private func registerStorageDepedency() throws { DIContainer.shared.register(CoreDataStorage.self, object: CoreDataStorage()) let coreDataStorage = try DIContainer.shared.resolve(CoreDataStorage.self) + DIContainer.shared.register( + CoreDataBookCoverStorage.self, + object: CoreDataBookCoverStorage(coreDataStorage: coreDataStorage) + ) + DIContainer.shared.register( + CoreDataBookStorage.self, + object: CoreDataBookStorage(coreDataStorage: coreDataStorage) + ) DIContainer.shared.register( BookCategoryStorage.self, object: CoreDataBookCategoryStorage(coreDataStorage: coreDataStorage) @@ -101,6 +108,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { MemorialHouseNameStorage.self, object: UserDefaultsMemorialHouseNameStorage() ) + DIContainer.shared.register( + MHFileManager.self, + object: MHFileManager(directoryType: .documentDirectory) + ) } private func registerRepositoryDependency() throws { @@ -125,6 +136,11 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { BookRepository.self, object: LocalBookRepository(storage: bookStorage) ) + let fileManager = try DIContainer.shared.resolve(MHFileManager.self) + DIContainer.shared.register( + MediaRepository.self, + object: LocalMediaRepository(storage: fileManager) + ) } private func registerUseCaseDependency() throws { @@ -160,9 +176,11 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { // MARK: - Book UseCase let bookRepository = try DIContainer.shared.resolve(BookRepository.self) + let mediaRepository = try DIContainer.shared.resolve(MediaRepository.self) DIContainer.shared.register( CreateBookUseCase.self, - object: DefaultCreateBookUseCase(repository: bookRepository) + object: DefaultCreateBookUseCase(repository: bookRepository, + mediaRepository: mediaRepository) ) DIContainer.shared.register( FetchBookUseCase.self, @@ -191,6 +209,23 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { DeleteBookCoverUseCase.self, object: DefaultDeleteBookCoverUseCase(repository: bookCoverRepository) ) + // MARK: - EditBook UseCase + DIContainer.shared.register( + PersistentlyStoreMediaUseCase.self, + object: DefaultPersistentlyStoreMediaUseCase(repository: mediaRepository) + ) + DIContainer.shared.register( + CreateMediaUseCase.self, + object: DefaultCreateMediaUseCase(repository: mediaRepository) + ) + DIContainer.shared.register( + FetchMediaUseCase.self, + object: DefaultFetchMediaUseCase(repository: mediaRepository) + ) + DIContainer.shared.register( + DeleteMediaUseCase.self, + object: DefaultDeleteMediaUseCase(repository: mediaRepository) + ) } private func registerViewModelFactoryDependency() throws { @@ -243,5 +278,23 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { ReadPageViewModelFactory.self, object: ReadPageViewModelFactory() ) + + // MARK: - EditBook ViewModel + let updateBookUseCase = try DIContainer.shared.resolve(UpdateBookUseCase.self) + let storeMediaUseCase = try DIContainer.shared.resolve(PersistentlyStoreMediaUseCase.self) + let createMediaUseCase = try DIContainer.shared.resolve(CreateMediaUseCase.self) + let fetchMediaUseCase = try DIContainer.shared.resolve(FetchMediaUseCase.self) + let deleteMediaUseCase = try DIContainer.shared.resolve(DeleteMediaUseCase.self) + DIContainer.shared.register( + EditBookViewModelFactory.self, + object: EditBookViewModelFactory( + fetchBookUseCase: fetchBookUseCase, + updateBookUseCase: updateBookUseCase, + storeMediaUseCase: storeMediaUseCase, + createMediaUseCase: createMediaUseCase, + fetchMediaUseCase: fetchMediaUseCase, + deleteMediaUseCase: deleteMediaUseCase + ) + ) } } diff --git a/MemorialHouse/MHCore/MHCore/MHDataError.swift b/MemorialHouse/MHCore/MHCore/MHDataError.swift index 2dbc9d76..424c8db0 100644 --- a/MemorialHouse/MHCore/MHCore/MHDataError.swift +++ b/MemorialHouse/MHCore/MHCore/MHDataError.swift @@ -15,6 +15,8 @@ public enum MHDataError: Error, CustomStringConvertible, Equatable { case fileDeletionFailure case fileMovingFailure case fileNotExists + case snapshotEncodingFailure + case snapshotDecodingFailure case generalFailure case setUserDefaultFailure @@ -48,6 +50,10 @@ public enum MHDataError: Error, CustomStringConvertible, Equatable { "파일 이동 실패" case .fileNotExists: "파일이 존재하지 않습니다" + case .snapshotEncodingFailure: + "Snapshot 인코딩 실패" + case .snapshotDecodingFailure: + "Snapshot 디코딩 실패" case .generalFailure: "알 수 없는 에러입니다." case .setUserDefaultFailure: diff --git a/MemorialHouse/MHData/MHData/LocalStorage/FileManager/MHFileManager.swift b/MemorialHouse/MHData/MHData/LocalStorage/FileManager/MHFileManager.swift index 4294e354..31a11c93 100644 --- a/MemorialHouse/MHData/MHData/LocalStorage/FileManager/MHFileManager.swift +++ b/MemorialHouse/MHData/MHData/LocalStorage/FileManager/MHFileManager.swift @@ -1,17 +1,17 @@ import MHFoundation import MHCore -struct MHFileManager { - private let fileManager = FileManager.default +public struct MHFileManager: Sendable { + private var fileManager: FileManager { FileManager.default } private let directoryType: FileManager.SearchPathDirectory - init(directoryType: FileManager.SearchPathDirectory) { + public init(directoryType: FileManager.SearchPathDirectory) { self.directoryType = directoryType } } extension MHFileManager: FileStorage { - func create(at path: String, fileName name: String, data: Data) async -> Result { + public func create(at path: String, fileName name: String, data: Data) async -> Result { guard let directory = fileManager.urls( for: directoryType, in: .userDomainMask @@ -22,13 +22,13 @@ extension MHFileManager: FileStorage { do { try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - try data.write(to: dataPath) + try data.write(to: dataPath, options: .atomic) return .success(()) } catch { return .failure(.fileCreationFailure) } } - func read(at path: String, fileName name: String) async -> Result { + public func read(at path: String, fileName name: String) async -> Result { guard let directory = fileManager.urls( for: directoryType, in: .userDomainMask @@ -47,7 +47,7 @@ extension MHFileManager: FileStorage { return .failure(.fileReadingFailure) } } - func delete(at path: String, fileName name: String) async -> Result { + public func delete(at path: String, fileName name: String) async -> Result { guard let directory = fileManager.urls( for: directoryType, in: .userDomainMask @@ -63,7 +63,7 @@ extension MHFileManager: FileStorage { return .failure(.fileDeletionFailure) } } - func copy(at url: URL, to newPath: String, newFileName name: String) async -> Result { + public func copy(at url: URL, to newPath: String, newFileName name: String) async -> Result { let originDataPath = url guard fileManager.fileExists(atPath: originDataPath.path) else { @@ -86,7 +86,7 @@ extension MHFileManager: FileStorage { return .failure(.fileMovingFailure) } } - func copy(at path: String, fileName name: String, to newPath: String) async -> Result { + public func copy(at path: String, fileName name: String, to newPath: String) async -> Result { guard let originDirectory = fileManager.urls( for: directoryType, in: .userDomainMask @@ -115,7 +115,7 @@ extension MHFileManager: FileStorage { return .failure(.fileMovingFailure) } } - func move(at path: String, fileName name: String, to newPath: String) async -> Result { + public func move(at path: String, fileName name: String, to newPath: String) async -> Result { guard let originDirectory = fileManager.urls( for: directoryType, in: .userDomainMask @@ -144,7 +144,7 @@ extension MHFileManager: FileStorage { return .failure(.fileMovingFailure) } } - func moveAll(in path: String, to newPath: String) async -> Result { + public func moveAll(in path: String, to newPath: String) async -> Result { guard let originDirectory = fileManager.urls( for: directoryType, in: .userDomainMask @@ -174,7 +174,7 @@ extension MHFileManager: FileStorage { return .failure(.fileMovingFailure) } } - func getURL(at path: String, fileName name: String) async -> Result { + public func getURL(at path: String, fileName name: String) async -> Result { guard let originDirectory = fileManager.urls( for: directoryType, in: .userDomainMask @@ -185,5 +185,19 @@ extension MHFileManager: FileStorage { return .success(originDataPath) } + public func getFileNames(at path: String) async -> Result<[String], MHDataError> { + guard let originDirectory = fileManager.urls( + for: directoryType, + in: .userDomainMask + ).first?.appending(path: path) + else { return .failure(.directorySettingFailure) } + + do { + let files = try fileManager.contentsOfDirectory(atPath: originDirectory.path) + return .success(files) + } catch { + return .failure(.fileNotExists) + } + } } diff --git a/MemorialHouse/MHData/MHData/LocalStorage/FileStorage.swift b/MemorialHouse/MHData/MHData/LocalStorage/FileStorage.swift index 89b2f1fe..dd981155 100644 --- a/MemorialHouse/MHData/MHData/LocalStorage/FileStorage.swift +++ b/MemorialHouse/MHData/MHData/LocalStorage/FileStorage.swift @@ -1,7 +1,7 @@ import MHFoundation import MHCore -public protocol FileStorage { +public protocol FileStorage: Sendable { /// 지정된 경로에 파일을 생성합니다. /// Documents폴더에 파일을 생성합니다. /// 중간 경로 폴더를 자동으로 생성합니다. @@ -69,4 +69,12 @@ public protocol FileStorage { /// - name: Documents/{path}/{name} 이 파일 URL을 반환합니다. (확장자 명시 필요) /// - Returns: 파일 URL을 반환합니다. func getURL(at path: String, fileName name: String) async -> Result + + /// 지정된 경로의 파일 목록을 반환합니다. + /// Documents폴더를 기준으로 파일 이름 목록을 반환합니다. + /// path는 디렉토리여야 합니다. + /// - Parameters: + /// - path: Documents/{path} 이런식으로 들어갑니다 + /// - Returns: 파일 이름 목록을 반환합니다 + func getFileNames(at path: String) async -> Result<[String], MHDataError> } diff --git a/MemorialHouse/MHData/MHData/Repository/LocalMediaRepository.swift b/MemorialHouse/MHData/MHData/Repository/LocalMediaRepository.swift index e5930ef9..efc45fb4 100644 --- a/MemorialHouse/MHData/MHData/Repository/LocalMediaRepository.swift +++ b/MemorialHouse/MHData/MHData/Repository/LocalMediaRepository.swift @@ -5,8 +5,10 @@ import MHCore import AVFoundation // TODO: nil이라면 바로 error를 return하도록 수정 -public struct LocalMediaRepository: MediaRepository { +public struct LocalMediaRepository: MediaRepository, Sendable { private let storage: FileStorage + private let temporaryPath = "temp" // TODO: - 지워질 것임! + private let snapshotFileName = ".snapshot" public init(storage: FileStorage) { self.storage = storage @@ -18,34 +20,33 @@ public struct LocalMediaRepository: MediaRepository { to bookID: UUID? ) async -> Result { let path = bookID == nil - ? "temp" + ? temporaryPath : bookID!.uuidString - let fileName = mediaDescription.id.uuidString + let fileName = fileName(of: mediaDescription) return await storage.create(at: path, fileName: fileName, data: data) } - public func create( media mediaDescription: MediaDescription, from: URL, to bookID: UUID? ) async -> Result { let path = bookID == nil - ? "temp" + ? temporaryPath : bookID!.uuidString - let fileName = mediaDescription.id.uuidString + let fileName = fileName(of: mediaDescription) return await storage.copy(at: from, to: path, newFileName: fileName) } - public func read( + public func fetch( media mediaDescription: MediaDescription, from bookID: UUID? ) async -> Result { let path = bookID == nil - ? "temp" + ? temporaryPath : bookID!.uuidString - let fileName = mediaDescription.id.uuidString + let fileName = fileName(of: mediaDescription) return await storage.read(at: path, fileName: fileName) } @@ -55,9 +56,9 @@ public struct LocalMediaRepository: MediaRepository { at bookID: UUID? ) async -> Result { let path = bookID == nil - ? "temp" + ? temporaryPath : bookID!.uuidString - let fileName = mediaDescription.id.uuidString + let fileName = fileName(of: mediaDescription) return await storage.delete(at: path, fileName: fileName) } @@ -77,9 +78,9 @@ public struct LocalMediaRepository: MediaRepository { from bookID: UUID? ) async -> Result { let path = bookID == nil - ? "temp" + ? temporaryPath : bookID!.uuidString - let fileName = mediaDescription.id.uuidString + let fileName = fileName(of: mediaDescription) return await storage.getURL(at: path, fileName: fileName) } @@ -87,6 +88,39 @@ public struct LocalMediaRepository: MediaRepository { public func moveAllTemporaryMedia(to bookID: UUID) async -> Result { let path = bookID.uuidString - return await storage.moveAll(in: "temp", to: path) + return await storage.moveAll(in: temporaryPath, to: path) + } + + // MARK: - Snpashot + public func createSnapshot(for media: [MediaDescription], in bookID: UUID) async -> Result { + let path = bookID.uuidString + let mediaList = media.map { fileName(of: $0) } + guard let snapshot = try? JSONEncoder().encode(mediaList) + else { return .failure(.snapshotEncodingFailure) } + + return await storage.create(at: path, fileName: snapshotFileName, data: snapshot) + } + public func deleteMediaBySnapshot(for bookID: UUID) async -> Result { + let path = bookID.uuidString + + do { + let snapshotData = try await storage.read(at: path, fileName: snapshotFileName).get() + let mediaSet = Set(try JSONDecoder().decode([String].self, from: snapshotData)) + // snapshot 파일은 제외 + let currentFiles = Set(try await storage.getFileNames(at: path).get()).subtracting([snapshotFileName]) + let shouldDelete = currentFiles.subtracting(mediaSet) + for fileName in shouldDelete { + _ = try await storage.delete(at: path, fileName: fileName).get() + } + return .success(()) + } catch let error as MHDataError { + return .failure(error) + } catch { + return .failure(.generalFailure) + } + } + // MARK: - Helper + private func fileName(of media: MediaDescription) -> String { + return media.id.uuidString + media.type.defaultFileExtension } } diff --git a/MemorialHouse/MHDomain/MHDomain/Entity/MediaType.swift b/MemorialHouse/MHDomain/MHDomain/Entity/MediaType.swift index e2dad1f2..3432c90d 100644 --- a/MemorialHouse/MHDomain/MHDomain/Entity/MediaType.swift +++ b/MemorialHouse/MHDomain/MHDomain/Entity/MediaType.swift @@ -2,4 +2,17 @@ public enum MediaType: String, Sendable { case image case video case audio + + /// 기본 파일 확장자를 반환합니다. + /// 사진은 .png, 비디오는 .mp4, 오디오는 .m4a를 반환합니다. + public var defaultFileExtension: String { + switch self { + case .image: + return ".png" + case .video: + return ".mp4" + case .audio: + return ".m4a" + } + } } diff --git a/MemorialHouse/MHDomain/MHDomain/Repository/MediaRepository.swift b/MemorialHouse/MHDomain/MHDomain/Repository/MediaRepository.swift index b6756919..59fac929 100644 --- a/MemorialHouse/MHDomain/MHDomain/Repository/MediaRepository.swift +++ b/MemorialHouse/MHDomain/MHDomain/Repository/MediaRepository.swift @@ -2,12 +2,16 @@ import MHFoundation import MHCore import Photos -public protocol MediaRepository { +public protocol MediaRepository: Sendable { func create(media mediaDescription: MediaDescription, data: Data, to bookID: UUID?) async -> Result func create(media mediaDescription: MediaDescription, from: URL, to bookID: UUID?) async -> Result - func read(media mediaDescription: MediaDescription, from bookID: UUID?) async -> Result + func fetch(media mediaDescription: MediaDescription, from bookID: UUID?) async -> Result func getURL(media mediaDescription: MediaDescription, from bookID: UUID?) async -> Result func delete(media mediaDescription: MediaDescription, at bookID: UUID?) async -> Result func moveTemporaryMedia(_ mediaDescription: MediaDescription, to bookID: UUID) async -> Result func moveAllTemporaryMedia(to bookID: UUID) async -> Result + + // MARK: - Snapshot + func createSnapshot(for media: [MediaDescription], in bookID: UUID) async -> Result + func deleteMediaBySnapshot(for bookID: UUID) async -> Result } diff --git a/MemorialHouse/MHDomain/MHDomain/UseCase/DefaultBookUseCase.swift b/MemorialHouse/MHDomain/MHDomain/UseCase/DefaultBookUseCase.swift index 4b35c0ab..ea7d0df9 100644 --- a/MemorialHouse/MHDomain/MHDomain/UseCase/DefaultBookUseCase.swift +++ b/MemorialHouse/MHDomain/MHDomain/UseCase/DefaultBookUseCase.swift @@ -2,13 +2,16 @@ import MHFoundation public struct DefaultCreateBookUseCase: CreateBookUseCase { private let repository: BookRepository + private let mediaRepository: MediaRepository - public init(repository: BookRepository) { + public init(repository: BookRepository, mediaRepository: MediaRepository) { self.repository = repository + self.mediaRepository = mediaRepository } public func execute(book: Book) async throws { try await repository.create(book: book).get() + try await mediaRepository.createSnapshot(for: [], in: book.id).get() } } diff --git a/MemorialHouse/MHDomain/MHDomain/UseCase/DefaultMediaUseCase.swift b/MemorialHouse/MHDomain/MHDomain/UseCase/DefaultMediaUseCase.swift new file mode 100644 index 00000000..0f0241ed --- /dev/null +++ b/MemorialHouse/MHDomain/MHDomain/UseCase/DefaultMediaUseCase.swift @@ -0,0 +1,87 @@ +import MHFoundation +import MHCore + +public struct DefaultCreateMediaUseCase: CreateMediaUseCase, Sendable { + // MARK: - Property + let repository: MediaRepository + + // MARK: - Initializer + public init(repository: MediaRepository) { + self.repository = repository + } + + // MARK: - Method + public func execute(media: MediaDescription, data: Data, at bookID: UUID?) async throws { + try await repository.create(media: media, data: data, to: bookID).get() + } + public func execute(media: MediaDescription, from url: URL, at bookID: UUID?) async throws { + try await repository.create(media: media, from: url, to: bookID).get() + } +} + +public struct DefaultFetchMediaUseCase: FetchMediaUseCase { + // MARK: - Property + let repository: MediaRepository + + // MARK: - Initializer + public init(repository: MediaRepository) { + self.repository = repository + } + + // MARK: - Method + public func execute(media: MediaDescription, in bookID: UUID) async throws -> Data { + do { + return try await repository.fetch(media: media, from: nil).get() // TODO: - 없어질 로직 + } catch { + return try await repository.fetch(media: media, from: bookID).get() + } + } + public func execute(media: MediaDescription, in bookID: UUID) async throws -> URL { + do { + return try await repository.getURL(media: media, from: nil).get() // TODO: - 없어질 로직 + } catch { + return try await repository.getURL(media: media, from: bookID).get() + } + } +} + +public struct DefaultDeleteMediaUseCase: DeleteMediaUseCase { + // MARK: - Property + let repository: MediaRepository + + // MARK: - Initializer + public init(repository: MediaRepository) { + self.repository = repository + } + + // MARK: - Method + public func execute(media: MediaDescription, in bookID: UUID) async throws { + do { + return try await repository.delete(media: media, at: nil).get() // TODO: - 없어질 로직 + } catch { + return try await repository.delete(media: media, at: bookID).get() + } + } +} + +public struct DefaultPersistentlyStoreMediaUseCase: PersistentlyStoreMediaUseCase { + // MARK: - Property + let repository: MediaRepository + + // MARK: - Initializer + public init(repository: MediaRepository) { + self.repository = repository + } + + // MARK: - Method + public func execute(to bookID: UUID) async throws { // TODO: - 없어질 로직 + try await repository.moveAllTemporaryMedia(to: bookID).get() + } + public func execute(to bookID: UUID, mediaList: [MediaDescription]?) async throws { + if let mediaList { + try await repository.createSnapshot(for: mediaList, in: bookID).get() + } + + try await repository.deleteMediaBySnapshot(for: bookID).get() + } +} diff --git a/MemorialHouse/MHDomain/MHDomain/UseCase/Interface/MediaUseCase.swift b/MemorialHouse/MHDomain/MHDomain/UseCase/Interface/MediaUseCase.swift new file mode 100644 index 00000000..cb434449 --- /dev/null +++ b/MemorialHouse/MHDomain/MHDomain/UseCase/Interface/MediaUseCase.swift @@ -0,0 +1,23 @@ +import MHFoundation + +public protocol CreateMediaUseCase: Sendable { + func execute(media: MediaDescription, data: Data, at bookID: UUID?) async throws + func execute(media: MediaDescription, from url: URL, at bookID: UUID?) async throws +} + +public protocol FetchMediaUseCase: Sendable { + func execute(media: MediaDescription, in bookID: UUID) async throws -> Data + func execute(media: MediaDescription, in bookID: UUID) async throws -> URL +} + +public protocol DeleteMediaUseCase: Sendable { + func execute(media: MediaDescription, in bookID: UUID) async throws +} + +public protocol PersistentlyStoreMediaUseCase: Sendable { + @available(*, deprecated, message: "temp를 더이상 사용하지 않습니다.") + func execute(to bookID: UUID) async throws // TODO: - 없애야함 + /// mediaList가 없을 경우 현재 디렉토리의 스냅샷 기준으로 저장합니다. + /// mediaList가 있을 경우 해당 목록을 기준으로 저장합니다. + func execute(to bookID: UUID, mediaList: [MediaDescription]?) async throws +} diff --git a/MemorialHouse/MHPresentation/MHPresentation/Source/BookCreation/BookCreationViewController.swift b/MemorialHouse/MHPresentation/MHPresentation/Source/BookCreation/CreateBookViewController.swift similarity index 92% rename from MemorialHouse/MHPresentation/MHPresentation/Source/BookCreation/BookCreationViewController.swift rename to MemorialHouse/MHPresentation/MHPresentation/Source/BookCreation/CreateBookViewController.swift index 7d51888b..569954f0 100644 --- a/MemorialHouse/MHPresentation/MHPresentation/Source/BookCreation/BookCreationViewController.swift +++ b/MemorialHouse/MHPresentation/MHPresentation/Source/BookCreation/CreateBookViewController.swift @@ -1,7 +1,9 @@ import UIKit +import MHDomain // TODO: - 추후 로직에 따라 제거 필요 +import MHCore import Combine -final class BookCreationViewController: UIViewController { +final class CreateBookViewController: UIViewController { // MARK: - Constant static let maxTitleLength = 10 // MARK: - Property @@ -74,18 +76,19 @@ final class BookCreationViewController: UIViewController { return shadowLayer }() + // TODO: - 뷰모델 개선 필요 @Published - private var viewModel: BookCreationViewModel + private var viewModel: CreateBookViewModel private var cancellables: Set = [] // MARK: - Initializer - init(viewModel: BookCreationViewModel) { + init(viewModel: CreateBookViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { - viewModel = BookCreationViewModel() + viewModel = CreateBookViewModel() super.init(coder: coder) } @@ -210,12 +213,18 @@ final class BookCreationViewController: UIViewController { normal: normalAttributes, selected: selectedAttributes ) { [weak self] in - // TODO: - 추후 뷰모델 관련 생성 이슈 조정 필요 - let editBookViewModel = EditBookViewModel() - self?.navigationController?.pushViewController( - EditBookViewController(viewModel: editBookViewModel), - animated: true - ) + // TODO: - 추후 DIContainer resolve 실패 처리 필요 + // TODO: - bookID에 bookCoverID 넣어주기 필요 + Task { + guard let editBookViewModelFactory = try? DIContainer.shared.resolve(EditBookViewModelFactory.self) else { return } + let book = Book(id: .init(), title: "HIHI", pages: [.init(metadata: [:], text: "")]) + try? await DIContainer.shared.resolve(CreateBookUseCase.self).execute(book: book) + let viewModel = editBookViewModelFactory.make(bookID: book.id) + self?.navigationController?.pushViewController( + EditBookViewController(viewModel: viewModel), + animated: true + ) + } } } @@ -330,7 +339,7 @@ final class BookCreationViewController: UIViewController { } } -extension BookCreationViewController: UITextFieldDelegate { +extension CreateBookViewController: UITextFieldDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.resignFirstResponder() return true diff --git a/MemorialHouse/MHPresentation/MHPresentation/Source/BookCreation/BookCreationViewModel.swift b/MemorialHouse/MHPresentation/MHPresentation/Source/BookCreation/CreateBookViewModel.swift similarity index 94% rename from MemorialHouse/MHPresentation/MHPresentation/Source/BookCreation/BookCreationViewModel.swift rename to MemorialHouse/MHPresentation/MHPresentation/Source/BookCreation/CreateBookViewModel.swift index b850ab36..432868f8 100644 --- a/MemorialHouse/MHPresentation/MHPresentation/Source/BookCreation/BookCreationViewModel.swift +++ b/MemorialHouse/MHPresentation/MHPresentation/Source/BookCreation/CreateBookViewModel.swift @@ -2,7 +2,7 @@ import MHDomain import MHFoundation import Photos -struct BookCreationViewModel { +struct CreateBookViewModel { var bookTitle: String = "" var bookCategory: String = "" var previousColorNumber: Int = -1 diff --git a/MemorialHouse/MHPresentation/MHPresentation/Source/Design/MHPolaroidPhotoView.swift b/MemorialHouse/MHPresentation/MHPresentation/Source/Design/MHPolaroidPhotoView.swift index 0d1ff2ce..b2a7ca12 100644 --- a/MemorialHouse/MHPresentation/MHPresentation/Source/Design/MHPolaroidPhotoView.swift +++ b/MemorialHouse/MHPresentation/MHPresentation/Source/Design/MHPolaroidPhotoView.swift @@ -1,4 +1,5 @@ import UIKit +import MHDomain final class MHPolaroidPhotoView: UIView { // MARK: - UI Components @@ -78,3 +79,12 @@ final class MHPolaroidPhotoView: UIView { creationDateLabel.text = creationDate } } + +extension MHPolaroidPhotoView: @preconcurrency MediaAttachable { + func configureSource(with mediaDescription: MediaDescription, data: Data) { + photoImageView.image = UIImage(data: data) + } + func configureSource(with mediaDescription: MediaDescription, url: URL) { + photoImageView.image = UIImage(contentsOfFile: url.path) + } +} diff --git a/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/EditBookViewController.swift b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/EditBookViewController.swift index aa937236..50c270b6 100644 --- a/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/EditBookViewController.swift +++ b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/EditBookViewController.swift @@ -1,6 +1,8 @@ import UIKit +import MHCore import Combine +// TODO: - 페이지 없애는 기능 추가 final class EditBookViewController: UIViewController { // MARK: - Constant static let buttonBottomConstant: CGFloat = -20 @@ -53,10 +55,16 @@ final class EditBookViewController: UIViewController { return stackView }() - private let publishButton: UIButton = { + private let addPageButton: UIButton = { let button = UIButton() - button.setImage(.publishButton, for: .normal) - button.imageView?.contentMode = .scaleAspectFit + let title = NSAttributedString( + string: "페이지 추가", + attributes: [ + .font: UIFont.ownglyphBerry(size: 20), + .foregroundColor: UIColor.mhTitle + ] + ) + button.setAttributedTitle(title, for: .normal) button.backgroundColor = .clear return button @@ -75,8 +83,8 @@ final class EditBookViewController: UIViewController { super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { - self.viewModel = EditBookViewModel() - + guard let viewModel = try? DIContainer.shared.resolve(EditBookViewModelFactory.self) else { return nil } + self.viewModel = viewModel.make(bookID: .init()) super.init(coder: coder) } @@ -86,12 +94,12 @@ final class EditBookViewController: UIViewController { setup() configureNavigationBar() - configureSaveButton() configureAddSubView() configureConstraints() configureKeyboard() configureBinding() configureButtonAction() + input.send(.viewDidLoad) } // MARK: - Setup & Configuration @@ -125,6 +133,7 @@ final class EditBookViewController: UIViewController { ) alert.addAction(UIAlertAction(title: "취소", style: .cancel)) alert.addAction(UIAlertAction(title: "확인", style: .default) { _ in + self?.input.send(.didCancelButtonTapped) self?.navigationController?.popViewController(animated: true) }) self?.present(alert, animated: true) @@ -132,46 +141,26 @@ final class EditBookViewController: UIViewController { // 네비게이션 오른쪽 아이템 navigationItem.rightBarButtonItem = UIBarButtonItem( - title: "저장", + title: "기록 마치기", normal: normalAttributes, selected: selectedAttributes ) { [weak self] in - // TODO: - 저장하는 로직 - self?.navigationController?.popViewController(animated: true) - } - - // 네비게이션 타이틀 - // TODO: - ViewModel에서 받아오는 타이틀로 변경 - navigationItem.title = "책 제목" - } - private func configureSaveButton() { - // BookCreationViewController에서 넘어온 경우에만 저장 버튼 보여주기 - let isFromCreation = navigationController?.viewControllers - .contains { $0 is BookCreationViewController } ?? false - - if isFromCreation { - navigationItem.rightBarButtonItem = nil - publishButton.isHidden = false - } else { - publishButton.isHidden = true + self?.input.send(.didSaveButtonTapped) + self?.navigationController?.popToRootViewController(animated: true) } } private func configureAddSubView() { - // editPageTableView view.addSubview(editPageTableView) - // buttonStackView buttonStackView.addArrangedSubview(addImageButton) buttonStackView.addArrangedSubview(addTextButton) buttonStackView.addArrangedSubview(addVideoButton) buttonStackView.addArrangedSubview(addAudioButton) view.addSubview(buttonStackView) - // publishButton - view.addSubview(publishButton) + view.addSubview(addPageButton) } private func configureConstraints() { - // tableView editPageTableView.setAnchor( top: view.safeAreaLayoutGuide.topAnchor, leading: view.leadingAnchor, @@ -179,7 +168,6 @@ final class EditBookViewController: UIViewController { trailing: view.trailingAnchor ) - // buttonStackView buttonStackView.setAnchor( leading: editPageTableView.leadingAnchor, constantLeading: 10, height: 40 @@ -191,12 +179,9 @@ final class EditBookViewController: UIViewController { ) buttonStackViewBottomConstraint?.isActive = true - // publishButton - publishButton.setAnchor( + addPageButton.setAnchor( bottom: buttonStackView.bottomAnchor, - trailing: editPageTableView.trailingAnchor, constantTrailing: 15, - width: 55, - height: 40 + trailing: editPageTableView.trailingAnchor, constantTrailing: 15 ) } private func configureKeyboard() { @@ -212,23 +197,28 @@ final class EditBookViewController: UIViewController { name: UIResponder.keyboardWillHideNotification, object: nil ) + // 스크롤이 될 때 키보드 내려가게 설정 + editPageTableView.keyboardDismissMode = .onDrag } private func configureBinding() { - // TODO: - 추후 로직 추가하기 let output = viewModel.transform(input: input.eraseToAnyPublisher()) output.receive(on: DispatchQueue.main) .sink { [weak self] event in switch event { - case .setTableView: + case let .updateViewController(title): + self?.navigationItem.title = title self?.editPageTableView.reloadData() + case .error(message: let message): + MHLogger.error(message) // TODO: - Alert 띄우기 } } .store(in: &cancellables) } private func configureButtonAction() { - // TODO: - 로직을 정한다음에 Action 추가 let addImageAction = UIAction { [weak self] _ in - // TODO: - 이미지 추가 로직 + // TODO: - 이미지 받는 임시 로직 + guard let data = UIImage(resource: .bookMake).pngData() else { return } + self?.input.send(.didAddMediaWithData(type: .image, data: data)) } addImageButton.addAction(addImageAction, for: .touchUpInside) @@ -237,21 +227,15 @@ final class EditBookViewController: UIViewController { } addVideoButton.addAction(addVideoAction, for: .touchUpInside) - let addTextAction = UIAction { [weak self] _ in - // TODO: - 텍스트 추가로직??? - } - addTextButton.addAction(addTextAction, for: .touchUpInside) - let addAudioAction = UIAction { [weak self] _ in // TODO: - 오디오 추가 로직 } addAudioButton.addAction(addAudioAction, for: .touchUpInside) - let publishAction = UIAction { [weak self] _ in - // TODO: - 발행 로직 - self?.navigationController?.popViewController(animated: true) + let addPageAction = UIAction { [weak self] _ in + self?.input.send(.addPageButtonTapped) } - publishButton.addAction(publishAction, for: .touchUpInside) + addPageButton.addAction(addPageAction, for: .touchUpInside) } // MARK: - Keyboard Appear & Hide @@ -272,32 +256,27 @@ final class EditBookViewController: UIViewController { } } -// MARK: - UIScrollViewDelegate -extension EditBookViewController { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - view.endEditing(true) - } -} - // MARK: - UITableViewDelegate extension EditBookViewController: UITableViewDelegate { - + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return view.safeAreaLayoutGuide.layoutFrame.height - buttonStackView.frame.height - 40 + } } // MARK: - UITableViewDataSource extension EditBookViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return 10 // TODO: - 추후 더미데이터 대신 뷰모델 데이터로 변경 + return viewModel.numberOfPages() } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell( withIdentifier: EditPageCell.identifier, - for: indexPath - ) as? EditPageCell else { return UITableViewCell() } + for: indexPath) as? EditPageCell + else { return UITableViewCell() } + + let editPageViewModel = viewModel.editPageViewModel(at: indexPath.row) + cell.configure(viewModel: editPageViewModel) return cell } - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return view.frame.height - } } diff --git a/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/EditPageCell.swift b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/EditPageCell.swift index 4ef84eac..eae071b7 100644 --- a/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/EditPageCell.swift +++ b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/EditPageCell.swift @@ -1,6 +1,14 @@ import UIKit +import Combine +import MHDomain +import MHCore final class EditPageCell: UITableViewCell { + // MARK: - Constant + private let defaultAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.ownglyphBerry(size: 20), + .foregroundColor: UIColor.mhTitle + ] // MARK: - Property private let textView: UITextView = { let textView = UITextView() @@ -18,9 +26,10 @@ final class EditPageCell: UITableViewCell { return textView }() - private var textLayoutManager: NSTextLayoutManager? private var textStorage: NSTextStorage? - private var textContainer: NSTextContainer? + private var viewModel: EditPageViewModel? + private let input = PassthroughSubject() + private var cancellables = Set() // MARK: - Initializer override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -30,7 +39,6 @@ final class EditPageCell: UITableViewCell { configureAddSubView() configureConstraints() } - required init?(coder: NSCoder) { super.init(coder: coder) @@ -39,31 +47,260 @@ final class EditPageCell: UITableViewCell { configureConstraints() } + // MARK: - LifeCycle + override func prepareForReuse() { + super.prepareForReuse() + + input.send(.pageWillDisappear) + cancellables.forEach { $0.cancel() } + cancellables = [] + viewModel = nil + textView.text = "" + } + // MARK: - Setup & Configuration private func setup() { backgroundColor = .clear selectionStyle = .none - textLayoutManager = textView.textLayoutManager textStorage = textView.textStorage - textContainer = textView.textContainer + textStorage?.delegate = self + textView.delegate = self } private func configureAddSubView() { - addSubview(textView) + contentView.addSubview(textView) } private func configureConstraints() { textView.setAnchor( - top: topAnchor, constantTop: 10, - leading: leadingAnchor, constantLeading: 10, - bottom: bottomAnchor, constantBottom: 10, - trailing: trailingAnchor, constantTrailing: 10 + top: contentView.topAnchor, constantTop: 10, + leading: contentView.leadingAnchor, constantLeading: 10, + bottom: contentView.bottomAnchor, constantBottom: 10, + trailing: contentView.trailingAnchor, constantTrailing: 10 ) } + private func configureBinding() { + let output = viewModel?.transform(input: input.eraseToAnyPublisher()) + output? + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .page(let page): + self?.configurePage(page: page) + case let .mediaAddedWithData(media, data): + self?.mediaAddedWithData(media: media, data: data) + case let .mediaAddedWithURL(media, url): + self?.mediaAddedWithURL(media: media, url: url) + case let .mediaLoadedWithData(media, data): + self?.mediaLoadedWithData(media: media, data: data) + case let .mediaLoadedWithURL(media, url): + self?.mediaLoadedWithURL(media: media, url: url) + case let .error(message): + MHLogger.error(message) // 더 좋은 처리가 필요함 + } + }.store(in: &cancellables) + } - // MARK: - TouchEvent - override func touchesBegan(_ touches: Set, with event: UIEvent?) { - textView.becomeFirstResponder() + // MARK: - Method + func configure(viewModel: EditPageViewModel) { + self.viewModel = viewModel + configureBinding() + input.send(.pageWillAppear) + } + + // MARK: - Helper + private func configurePage(page: Page) { + let mergedText = mergeStorageInformation( + text: page.text, + attachmentMetaData: page.metadata + ) + textStorage?.setAttributedString(mergedText) + } + /// Text와 Attachment 정보를 하나의 문자열로 조합합니다. + private func mergeStorageInformation( + text: String, + attachmentMetaData: [Int: MediaDescription] + ) -> NSAttributedString { + let mutableAttributedString = NSMutableAttributedString(string: text) + attachmentMetaData.forEach { location, description in + let range = NSRange(location: location, length: 1) + let mediaAttachment = MediaAttachment( + view: MHPolaroidPhotoView(), // TODO: - 이거 바꿔줘야함... + description: description + ) + let attachmentString = NSAttributedString(attachment: mediaAttachment) + // Placeholder(공백) 교체 + mutableAttributedString.replaceCharacters(in: range, with: attachmentString) + input.send(.didRequestMediaDataForData(media: description)) + } + + mutableAttributedString.addAttributes(defaultAttributes, + range: NSRange(location: 0, length: mutableAttributedString.length)) - super.touchesBegan(touches, with: event) + return mutableAttributedString + } + private func mediaAddedWithData(media: MediaDescription, data: Data) { + let attachment = MediaAttachment( + view: MHPolaroidPhotoView(), // TODO: - 수정 필요 + description: media + ) + attachment.configure(with: data) + let text = NSMutableAttributedString(attachment: attachment) + text.addAttributes(defaultAttributes, + range: NSRange(location: 0, length: 1)) + textStorage?.append(text) + } + private func mediaAddedWithURL(media: MediaDescription, url: URL) { + let attachment = MediaAttachment( + view: MHPolaroidPhotoView(),// TODO: - 수정 필요 + description: media + ) + attachment.configure(with: url) + let text = NSMutableAttributedString(attachment: attachment) + text.addAttributes(defaultAttributes, + range: NSRange(location: 0, length: 1)) + textStorage?.append(text) + } + private func mediaLoadedWithData(media: MediaDescription, data: Data) { + let attachment = findAttachment(by: media) + attachment?.configure(with: data) + } + private func mediaLoadedWithURL(media: MediaDescription, url: URL) { + let attachment = findAttachment(by: media) + attachment?.configure(with: url) + } + /// Text에서 특정 Attachment를 찾아서 적용합니다. + private func findAttachment( + by media: MediaDescription + ) -> MediaAttachment? { + var attachment: MediaAttachment? + guard let textStorage else { return attachment } + textStorage + .enumerateAttribute( + .attachment, + in: NSRange(location: 0, length: textStorage.length) + ) { value, _, _ in + guard let mediaAttachment = value as? MediaAttachment, + mediaAttachment.mediaDescription.id == media.id else { return } + attachment = mediaAttachment + } + return attachment + } +} + +// MARK: - MediaAttachmentDataSource +extension EditPageCell: @preconcurrency MediaAttachmentDataSource { + func mediaAttachmentDragingImage(_ mediaAttachment: MediaAttachment, about view: UIView?) -> UIImage? { + view?.snapshotImage() + } +} + +// MARK: - UITextViewDelegate +extension EditPageCell: UITextViewDelegate { + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + guard let textStorage else { return false } + let attributedText = NSMutableAttributedString( + string: text, + attributes: defaultAttributes + ) + + // Attachment지우기 전에 드래그해서 알려주기 + if text.isEmpty && range.length == 1 + && attachmentAt(range.location) != nil + && textView.selectedRange.length == 0 { + textView.selectedRange = NSRange(location: range.location, length: 1) + return false + } + + return text.isEmpty + || isAcceptableHeight(textStorage, shouldChangeTextIn: range, replacementText: attributedText) + } + func textViewDidBeginEditing(_ textView: UITextView) { + input.send(.didBeginEditingPage) + } + /// TextView의 높이가 적절한지 확인합니다. + private func isAcceptableHeight( + _ textStorage: NSTextStorage, + shouldChangeTextIn range: NSRange, + replacementText attributedText: NSAttributedString + ) -> Bool { + let updatedText = NSMutableAttributedString(attributedString: textStorage) + let horizontalInset = textView.textContainerInset.left + textView.textContainerInset.right + let verticalInset = textView.textContainerInset.top + textView.textContainerInset.bottom + let textViewWidth = textView.bounds.width - horizontalInset + let textViewHight = textView.bounds.height - verticalInset + let temporaryTextView = UITextView( + frame: CGRect(x: 0, y: 0, width: textViewWidth, height: .greatestFiniteMagnitude) + ) + updatedText.replaceCharacters(in: range, with: attributedText) + temporaryTextView.attributedText = updatedText + temporaryTextView.sizeToFit() + + return temporaryTextView.contentSize.height <= textViewHight + } +} + +// MARK: - NSTextStorageDelegate +extension EditPageCell: @preconcurrency NSTextStorageDelegate { + func textStorage( + _ textStorage: NSTextStorage, + willProcessEditing editedMask: NSTextStorage.EditActions, + range editedRange: NSRange, + changeInLength delta: Int + ) { + // 입력하는 곳 앞에 Attachment가 있을 때, 줄바꿈을 추가합니다. + if editedRange.location - 1 > 0, delta > 0, + attachmentAt(editedRange.location - 1) != nil { + textStorage.insert( + NSAttributedString( + string: "\n", + attributes: defaultAttributes + ), + at: editedRange.location + ) + textView.selectedRange = NSRange(location: editedRange.location + 1, length: 0) + } + + // 입력하는 곳 뒤에 Attachment가 있을 때, 줄바꿈을 추가합니다. + let nextIndex = editedRange.location + editedRange.length + if nextIndex < textStorage.length, + let attachment = attachmentAt(nextIndex) { + attachment.cachedViewProvider = nil + // 입력하려는 문자끝에 \n이 있으면 아래 로직 무시 + guard textStorage.attributedSubstring( + from: NSRange(location: editedRange.location, length: 1) + ).string != "\n" else { return } + textStorage.insert( + NSAttributedString( + string: "\n", + attributes: defaultAttributes + ), + at: nextIndex + ) + } + } + func textStorage( + _ textStorage: NSTextStorage, + didProcessEditing editedMask: NSTextStorage.EditActions, + range editedRange: NSRange, + changeInLength delta: Int + ) { + let text = textStorage.attributedSubstring(from: editedRange) + + let nextIndex = editedRange.location + editedRange.length + if nextIndex < textStorage.length, editedRange.length >= 1, + let attachment = attachmentAt(nextIndex) { + attachment.cachedViewProvider = nil + } + if !isAcceptableHeight(textStorage, shouldChangeTextIn: editedRange, replacementText: text) { + // TODO: - 좀더 우아하게 처리하기? 미리 알려주는 로직으로... 개선필요 + textStorage.deleteCharacters(in: editedRange) + } + input.send(.didEditPage(attributedText: textStorage)) + } + // 그곳에 Attachment가 있는지 확인합니다. + private func attachmentAt(_ index: Int) -> MediaAttachment? { + guard let textStorage else { return nil } + guard index >= 0 && index < textStorage.length else { return nil } + return textStorage.attributes(at: index, effectiveRange: nil)[.attachment] as? MediaAttachment } } diff --git a/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/MediaAttachable.swift b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/MediaAttachable.swift new file mode 100644 index 00000000..d6cdc5ef --- /dev/null +++ b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/MediaAttachable.swift @@ -0,0 +1,7 @@ +import MHFoundation +import MHDomain + +protocol MediaAttachable { + func configureSource(with mediaDescription: MediaDescription, data: Data) + func configureSource(with mediaDescription: MediaDescription, url: URL) +} diff --git a/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/MediaAttachment.swift b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/MediaAttachment.swift new file mode 100644 index 00000000..fd62c0fc --- /dev/null +++ b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/MediaAttachment.swift @@ -0,0 +1,65 @@ +import UIKit +import AVKit +import MHDomain + +protocol MediaAttachmentDataSource: AnyObject { + func mediaAttachmentDragingImage(_ mediaAttachment: MediaAttachment, about view: UIView?) -> UIImage? +} + +final class MediaAttachment: NSTextAttachment { + // MARK: - Property + private let view: (UIView & MediaAttachable) + var cachedViewProvider: MediaAttachmentViewProvider? + let mediaDescription: MediaDescription + weak var dataSource: MediaAttachmentDataSource? + + // MARK: - Initializer + init(view: (UIView & MediaAttachable), description: MediaDescription) { + self.view = view + self.mediaDescription = description + super.init(data: nil, ofType: nil) + } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: - ViewConfigures + override func viewProvider( + for parentView: UIView?, + location: any NSTextLocation, + textContainer: NSTextContainer? + ) -> NSTextAttachmentViewProvider? { + if let provider = cachedViewProvider { + return provider + } + let provider = MediaAttachmentViewProvider( + textAttachment: self, + parentView: parentView, + textLayoutManager: textContainer?.textLayoutManager, + location: location + ) + provider.tracksTextAttachmentViewBounds = true + provider.view = view + provider.type = mediaDescription.type + cachedViewProvider = provider + + return provider + } + override func image( + forBounds imageBounds: CGRect, + textContainer: NSTextContainer?, + characterIndex charIndex: Int + ) -> UIImage? { + cachedViewProvider = nil + return dataSource?.mediaAttachmentDragingImage(self, about: view) + } + + // MARK: - Method + func configure(with data: Data) { + view.configureSource(with: mediaDescription, data: data) + } + func configure(with url: URL) { + view.configureSource(with: mediaDescription, url: url) + } +} diff --git a/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/MediaAttachmentViewProvider.swift b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/MediaAttachmentViewProvider.swift new file mode 100644 index 00000000..01e87a74 --- /dev/null +++ b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/View/MediaAttachmentViewProvider.swift @@ -0,0 +1,31 @@ +import UIKit +import MHDomain + +final class MediaAttachmentViewProvider: NSTextAttachmentViewProvider { + // MARK: - Property + var type: MediaType? + private var height: CGFloat { + switch type { // TODO: - 조정 필요 + case .image: + 300 + case .video: + 200 + case .audio: + 100 + case nil: + 10 + default: + 100 + } + } + + override func attachmentBounds( + for attributes: [NSAttributedString.Key: Any], + location: NSTextLocation, + textContainer: NSTextContainer?, + proposedLineFragment: CGRect, + position: CGPoint + ) -> CGRect { + return CGRect(x: 0, y: 0, width: proposedLineFragment.width, height: height) + } +} diff --git a/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/ViewModel/EditBookViewModel.swift b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/ViewModel/EditBookViewModel.swift index 81764860..fce00d40 100644 --- a/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/ViewModel/EditBookViewModel.swift +++ b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/ViewModel/EditBookViewModel.swift @@ -1,31 +1,170 @@ +import MHFoundation import Combine +import MHDomain +import MHCore final class EditBookViewModel: ViewModelType { // MARK: - Type enum Input { case viewDidLoad + case didAddMediaWithData(type: MediaType, data: Data) + case didAddMediaInURL(type: MediaType, url: URL) + case addPageButtonTapped + case didSaveButtonTapped + case didCancelButtonTapped } enum Output { - case setTableView + case updateViewController(title: String) + case error(message: String) } // MARK: - Property private let output = PassthroughSubject() private var cancellables = Set() - private var pages = [String]() + private let fetchBookUseCase: FetchBookUseCase + private let updateBookUseCase: UpdateBookUseCase + private let storeMediaUseCase: PersistentlyStoreMediaUseCase + private let createMediaUseCase: CreateMediaUseCase + private let fetchMediaUseCase: FetchMediaUseCase + private let deleteMediaUseCase: DeleteMediaUseCase + private let bookID: UUID + private var title: String = "" + private var editPageViewModels: [EditPageViewModel] = [] + private var currentPageIndex = 0 - // MARK: - Method + // MARK: - Initializer + init( + fetchBookUseCase: FetchBookUseCase, + updateBookUseCase: UpdateBookUseCase, + storeMediaUseCase: PersistentlyStoreMediaUseCase, + createMediaUseCase: CreateMediaUseCase, + fetchMediaUseCase: FetchMediaUseCase, + deleteMediaUseCase: DeleteMediaUseCase, + bookID: UUID + ) { + self.fetchBookUseCase = fetchBookUseCase + self.updateBookUseCase = updateBookUseCase + self.storeMediaUseCase = storeMediaUseCase + self.createMediaUseCase = createMediaUseCase + self.fetchMediaUseCase = fetchMediaUseCase + self.deleteMediaUseCase = deleteMediaUseCase + self.bookID = bookID + } + + // MARK: - Binding Method func transform(input: AnyPublisher) -> AnyPublisher { input.sink { [weak self] event in switch event { case .viewDidLoad: - self?.fetchPages() + Task { await self?.fetchBook() } + case let .didAddMediaWithData(type, data): + Task { await self?.addMedia(type: type, with: data) } + case let .didAddMediaInURL(type, url): + Task { await self?.addMedia(type: type, in: url) } + case .addPageButtonTapped: + self?.addEmptyPage() + case .didSaveButtonTapped: + Task { await self?.saveMediaAll() } + case .didCancelButtonTapped: + Task { await self?.revokeMediaAll() } } }.store(in: &cancellables) return output.eraseToAnyPublisher() } - private func fetchPages() { - // TODO: - Page가져오는 로직 추가 + private func fetchBook() async { + do { + let book = try await fetchBookUseCase.execute(id: bookID) + title = book.title + editPageViewModels = book.pages.map { page in + let editPageViewModel = EditPageViewModel( + fetchMediaUseCase: fetchMediaUseCase, + deleteMediaUseCase: deleteMediaUseCase, + bookID: bookID, + page: page + ) + editPageViewModel.delegate = self + return editPageViewModel + } + output.send(.updateViewController(title: title)) + } catch { + output.send(.error(message: "책을 가져오는데 실패했습니다.")) + MHLogger.error(error.localizedDescription + #function) + } + } + private func addMedia(type: MediaType, with data: Data) async { + let description = MediaDescription( + id: UUID(), + type: type + ) + do { + try await createMediaUseCase.execute(media: description, data: data, at: bookID) + editPageViewModels[currentPageIndex].addMedia(media: description, data: data) + } catch { + output.send(.error(message: "미디어를 추가하는데 실패했습니다.")) + MHLogger.error(error.localizedDescription + #function) + } + } + private func addMedia(type: MediaType, in url: URL) async { + let description = MediaDescription( + id: UUID(), + type: type + ) + do { + try await createMediaUseCase.execute(media: description, from: url, at: bookID) + editPageViewModels[currentPageIndex].addMedia(media: description, url: url) + } catch { + output.send(.error(message: "미디어를 추가하는데 실패했습니다.")) + MHLogger.error(error.localizedDescription + #function) + } + } + private func addEmptyPage() { + let page = Page(id: UUID(), metadata: [:], text: "") + let editPageViewModel = EditPageViewModel( + fetchMediaUseCase: fetchMediaUseCase, + deleteMediaUseCase: deleteMediaUseCase, + bookID: bookID, + page: page + ) + editPageViewModel.delegate = self + editPageViewModels.append(editPageViewModel) + output.send(.updateViewController(title: title)) + } + private func saveMediaAll() async { + let pages = editPageViewModels.map { $0.page } + let book = Book(id: bookID, title: title, pages: pages) + let mediaList = pages.flatMap { $0.metadata.values } + do { + try await updateBookUseCase.execute(id: bookID, book: book) + try await storeMediaUseCase.execute(to: bookID, mediaList: mediaList) + } catch { + output.send(.error(message: "책을 저장하는데 실패했습니다.")) + MHLogger.error(error.localizedDescription + #function) + } + } + private func revokeMediaAll() async { + do { + try await storeMediaUseCase.execute(to: bookID, mediaList: nil) + } catch { + output.send(.error(message: "저장 취소하는데 실패했습니다.")) + MHLogger.error(error.localizedDescription + #function) + } + } + + // MARK: - Method For ViewController + func numberOfPages() -> Int { + return editPageViewModels.count + } + func editPageViewModel(at index: Int) -> EditPageViewModel { + let editPageViewModel = editPageViewModels[index] + + return editPageViewModel + } +} + +extension EditBookViewModel: EditPageViewModelDelegate { + func didBeginEditingPage(_ editPageViewModel: EditPageViewModel, page: Page) { + let pageID = page.id + currentPageIndex = editPageViewModels.firstIndex { $0.page.id == pageID } ?? 0 } } diff --git a/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/ViewModel/EditBookViewModelFactory.swift b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/ViewModel/EditBookViewModelFactory.swift new file mode 100644 index 00000000..69509fb1 --- /dev/null +++ b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/ViewModel/EditBookViewModelFactory.swift @@ -0,0 +1,39 @@ +import MHFoundation +import MHDomain + +public struct EditBookViewModelFactory { + private let fetchBookUseCase: FetchBookUseCase + private let updateBookUseCase: UpdateBookUseCase + private let storeMediaUseCase: PersistentlyStoreMediaUseCase + private let createMediaUseCase: CreateMediaUseCase + private let fetchMediaUseCase: FetchMediaUseCase + private let deleteMediaUseCase: DeleteMediaUseCase + + public init( + fetchBookUseCase: FetchBookUseCase, + updateBookUseCase: UpdateBookUseCase, + storeMediaUseCase: PersistentlyStoreMediaUseCase, + createMediaUseCase: CreateMediaUseCase, + fetchMediaUseCase: FetchMediaUseCase, + deleteMediaUseCase: DeleteMediaUseCase + ) { + self.fetchBookUseCase = fetchBookUseCase + self.updateBookUseCase = updateBookUseCase + self.storeMediaUseCase = storeMediaUseCase + self.createMediaUseCase = createMediaUseCase + self.fetchMediaUseCase = fetchMediaUseCase + self.deleteMediaUseCase = deleteMediaUseCase + } + + func make(bookID: UUID) -> EditBookViewModel { + EditBookViewModel( + fetchBookUseCase: fetchBookUseCase, + updateBookUseCase: updateBookUseCase, + storeMediaUseCase: storeMediaUseCase, + createMediaUseCase: createMediaUseCase, + fetchMediaUseCase: fetchMediaUseCase, + deleteMediaUseCase: deleteMediaUseCase, + bookID: bookID + ) + } +} diff --git a/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/ViewModel/EditPageViewModel.swift b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/ViewModel/EditPageViewModel.swift new file mode 100644 index 00000000..5e714647 --- /dev/null +++ b/MemorialHouse/MHPresentation/MHPresentation/Source/EditBook/ViewModel/EditPageViewModel.swift @@ -0,0 +1,139 @@ +import MHFoundation +import Combine +import MHDomain +import MHCore + +protocol EditPageViewModelDelegate: AnyObject { + func didBeginEditingPage(_ editPageViewModel: EditPageViewModel, page: Page) +} + +final class EditPageViewModel: ViewModelType { + // MARK: - Type + enum Input { + case pageWillAppear + case pageWillDisappear + case didBeginEditingPage + case didEditPage(attributedText: NSAttributedString) + case didRequestMediaDataForData(media: MediaDescription) + case didRequestMediaDataForURL(media: MediaDescription) + } + enum Output { + case page(page: Page) + case mediaAddedWithData(media: MediaDescription, data: Data) + case mediaAddedWithURL(media: MediaDescription, url: URL) + case mediaLoadedWithData(media: MediaDescription, data: Data) + case mediaLoadedWithURL(media: MediaDescription, url: URL) + case error(message: String) + } + + // MARK: - Property + private let output = PassthroughSubject() + private var cancellables = Set() + private let fetchMediaUseCase: FetchMediaUseCase + private let deleteMediaUseCase: DeleteMediaUseCase + weak var delegate: EditPageViewModelDelegate? + private let bookID: UUID + private(set) var page: Page + + // MARK: - Initializer + init( + fetchMediaUseCase: FetchMediaUseCase, + deleteMediaUseCase: DeleteMediaUseCase, + bookID: UUID, + page: Page + ) { + self.fetchMediaUseCase = fetchMediaUseCase + self.deleteMediaUseCase = deleteMediaUseCase + self.bookID = bookID + self.page = page + } + + // MARK: - Binding Method + func transform(input: AnyPublisher) -> AnyPublisher { + input.sink { [weak self] event in + switch event { + case .pageWillAppear: + self?.pageWillAppear() + case .pageWillDisappear: + self?.pageWillDisappear() + case .didBeginEditingPage: + self?.didBeginEditingPage() + case .didEditPage(let attributedText): + self?.didEditPage(text: attributedText) + case .didRequestMediaDataForData(let media): + Task { await self?.loadMediaForData(media: media) } + case .didRequestMediaDataForURL(let media): + Task { await self?.loadMediaForURL(media: media) } + } + }.store(in: &cancellables) + + return output.eraseToAnyPublisher() + } + private func pageWillAppear() { + output.send(.page(page: page)) + } + private func pageWillDisappear() { + cancellables.forEach { $0.cancel() } + cancellables = [] + } + private func didEditPage(text: NSAttributedString) { + let page = converTextToPage(text: text) + self.page = page + } + private func loadMediaForData(media: MediaDescription) async { + do { + let mediaData: Data = try await fetchMediaUseCase.execute(media: media, in: bookID) + output.send(.mediaLoadedWithData(media: media, data: mediaData)) + } catch { + output.send(.error(message: "미디어 로딩에 실패하였습니다.")) + MHLogger.error(error.localizedDescription + #function) + } + } + private func loadMediaForURL(media: MediaDescription) async { + do { + let mediaURL: URL = try await fetchMediaUseCase.execute(media: media, in: bookID) + output.send(.mediaLoadedWithURL(media: media, url: mediaURL)) + } catch { + output.send(.error(message: "미디어 로딩에 실패하였습니다.")) + MHLogger.error(error.localizedDescription + #function) + } + } + + // MARK: - Method + func addMedia(media: MediaDescription, data: Data) { + output.send(.mediaAddedWithData(media: media, data: data)) + } + func addMedia(media: MediaDescription, url: URL) { + output.send(.mediaAddedWithURL(media: media, url: url)) + } + func didBeginEditingPage() { + delegate?.didBeginEditingPage(self, page: page) + } + + // MARK: - Helper + private func converTextToPage(text: NSAttributedString) -> Page { + let (savedText, metadata) = separateStorageInformation(text) + let newPage = Page( + id: page.id, + metadata: metadata, + text: savedText + ) + return newPage + } + /// NSAttributedString에서 Text와 Attachment 정보를 추출해냅니다. + private func separateStorageInformation( + _ text: NSAttributedString + ) -> (String, [Int: MediaDescription]) { + var metaData = [Int: MediaDescription]() + let mutableAttributedString = NSMutableAttributedString(attributedString: text) + + text.enumerateAttribute(.attachment, in: NSRange(location: 0, length: text.length)) { value, range, _ in + if let mediaAttachment = value as? MediaAttachment { + metaData[range.location] = mediaAttachment.mediaDescription + // Placeholder로 텍스트 대체 + mutableAttributedString.replaceCharacters(in: range, with: " ") + } + } + return (mutableAttributedString.string, metaData) + } +} diff --git a/MemorialHouse/MHPresentation/MHPresentation/Source/Extensions/UIView+SnapshotImage.swift b/MemorialHouse/MHPresentation/MHPresentation/Source/Extensions/UIView+SnapshotImage.swift new file mode 100644 index 00000000..ed6a23ce --- /dev/null +++ b/MemorialHouse/MHPresentation/MHPresentation/Source/Extensions/UIView+SnapshotImage.swift @@ -0,0 +1,10 @@ +import UIKit + +extension UIView { + func snapshotImage() -> UIImage? { + let renderer = UIGraphicsImageRenderer(bounds: bounds) + return renderer.image { context in + layer.render(in: context.cgContext) + } + } +} diff --git a/MemorialHouse/MHPresentation/MHPresentation/Source/Home/HomeViewController.swift b/MemorialHouse/MHPresentation/MHPresentation/Source/Home/HomeViewController.swift index b44beab5..ee880f4d 100644 --- a/MemorialHouse/MHPresentation/MHPresentation/Source/Home/HomeViewController.swift +++ b/MemorialHouse/MHPresentation/MHPresentation/Source/Home/HomeViewController.swift @@ -170,7 +170,7 @@ public final class HomeViewController: UIViewController { } private func moveMakingBookViewController() { - let bookCreationViewController = BookCreationViewController(viewModel: BookCreationViewModel()) + let bookCreationViewController = CreateBookViewController(viewModel: CreateBookViewModel()) navigationController?.pushViewController(bookCreationViewController, animated: true) }