From 1c842020838b394878f1b9b6553578faa0fdfe0e Mon Sep 17 00:00:00 2001 From: baegteun Date: Sat, 16 Aug 2025 18:44:21 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B1=85=20=EC=83=81=EC=84=B8=20Quote=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interactor/CreateQuoteInteractor.swift | 41 ++ .../Interactor/DeleteQuoteInteractor.swift | 33 + .../Interactor/UpdateQuoteInteractor.swift | 36 ++ .../AllQuotes/AllQuotesViewController.swift | 239 ++++++++ .../AllQuotes/QuoteTableViewCell.swift | 99 +++ .../BookDetail/AllQuotesViewController.swift | 122 ---- .../BookDetail/BookDetailViewController.swift | 565 ++++++++++++++---- ONMIR/Feature/Home/HomeViewController.swift | 7 +- .../QuoteEditorViewController.swift | 379 ++++++++++++ .../QuoteEditor/QuoteEditorViewModel.swift | 90 +++ 10 files changed, 1370 insertions(+), 241 deletions(-) create mode 100644 ONMIR/Domain/Quote/Interactor/CreateQuoteInteractor.swift create mode 100644 ONMIR/Domain/Quote/Interactor/DeleteQuoteInteractor.swift create mode 100644 ONMIR/Domain/Quote/Interactor/UpdateQuoteInteractor.swift create mode 100644 ONMIR/Feature/BookDetail/AllQuotes/AllQuotesViewController.swift create mode 100644 ONMIR/Feature/BookDetail/AllQuotes/QuoteTableViewCell.swift delete mode 100644 ONMIR/Feature/BookDetail/AllQuotesViewController.swift create mode 100644 ONMIR/Feature/QuoteEditor/QuoteEditorViewController.swift create mode 100644 ONMIR/Feature/QuoteEditor/QuoteEditorViewModel.swift diff --git a/ONMIR/Domain/Quote/Interactor/CreateQuoteInteractor.swift b/ONMIR/Domain/Quote/Interactor/CreateQuoteInteractor.swift new file mode 100644 index 0000000..9948551 --- /dev/null +++ b/ONMIR/Domain/Quote/Interactor/CreateQuoteInteractor.swift @@ -0,0 +1,41 @@ +import CoreData +import Foundation + +public struct CreateQuoteInteractor: Sendable { + private let contextManager: any CoreDataStack + + public init(contextManager: any CoreDataStack = ContextManager.shared) { + self.contextManager = contextManager + } + + public func callAsFunction(request: Request) async throws { + try await contextManager.performAndSave { @Sendable context in + let quoteEntity = QuoteEntity(context: context) + quoteEntity.content = request.content + quoteEntity.page = request.page + + let book = context.object(with: request.bookObjectID) as! BookEntity + quoteEntity.book = book + + context.insert(quoteEntity) + } + } +} + +extension CreateQuoteInteractor { + public struct Request: Sendable { + public let content: String + public let page: Int64 + public let bookObjectID: NSManagedObjectID + + public init( + content: String, + page: Int64, + bookObjectID: NSManagedObjectID + ) { + self.content = content + self.page = page + self.bookObjectID = bookObjectID + } + } +} \ No newline at end of file diff --git a/ONMIR/Domain/Quote/Interactor/DeleteQuoteInteractor.swift b/ONMIR/Domain/Quote/Interactor/DeleteQuoteInteractor.swift new file mode 100644 index 0000000..c5fa625 --- /dev/null +++ b/ONMIR/Domain/Quote/Interactor/DeleteQuoteInteractor.swift @@ -0,0 +1,33 @@ +import CoreData +import Foundation + +public struct DeleteQuoteInteractor: Sendable { + private let contextManager: any CoreDataStack + + public init(contextManager: any CoreDataStack = ContextManager.shared) { + self.contextManager = contextManager + } + + public func callAsFunction(request: Request) async throws { + try await contextManager.performAndSave { @Sendable context in + for objectID in request.quoteObjectIDs { + let quote = context.object(with: objectID) + context.delete(quote) + } + } + } +} + +extension DeleteQuoteInteractor { + public struct Request: Sendable { + public let quoteObjectIDs: [NSManagedObjectID] + + public init(quoteObjectIDs: [NSManagedObjectID]) { + self.quoteObjectIDs = quoteObjectIDs + } + + public init(quoteObjectID: NSManagedObjectID) { + self.quoteObjectIDs = [quoteObjectID] + } + } +} \ No newline at end of file diff --git a/ONMIR/Domain/Quote/Interactor/UpdateQuoteInteractor.swift b/ONMIR/Domain/Quote/Interactor/UpdateQuoteInteractor.swift new file mode 100644 index 0000000..c2baf13 --- /dev/null +++ b/ONMIR/Domain/Quote/Interactor/UpdateQuoteInteractor.swift @@ -0,0 +1,36 @@ +import CoreData +import Foundation + +public struct UpdateQuoteInteractor: Sendable { + private let contextManager: any CoreDataStack + + public init(contextManager: any CoreDataStack = ContextManager.shared) { + self.contextManager = contextManager + } + + public func callAsFunction(request: Request) async throws { + try await contextManager.performAndSave { @Sendable context in + let quote = context.object(with: request.quoteObjectID) as! QuoteEntity + quote.content = request.content + quote.page = request.page + } + } +} + +extension UpdateQuoteInteractor { + public struct Request: Sendable { + public let quoteObjectID: NSManagedObjectID + public let content: String + public let page: Int64 + + public init( + quoteObjectID: NSManagedObjectID, + content: String, + page: Int64 + ) { + self.quoteObjectID = quoteObjectID + self.content = content + self.page = page + } + } +} \ No newline at end of file diff --git a/ONMIR/Feature/BookDetail/AllQuotes/AllQuotesViewController.swift b/ONMIR/Feature/BookDetail/AllQuotes/AllQuotesViewController.swift new file mode 100644 index 0000000..57c60b0 --- /dev/null +++ b/ONMIR/Feature/BookDetail/AllQuotes/AllQuotesViewController.swift @@ -0,0 +1,239 @@ +import UIKit +import CoreData +import SnapKit + +final class AllQuotesViewController: UIViewController { + private let tableView = UITableView() + private let bookObjectID: NSManagedObjectID + private lazy var fetchedResultsController = makeFetchedResultsController() + private lazy var dataSource = makeDataSource() + + init(bookObjectID: NSManagedObjectID) { + self.bookObjectID = bookObjectID + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + + do { + try fetchedResultsController.performFetch() + updateSnapshot() + } catch { + print("Failed to fetch quotes: \(error)") + } + } + + private func setupUI() { + title = "All Quotes" + view.backgroundColor = .systemBackground + + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .add, + target: self, + action: #selector(addQuoteTapped) + ) + + tableView.delegate = self + tableView.register(QuoteTableViewCell.self, forCellReuseIdentifier: "QuoteCell") + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 80 + tableView.separatorStyle = .none + tableView.backgroundColor = .systemGroupedBackground + + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + @objc private func addQuoteTapped() { + guard let book = getBook() else { return } + + let quoteViewModel = QuoteEditorViewModel(book: book, editMode: .create) + let quoteViewController = QuoteEditorViewController(viewModel: quoteViewModel) { [weak self] in + // Refresh will happen automatically via NSFetchedResultsController + } + let navigationController = UINavigationController(rootViewController: quoteViewController) + + if let sheet = navigationController.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + } + + present(navigationController, animated: true) + } + + private func getBook() -> BookEntity? { + let context = ContextManager.shared.mainContext + return context.object(with: bookObjectID) as? BookEntity + } + + private func makeDataSource() -> UITableViewDiffableDataSource { + let dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, objectID in + let cell = tableView.dequeueReusableCell(withIdentifier: "QuoteCell", for: indexPath) as! QuoteTableViewCell + + guard let self = self else { return cell } + + let context = self.fetchedResultsController.managedObjectContext + + guard let quote = context.object(with: objectID) as? QuoteEntity else { + return cell + } + + cell.configure(with: quote) + + return cell + } + + tableView.dataSource = dataSource + return dataSource + } + + private func makeFetchedResultsController() -> NSFetchedResultsController { + let context = ContextManager.shared.mainContext + + guard let book = context.object(with: bookObjectID) as? BookEntity else { + assertionFailure("Book not found") + self.dismiss(animated: true) + return .init() + } + + let request: NSFetchRequest = QuoteEntity.fetchRequest() + request.predicate = NSPredicate(format: "book == %@", book) + request.sortDescriptors = [NSSortDescriptor(keyPath: \QuoteEntity.page, ascending: false)] + + let controller = NSFetchedResultsController( + fetchRequest: request, + managedObjectContext: context, + sectionNameKeyPath: nil, + cacheName: nil + ) + + controller.delegate = self + return controller + } + + private func updateSnapshot() { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + + let objectIDs = fetchedResultsController.fetchedObjects?.map { $0.objectID } ?? [] + snapshot.appendItems(objectIDs, toSection: 0) + + dataSource.apply(snapshot, animatingDifferences: true) + } +} + + +extension AllQuotesViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + guard let objectID = dataSource.itemIdentifier(for: indexPath), + let quote = fetchedResultsController.managedObjectContext.object(with: objectID) as? QuoteEntity, + let book = getBook() else { return } + + let quoteViewModel = QuoteEditorViewModel(book: book, editMode: .edit(quote)) + let quoteViewController = QuoteEditorViewController(viewModel: quoteViewModel) { [weak self] in + // Refresh will happen automatically via NSFetchedResultsController + } + let navigationController = UINavigationController(rootViewController: quoteViewController) + + if let sheet = navigationController.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + } + + present(navigationController, animated: true) + } + + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + guard let objectID = dataSource.itemIdentifier(for: indexPath), + let quote = fetchedResultsController.managedObjectContext.object(with: objectID) as? QuoteEntity else { + return nil + } + + let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, completion in + self?.showDeleteConfirmation(for: quote) + completion(true) + } + deleteAction.image = UIImage(systemName: "trash") + + let editAction = UIContextualAction(style: .normal, title: "Edit") { [weak self] _, _, completion in + guard let book = self?.getBook() else { + completion(false) + return + } + + let quoteViewModel = QuoteEditorViewModel(book: book, editMode: .edit(quote)) + let quoteViewController = QuoteEditorViewController(viewModel: quoteViewModel) { [weak self] in + // Refresh will happen automatically via NSFetchedResultsController + } + let navigationController = UINavigationController(rootViewController: quoteViewController) + + if let sheet = navigationController.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + } + + self?.present(navigationController, animated: true) + completion(true) + } + editAction.backgroundColor = .systemBlue + editAction.image = UIImage(systemName: "pencil") + + return UISwipeActionsConfiguration(actions: [deleteAction, editAction]) + } + + private func showDeleteConfirmation(for quote: QuoteEntity) { + let alert = UIAlertController( + title: "Delete Quote", + message: "Are you sure you want to delete this quote?", + preferredStyle: .alert + ) + + let deleteAction = UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in + self?.deleteQuote(quote) + } + + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) + + alert.addAction(deleteAction) + alert.addAction(cancelAction) + + present(alert, animated: true) + } + + private func deleteQuote(_ quote: QuoteEntity) { + Task { + do { + let deleteInteractor = DeleteQuoteInteractor() + let request = DeleteQuoteInteractor.Request(quoteObjectID: quote.objectID) + try await deleteInteractor(request: request) + } catch { + await MainActor.run { + let errorAlert = UIAlertController( + title: "Error", + message: "Failed to delete quote: \(error.localizedDescription)", + preferredStyle: .alert + ) + errorAlert.addAction(UIAlertAction(title: "OK", style: .default)) + self.present(errorAlert, animated: true) + } + } + } + } +} + +extension AllQuotesViewController: @preconcurrency NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + let typedSnapshot = snapshot as NSDiffableDataSourceSnapshot + dataSource.apply(typedSnapshot, animatingDifferences: true) + } +} diff --git a/ONMIR/Feature/BookDetail/AllQuotes/QuoteTableViewCell.swift b/ONMIR/Feature/BookDetail/AllQuotes/QuoteTableViewCell.swift new file mode 100644 index 0000000..d96bec5 --- /dev/null +++ b/ONMIR/Feature/BookDetail/AllQuotes/QuoteTableViewCell.swift @@ -0,0 +1,99 @@ +import UIKit +import SnapKit + +final class QuoteTableViewCell: UITableViewCell { + private let containerView = { + let view = UIView() + view.backgroundColor = .secondarySystemBackground + view.layer.cornerRadius = 12 + view.layer.masksToBounds = false + return view + }() + + private let quoteIconImageView = { + let imageView = UIImageView() + let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .bold) + imageView.image = UIImage(systemName: "quote.opening", withConfiguration: config) + imageView.tintColor = .secondaryLabel + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private let contentLabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 16, weight: .medium) + label.textColor = .label + label.numberOfLines = 0 + return label + }() + + private let pageLabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 14, weight: .regular) + label.textColor = .secondaryLabel + return label + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + contentLabel.text = nil + pageLabel.text = nil + } + + private func setupUI() { + backgroundColor = .clear + selectionStyle = .none + + contentView.addSubview(containerView) + + [quoteIconImageView, contentLabel, pageLabel].forEach { + containerView.addSubview($0) + } + + setupConstraints() + } + + private func setupConstraints() { + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16)) + } + + quoteIconImageView.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(16) + make.top.equalToSuperview().inset(16) + make.size.equalTo(24) + } + + contentLabel.snp.makeConstraints { make in + make.leading.equalTo(quoteIconImageView.snp.trailing).offset(12) + make.trailing.equalToSuperview().inset(16) + make.top.equalTo(quoteIconImageView.snp.top) + } + + pageLabel.snp.makeConstraints { make in + make.leading.trailing.equalTo(contentLabel) + make.top.equalTo(contentLabel.snp.bottom).offset(8) + make.bottom.equalToSuperview().inset(16) + } + } + + func configure(with quote: QuoteEntity) { + contentLabel.text = quote.content ?? "" + + let page = quote.page + if page > 0 { + pageLabel.text = "Page \(page)" + } else { + pageLabel.text = "No page specified" + } + } +} \ No newline at end of file diff --git a/ONMIR/Feature/BookDetail/AllQuotesViewController.swift b/ONMIR/Feature/BookDetail/AllQuotesViewController.swift deleted file mode 100644 index 23d199a..0000000 --- a/ONMIR/Feature/BookDetail/AllQuotesViewController.swift +++ /dev/null @@ -1,122 +0,0 @@ -import UIKit -import CoreData -import SnapKit - -#warning("TODO: UI") -final class AllQuotesViewController: UIViewController { - private let tableView = UITableView() - private let bookObjectID: NSManagedObjectID - private lazy var fetchedResultsController = makeFetchedResultsController() - private lazy var dataSource = makeDataSource() - - init(bookObjectID: NSManagedObjectID) { - self.bookObjectID = bookObjectID - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - setupUI() - - do { - try fetchedResultsController.performFetch() - updateSnapshot() - } catch { - print("Failed to fetch quotes: \(error)") - } - } - - private func setupUI() { - title = "All Quotes" - view.backgroundColor = .systemBackground - - tableView.delegate = self - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "QuoteCell") - tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 80 - - view.addSubview(tableView) - tableView.snp.makeConstraints { make in - make.edges.equalToSuperview() - } - } - - private func makeDataSource() -> UITableViewDiffableDataSource { - let dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, objectID in - let cell = tableView.dequeueReusableCell(withIdentifier: "QuoteCell", for: indexPath) - - guard let self = self - else { return cell } - - let context = self.fetchedResultsController.managedObjectContext - - guard - let quote = context.object(with: objectID) as? QuoteEntity - else { - return cell - } - - cell.textLabel?.text = quote.managedObjectContext?.performAndWait { quote.content } ?? "" - cell.detailTextLabel?.text = quote.managedObjectContext?.performAndWait { "\(quote.page) P" } ?? "" - cell.textLabel?.numberOfLines = 0 - cell.accessoryType = .disclosureIndicator - - return cell - } - - tableView.dataSource = dataSource - return dataSource - } - - private func makeFetchedResultsController() -> NSFetchedResultsController { - let context = ContextManager.shared.mainContext - - guard let book = context.object(with: bookObjectID) as? BookEntity else { - assertionFailure("Book not found") - self.dismiss(animated: true) - return .init() - } - - let request: NSFetchRequest = QuoteEntity.fetchRequest() - request.predicate = NSPredicate(format: "book == %@", book) - request.sortDescriptors = [NSSortDescriptor(keyPath: \QuoteEntity.page, ascending: false)] - - let controller = NSFetchedResultsController( - fetchRequest: request, - managedObjectContext: context, - sectionNameKeyPath: nil, - cacheName: nil - ) - - controller.delegate = self - return controller - } - - private func updateSnapshot() { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([0]) - - let objectIDs = fetchedResultsController.fetchedObjects?.map { $0.objectID } ?? [] - snapshot.appendItems(objectIDs, toSection: 0) - - dataSource.apply(snapshot, animatingDifferences: true) - } -} - - -extension AllQuotesViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - } -} - -extension AllQuotesViewController: @preconcurrency NSFetchedResultsControllerDelegate { - func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - let typedSnapshot = snapshot as NSDiffableDataSourceSnapshot - dataSource.apply(typedSnapshot, animatingDifferences: true) - } -} diff --git a/ONMIR/Feature/BookDetail/BookDetailViewController.swift b/ONMIR/Feature/BookDetail/BookDetailViewController.swift index 8f8d366..825e14b 100644 --- a/ONMIR/Feature/BookDetail/BookDetailViewController.swift +++ b/ONMIR/Feature/BookDetail/BookDetailViewController.swift @@ -91,9 +91,14 @@ final class BookDetailViewController: UIViewController { startStateObserving() viewModel.loadBook(with: bookObjectID) - registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (traitEnvironment: Self, previousTraitCollection) in - if previousTraitCollection.userInterfaceStyle != traitEnvironment.traitCollection.userInterfaceStyle { - traitEnvironment.gradientLayer.colors = Self.gradientColors.map(\.cgColor) + registerForTraitChanges([UITraitUserInterfaceStyle.self]) { + (traitEnvironment: Self, previousTraitCollection) in + if previousTraitCollection.userInterfaceStyle + != traitEnvironment.traitCollection.userInterfaceStyle + { + traitEnvironment.gradientLayer.colors = Self.gradientColors.map( + \.cgColor + ) } } } @@ -110,6 +115,7 @@ final class BookDetailViewController: UIViewController { view.backgroundColor = .systemBackground navigationItem.largeTitleDisplayMode = .never + setupNavigationBar() setupBackgroundView() view.addSubview(collectionView) @@ -118,6 +124,55 @@ final class BookDetailViewController: UIViewController { } } + private func setupNavigationBar() { + let closeButton = UIBarButtonItem( + image: .init(systemName: "xmark.circle.fill"), + primaryAction: UIAction { [weak self] _ in + self?.handleCloseButtonTapped() + } + ) + closeButton.tintColor = .systemGray5 + navigationItem.leftBarButtonItem = closeButton + + let moreMenu = UIMenu( + title: "", + children: [ + UIAction( + title: "Share Book", + image: UIImage(systemName: "square.and.arrow.up") + ) { [weak self] _ in + guard let self = self, let book = self.viewModel.book else { return } + self.shareBook(book) + }, + UIAction(title: "Rate Book", image: UIImage(systemName: "star")) { + [weak self] _ in + guard let self = self, let book = self.viewModel.book else { return } + self.rateBook(book) + }, + UIAction(title: "Edit Book", image: UIImage(systemName: "pencil")) { + [weak self] _ in + guard let self = self, let book = self.viewModel.book else { return } + self.editBook(book) + }, + UIAction( + title: "Delete Book", + image: UIImage(systemName: "trash"), + attributes: .destructive + ) { [weak self] _ in + guard let self = self, let book = self.viewModel.book else { return } + self.deleteBook(book) + }, + ] + ) + + let moreButton = UIBarButtonItem( + image: UIImage(systemName: "ellipsis.circle.fill"), + menu: moreMenu + ) + moreButton.tintColor = .systemGray5 + navigationItem.rightBarButtonItem = moreButton + } + private func setupBackgroundView() { view.addSubview(backgroundImageView) view.addSubview(blurEffectView) @@ -217,10 +272,12 @@ final class BookDetailViewController: UIViewController { snapshot.appendItems([.bookDetails(book)], toSection: .bookDetails) } - let logItems: [Item] = [.addRecord] + viewModel.recentReadingLogs.map { Item.readingLog($0) } + let logItems: [Item] = + [.addRecord] + viewModel.recentReadingLogs.map { Item.readingLog($0) } snapshot.appendItems(logItems, toSection: .readingLogs) - let quoteItems: [Item] = [.addQuote] + viewModel.recentQuotes.map { Item.quote($0) } + let quoteItems: [Item] = + [.addQuote] + viewModel.recentQuotes.map { Item.quote($0) } snapshot.appendItems(quoteItems, toSection: .quotes) dataSource.apply(snapshot, animatingDifferences: true) @@ -396,17 +453,21 @@ final class BookDetailViewController: UIViewController { return section } - private func createReadingLogsListSection(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection { + private func createReadingLogsListSection( + environment: NSCollectionLayoutEnvironment + ) -> NSCollectionLayoutSection { var configuration = UICollectionLayoutListConfiguration(appearance: .plain) configuration.showsSeparators = false configuration.backgroundColor = .clear - - configuration.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in + + configuration.trailingSwipeActionsConfigurationProvider = { + [weak self] indexPath in guard let self = self, - let item = self.dataSource.itemIdentifier(for: indexPath) else { - return nil + let item = self.dataSource.itemIdentifier(for: indexPath) + else { + return nil } - + switch item { case .readingLog(let readingLog): let editAction = UIContextualAction(style: .normal, title: "Edit") { _, _, completion in @@ -415,20 +476,26 @@ final class BookDetailViewController: UIViewController { } editAction.backgroundColor = .systemBlue editAction.image = UIImage(systemName: "pencil") - - let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { _, _, completion in + + let deleteAction = UIContextualAction( + style: .destructive, + title: "Delete" + ) { _, _, completion in self.showDeleteConfirmation(readingLogs: [readingLog]) completion(true) } deleteAction.image = UIImage(systemName: "trash") - + return UISwipeActionsConfiguration(actions: [deleteAction, editAction]) default: return nil } } - let section = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: environment) + let section = NSCollectionLayoutSection.list( + using: configuration, + layoutEnvironment: environment + ) section.contentInsets = NSDirectionalEdgeInsets( top: 0, leading: 0, @@ -450,39 +517,52 @@ final class BookDetailViewController: UIViewController { return section } - private func createQuotesListSection(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection { + private func createQuotesListSection( + environment: NSCollectionLayoutEnvironment + ) -> NSCollectionLayoutSection { var configuration = UICollectionLayoutListConfiguration(appearance: .plain) configuration.showsSeparators = false configuration.backgroundColor = .clear - - configuration.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in + + configuration.trailingSwipeActionsConfigurationProvider = { + [weak self] indexPath in guard let self = self, - let item = self.dataSource.itemIdentifier(for: indexPath) else { - return nil + let item = self.dataSource.itemIdentifier(for: indexPath) + else { + return nil } - + switch item { case .quote(let quote): - let editAction = UIContextualAction(style: .normal, title: "Edit") { _, _, completion in - #warning("TODO: Quote edit") + let editAction = UIContextualAction(style: .normal, title: "Edit") { + _, + _, + completion in + self.showEditQuote(quote) completion(true) } editAction.backgroundColor = .systemBlue editAction.image = UIImage(systemName: "pencil") - - let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { _, _, completion in - #warning("TODO: Quote delete") + + let deleteAction = UIContextualAction( + style: .destructive, + title: "Delete" + ) { _, _, completion in + self.showDeleteConfirmation(quotes: [quote]) completion(true) } deleteAction.image = UIImage(systemName: "trash") - + return UISwipeActionsConfiguration(actions: [deleteAction, editAction]) default: return nil } } - let section = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: environment) + let section = NSCollectionLayoutSection.list( + using: configuration, + layoutEnvironment: environment + ) section.contentInsets = NSDirectionalEdgeInsets( top: 0, leading: 0, @@ -551,14 +631,19 @@ final class BookDetailViewController: UIViewController { } extension BookDetailViewController: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath + ) { collectionView.deselectItem(at: indexPath, animated: true) - + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } - + switch item { case .readingLog(let readingLog): showEditReadingLog(readingLog) + case .quote(let quote): + showEditQuote(quote) default: break } @@ -570,21 +655,22 @@ extension BookDetailViewController: UICollectionViewDelegate { point: CGPoint ) -> UIContextMenuConfiguration? { guard !indexPaths.isEmpty else { return nil } - + let items = indexPaths.compactMap { dataSource.itemIdentifier(for: $0) } - + let readingLogs = items.compactMap { item -> ReadingLogEntity? in if case .readingLog(let log) = item { return log } return nil } - + let quotes = items.compactMap { item -> QuoteEntity? in if case .quote(let quote) = item { return quote } return nil } - + if readingLogs.count == items.count && !readingLogs.isEmpty { - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { + _ in let deleteAction = UIAction( title: "Delete \(readingLogs.count) Reading Logs", image: UIImage(systemName: "trash"), @@ -592,43 +678,47 @@ extension BookDetailViewController: UICollectionViewDelegate { ) { [weak self] _ in self?.showDeleteConfirmation(readingLogs: readingLogs) } - + return UIMenu(title: "", children: [deleteAction]) } } else if quotes.count == items.count && !quotes.isEmpty { - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { + _ in let deleteAction = UIAction( title: "Delete \(quotes.count) Quotes", image: UIImage(systemName: "trash"), attributes: .destructive ) { [weak self] _ in - #warning("TODO: Multiple quote delete") + self?.showDeleteConfirmation(quotes: quotes) } - + return UIMenu(title: "", children: [deleteAction]) } } - + return nil } - + func collectionView( _ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint ) -> UIContextMenuConfiguration? { - guard let item = dataSource.itemIdentifier(for: indexPath) else { return nil } - + guard let item = dataSource.itemIdentifier(for: indexPath) else { + return nil + } + switch item { case .readingLog(let readingLog): - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { + _ in let editAction = UIAction( title: "Edit", image: UIImage(systemName: "pencil") ) { [weak self] _ in self?.showEditReadingLog(readingLog) } - + let deleteAction = UIAction( title: "Delete", image: UIImage(systemName: "trash"), @@ -636,63 +726,72 @@ extension BookDetailViewController: UICollectionViewDelegate { ) { [weak self] _ in self?.showDeleteConfirmation(readingLogs: [readingLog]) } - + return UIMenu(title: "", children: [editAction, deleteAction]) } case .quote(let quote): - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { + _ in let editAction = UIAction( title: "Edit", image: UIImage(systemName: "pencil") ) { [weak self] _ in - #warning("TODO: Quote edit") + self?.showEditQuote(quote) } - + let deleteAction = UIAction( title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive ) { [weak self] _ in - #warning("TODO: Quote delete") + self?.showDeleteConfirmation(quotes: [quote]) } - + return UIMenu(title: "", children: [editAction, deleteAction]) } default: return nil } } - + func collectionView( _ collectionView: UICollectionView, contextMenuConfiguration configuration: UIContextMenuConfiguration, highlightPreviewForItemAt indexPath: IndexPath ) -> UITargetedPreview? { - guard let item = dataSource.itemIdentifier(for: indexPath) else { return nil } - + guard let item = dataSource.itemIdentifier(for: indexPath) else { + return nil + } + switch item { case .readingLog: - guard let cell = collectionView.cellForItem(at: indexPath) as? BookDetailViewController.ReadingLogCell, - let highlightView = cell.contextMenuHighlightView() else { + guard + let cell = collectionView.cellForItem(at: indexPath) + as? BookDetailViewController.ReadingLogCell, + let highlightView = cell.contextMenuHighlightView() + else { return nil } - + let parameters = UIPreviewParameters() parameters.backgroundColor = .clear - + return UITargetedPreview(view: highlightView, parameters: parameters) - + case .quote: - guard let cell = collectionView.cellForItem(at: indexPath) as? BookDetailViewController.QuoteCell, - let highlightView = cell.contextMenuHighlightView() else { + guard + let cell = collectionView.cellForItem(at: indexPath) + as? BookDetailViewController.QuoteCell, + let highlightView = cell.contextMenuHighlightView() + else { return nil } - + let parameters = UIPreviewParameters() parameters.backgroundColor = .clear - + return UITargetedPreview(view: highlightView, parameters: parameters) - + default: return nil } @@ -703,42 +802,52 @@ extension BookDetailViewController: UICollectionViewDelegate { contextMenuConfiguration configuration: UIContextMenuConfiguration, dismissalPreviewForItemAt indexPath: IndexPath ) -> UITargetedPreview? { - guard let item = dataSource.itemIdentifier(for: indexPath) else { return nil } - + guard let item = dataSource.itemIdentifier(for: indexPath) else { + return nil + } + switch item { case .readingLog: - guard let cell = collectionView.cellForItem(at: indexPath) as? BookDetailViewController.ReadingLogCell, - let highlightView = cell.contextMenuHighlightView() else { + guard + let cell = collectionView.cellForItem(at: indexPath) + as? BookDetailViewController.ReadingLogCell, + let highlightView = cell.contextMenuHighlightView() + else { return nil } - + let parameters = UIPreviewParameters() parameters.backgroundColor = .clear - + return UITargetedPreview(view: highlightView, parameters: parameters) - + case .quote: - guard let cell = collectionView.cellForItem(at: indexPath) as? BookDetailViewController.QuoteCell, - let highlightView = cell.contextMenuHighlightView() else { + guard + let cell = collectionView.cellForItem(at: indexPath) + as? BookDetailViewController.QuoteCell, + let highlightView = cell.contextMenuHighlightView() + else { return nil } - + let parameters = UIPreviewParameters() parameters.backgroundColor = .clear - + return UITargetedPreview(view: highlightView, parameters: parameters) - + default: return nil } } - + func collectionView( _ collectionView: UICollectionView, canPerformPrimaryActionForItemAt indexPath: IndexPath ) -> Bool { - guard let item = dataSource.itemIdentifier(for: indexPath) else { return false } - + guard let item = dataSource.itemIdentifier(for: indexPath) else { + return false + } + switch item { case .readingLog, .quote: return true @@ -746,21 +855,23 @@ extension BookDetailViewController: UICollectionViewDelegate { return false } } - + func collectionView( _ collectionView: UICollectionView, performPrimaryActionForItemAt indexPath: IndexPath ) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } - + switch item { case .readingLog(let readingLog): showEditReadingLog(readingLog) + case .quote(let quote): + showEditQuote(quote) default: break } } - + func scrollViewDidScroll(_ scrollView: UIScrollView) { let contentOffsetY = scrollView.contentOffset.y let fadeStartOffset: CGFloat = 100 @@ -789,7 +900,7 @@ extension BookDetailViewController: UICollectionViewDelegate { let allQuotesVC = AllQuotesViewController(bookObjectID: bookObjectID) navigationController?.pushViewController(allQuotesVC, animated: true) } - + private func handleAddAction(_ actionType: AddActionCell.ActionType) { switch actionType { case .newRecord: @@ -798,80 +909,113 @@ extension BookDetailViewController: UICollectionViewDelegate { showAddNewQuote() } } - + private func showAddNewRecord() { guard let book = viewModel.book else { return } - - let recordViewModel = BookRecordEditorViewModel(book: book, editMode: .create) - let recordViewController = BookRecordEditorViewController(viewModel: recordViewModel) { [weak self] in + + let recordViewModel = BookRecordEditorViewModel( + book: book, + editMode: .create + ) + let recordViewController = BookRecordEditorViewController( + viewModel: recordViewModel + ) { [weak self] in self?.refreshBookData() } - let navigationController = UINavigationController(rootViewController: recordViewController) - + let navigationController = UINavigationController( + rootViewController: recordViewController + ) + if let sheet = navigationController.sheetPresentationController { sheet.detents = [.medium(), .large()] sheet.prefersGrabberVisible = true } - + present(navigationController, animated: true) } - + private func showAddNewQuote() { - print("Add new quote tapped") + guard let book = viewModel.book else { return } + + let quoteViewModel = QuoteEditorViewModel(book: book, editMode: .create) + let quoteViewController = QuoteEditorViewController( + viewModel: quoteViewModel + ) { [weak self] in + self?.refreshBookData() + } + let navigationController = UINavigationController( + rootViewController: quoteViewController + ) + + if let sheet = navigationController.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + } + + present(navigationController, animated: true) } - + private func refreshBookData() { viewModel.loadBook(with: bookObjectID) } - + private func showEditReadingLog(_ readingLog: ReadingLogEntity) { guard let book = viewModel.book else { return } - - let recordViewModel = BookRecordEditorViewModel(book: book, editMode: .edit(readingLog)) - let recordViewController = BookRecordEditorViewController(viewModel: recordViewModel) { [weak self] in + + let recordViewModel = BookRecordEditorViewModel( + book: book, + editMode: .edit(readingLog) + ) + let recordViewController = BookRecordEditorViewController( + viewModel: recordViewModel + ) { [weak self] in self?.refreshBookData() } - let navigationController = UINavigationController(rootViewController: recordViewController) - + let navigationController = UINavigationController( + rootViewController: recordViewController + ) + if let sheet = navigationController.sheetPresentationController { sheet.detents = [.medium(), .large()] sheet.prefersGrabberVisible = true } - + present(navigationController, animated: true) } - + private func showDeleteConfirmation(readingLogs: [ReadingLogEntity]) { let count = readingLogs.count - let title = count == 1 ? "Delete Reading Log" : "Delete \(count) Reading Logs" - let message = count == 1 ? - "Are you sure you want to delete this reading log?" : - "Are you sure you want to delete these \(count) reading logs?" - + let title = + count == 1 ? "Delete Reading Log" : "Delete \(count) Reading Logs" + let message = + count == 1 + ? "Are you sure you want to delete this reading log?" + : "Are you sure you want to delete these \(count) reading logs?" + let alert = UIAlertController( title: title, message: message, preferredStyle: .alert ) - + let deleteAction = UIAlertAction( title: "Delete", style: .destructive ) { [weak self] _ in self?.deleteReadingLogs(readingLogs) } - + let cancelAction = UIAlertAction( title: "Cancel", style: .cancel ) - + alert.addAction(deleteAction) alert.addAction(cancelAction) - + present(alert, animated: true) } - + private func deleteReadingLogs(_ readingLogs: [ReadingLogEntity]) { Task { do { @@ -881,15 +1025,99 @@ extension BookDetailViewController: UICollectionViewDelegate { context.delete(objectToDelete) } } - + + await MainActor.run { + self.refreshBookData() + } + } catch { + await MainActor.run { + let message = + "Failed to delete reading logs: \(error.localizedDescription)" + + let errorAlert = UIAlertController( + title: "Error", + message: message, + preferredStyle: .alert + ) + errorAlert.addAction(UIAlertAction(title: "OK", style: .default)) + self.present(errorAlert, animated: true) + } + } + } + } + + private func showEditQuote(_ quote: QuoteEntity) { + guard let book = viewModel.book else { return } + + let quoteViewModel = QuoteEditorViewModel( + book: book, + editMode: .edit(quote) + ) + let quoteViewController = QuoteEditorViewController( + viewModel: quoteViewModel + ) { [weak self] in + self?.refreshBookData() + } + let navigationController = UINavigationController( + rootViewController: quoteViewController + ) + + if let sheet = navigationController.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + } + + present(navigationController, animated: true) + } + + private func showDeleteConfirmation(quotes: [QuoteEntity]) { + let count = quotes.count + let title = count == 1 ? "Delete Quote" : "Delete \(count) Quotes" + let message = + count == 1 + ? "Are you sure you want to delete this quote?" + : "Are you sure you want to delete these \(count) quotes?" + + let alert = UIAlertController( + title: title, + message: message, + preferredStyle: .alert + ) + + let deleteAction = UIAlertAction( + title: "Delete", + style: .destructive + ) { [weak self] _ in + self?.deleteQuotes(quotes) + } + + let cancelAction = UIAlertAction( + title: "Cancel", + style: .cancel + ) + + alert.addAction(deleteAction) + alert.addAction(cancelAction) + + present(alert, animated: true) + } + + private func deleteQuotes(_ quotes: [QuoteEntity]) { + Task { + do { + let deleteInteractor = DeleteQuoteInteractor() + let request = DeleteQuoteInteractor.Request( + quoteObjectIDs: quotes.map { $0.objectID } + ) + try await deleteInteractor(request: request) + await MainActor.run { self.refreshBookData() } } catch { await MainActor.run { - let count = readingLogs.count - let message = "Failed to delete reading logs: \(error.localizedDescription)" - + let message = "Failed to delete quotes: \(error.localizedDescription)" + let errorAlert = UIAlertController( title: "Error", message: message, @@ -901,6 +1129,111 @@ extension BookDetailViewController: UICollectionViewDelegate { } } } + + private func handleCloseButtonTapped() { + if let navigationController = navigationController, + navigationController.viewControllers.count > 1 + { + navigationController.popViewController(animated: true) + } else { + dismiss(animated: true) + } + } + + private func shareBook(_ book: BookEntity) { + var items: [Any] = [] + + if let title = book.title { + let shareText = "Check out this book: \(title)" + if let author = book.author { + items.append("\(shareText) by \(author)") + } else { + items.append(shareText) + } + } + + if let coverURL = book.coverImageURL { + items.append(coverURL) + } + + let activityViewController = UIActivityViewController( + activityItems: items, + applicationActivities: nil + ) + + if let popoverController = activityViewController + .popoverPresentationController + { + popoverController.barButtonItem = navigationItem.rightBarButtonItem + } + + present(activityViewController, animated: true) + } + + private func rateBook(_ book: BookEntity) { + #warning("TODO: Rate book") + } + + private func editBook(_ book: BookEntity) { + #warning("TODO: Edit book") + } + + private func deleteBook(_ book: BookEntity) { + let alert = UIAlertController( + title: "Delete Book", + message: + "Are you sure you want to delete this book? This will also delete all reading logs and quotes.", + preferredStyle: .alert + ) + + let deleteAction = UIAlertAction( + title: "Delete", + style: .destructive + ) { [weak self] _ in + self?.performBookDeletion(book) + } + + let cancelAction = UIAlertAction( + title: "Cancel", + style: .cancel + ) + + alert.addAction(deleteAction) + alert.addAction(cancelAction) + + present(alert, animated: true) + } + + private func performBookDeletion(_ book: BookEntity) { + Task { + do { + try await ContextManager.shared.performAndSave { context in + let objectToDelete = context.object(with: book.objectID) + context.delete(objectToDelete) + } + + await MainActor.run { + if let navigationController = navigationController, + navigationController.viewControllers.count > 1 + { + navigationController.popViewController(animated: true) + } else { + dismiss(animated: true) + } + } + } catch { + await MainActor.run { + let errorAlert = UIAlertController( + title: "Error", + message: "Failed to delete book: \(error.localizedDescription)", + preferredStyle: .alert + ) + errorAlert.addAction(UIAlertAction(title: "OK", style: .default)) + self.present(errorAlert, animated: true) + } + } + } + } } #Preview { diff --git a/ONMIR/Feature/Home/HomeViewController.swift b/ONMIR/Feature/Home/HomeViewController.swift index f2f76ad..a8754b9 100644 --- a/ONMIR/Feature/Home/HomeViewController.swift +++ b/ONMIR/Feature/Home/HomeViewController.swift @@ -71,8 +71,8 @@ public final class HomeViewController: UIViewController { view.backgroundColor = .secondarySystemBackground view.addSubview(bookListSectionRowView) - view.addSubview(addBookButton) view.addSubview(readingBookCollectionView) + view.addSubview(addBookButton) bookListSectionRowView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).inset(24) @@ -184,13 +184,14 @@ extension HomeViewController: UICollectionViewDelegate, UICollectionViewDataSour public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let book = viewModel.books[indexPath.item] let bookDetailVC = BookDetailViewController(bookObjectID: book.bookObjectID) + let navigationController = UINavigationController(rootViewController: bookDetailVC) if #available(iOS 18.0, *) { let zoomOption = UIViewController.Transition.ZoomOptions() - bookDetailVC.preferredTransition = .zoom(options: zoomOption, sourceViewProvider: { context in + navigationController.preferredTransition = .zoom(options: zoomOption, sourceViewProvider: { context in let cell = collectionView.cellForItem(at: indexPath) return cell }) } - self.present(bookDetailVC, animated: true) + self.present(navigationController, animated: true) } } diff --git a/ONMIR/Feature/QuoteEditor/QuoteEditorViewController.swift b/ONMIR/Feature/QuoteEditor/QuoteEditorViewController.swift new file mode 100644 index 0000000..9016f6e --- /dev/null +++ b/ONMIR/Feature/QuoteEditor/QuoteEditorViewController.swift @@ -0,0 +1,379 @@ +import SnapKit +import UIKit + +public final class QuoteEditorViewController: UIViewController { + private let scrollView = UIScrollView() + private let contentView = UIView() + + private let headerLabel = { + let label = UILabel() + label.text = "Quote" + label.font = UIFont.preferredFont(forTextStyle: .largeTitle) + label.textColor = .label + return label + }() + + private let quoteTextView = { + let textView = UITextView() + textView.font = UIFont.preferredFont(forTextStyle: .title3) + textView.textColor = .label + textView.backgroundColor = .systemBackground + textView.textContainer.lineFragmentPadding = 0 + textView.textContainerInset = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + textView.layer.cornerRadius = 16 + textView.layer.borderWidth = 1.0 + textView.layer.borderColor = UIColor.separator.cgColor + + textView.layer.shadowColor = UIColor.black.cgColor + textView.layer.shadowOffset = CGSize(width: 0, height: 1) + textView.layer.shadowOpacity = 0.05 + textView.layer.shadowRadius = 2 + + return textView + }() + + private let placeholderLabel = { + let label = UILabel() + label.text = "Enter your quote here..." + label.font = UIFont.preferredFont(forTextStyle: .title3) + label.textColor = .placeholderText + label.numberOfLines = 0 + return label + }() + + private let pageLabel = { + let label = UILabel() + label.text = "Page" + label.font = UIFont.preferredFont(forTextStyle: .subheadline) + label.textColor = .label + label.accessibilityLabel = "Page number" + return label + }() + + private let pageTextField = { + let textField = UITextField() + textField.font = UIFont.monospacedDigitSystemFont(ofSize: UIFont.preferredFont(forTextStyle: .title3).pointSize, weight: .semibold) + textField.textColor = .label + textField.keyboardType = .numberPad + textField.borderStyle = .none + textField.backgroundColor = .systemBackground + textField.layer.cornerRadius = 12 + textField.layer.borderWidth = 1.0 + textField.layer.borderColor = UIColor.separator.cgColor + textField.placeholder = "1" + textField.textAlignment = .center + textField.accessibilityLabel = "Page number" + textField.accessibilityHint = "Enter the page number for this quote" + + textField.layer.shadowColor = UIColor.black.cgColor + textField.layer.shadowOffset = CGSize(width: 0, height: 1) + textField.layer.shadowOpacity = 0.05 + textField.layer.shadowRadius = 2 + + textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 0)) + textField.leftViewMode = .always + textField.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 0)) + textField.rightViewMode = .always + + return textField + }() + + private let pageRangeLabel = { + let label = UILabel() + label.font = UIFont.preferredFont(forTextStyle: .caption1) + label.textColor = .secondaryLabel + label.textAlignment = .center + return label + }() + + private let doneButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("Done", for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) + button.backgroundColor = .label + button.setTitleColor(.systemBackground, for: .normal) + button.layer.cornerRadius = 12 + return button + }() + + private let viewModel: QuoteEditorViewModel + private let onSaveCompletion: (() -> Void)? + + init(viewModel: QuoteEditorViewModel, onSaveCompletion: (() -> Void)? = nil) { + self.viewModel = viewModel + self.onSaveCompletion = onSaveCompletion + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupNavigationBar() + setupActions() + updateUI() + updateDoneButtonState() + } + + private func setupUI() { + view.backgroundColor = .systemGroupedBackground + + view.addSubview(scrollView) + view.addSubview(doneButton) + scrollView.addSubview(contentView) + + contentView.addSubview(headerLabel) + contentView.addSubview(quoteTextView) + quoteTextView.addSubview(placeholderLabel) + contentView.addSubview(pageLabel) + contentView.addSubview(pageTextField) + contentView.addSubview(pageRangeLabel) + + setupConstraints() + } + + private func setupConstraints() { + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(doneButton.snp.top).offset(-24) + } + + contentView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalToSuperview() + } + + doneButton.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(20) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) + make.height.equalTo(50) + } + + // Header + headerLabel.snp.makeConstraints { make in + make.top.equalToSuperview().inset(32) + make.leading.equalToSuperview().inset(20) + } + + // Quote Text View + quoteTextView.snp.makeConstraints { make in + make.top.equalTo(headerLabel.snp.bottom).offset(24) + make.leading.trailing.equalToSuperview().inset(20) + make.height.greaterThanOrEqualTo(200) + } + + // Page section (Quote 입력 밑으로) + pageLabel.snp.makeConstraints { make in + make.top.equalTo(quoteTextView.snp.bottom).offset(24) + make.leading.equalToSuperview().inset(20) + } + + pageTextField.snp.makeConstraints { make in + make.top.equalTo(pageLabel.snp.bottom).offset(8) + make.leading.equalToSuperview().inset(20) + make.width.equalTo(90) + make.height.equalTo(44) + } + + pageRangeLabel.snp.makeConstraints { make in + make.top.equalTo(pageTextField.snp.bottom).offset(4) + make.leading.equalTo(pageTextField) + make.bottom.lessThanOrEqualToSuperview().inset(32) + } + + placeholderLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(20) + make.leading.trailing.equalToSuperview().inset(20) + } + } + + private func setupNavigationBar() { + switch viewModel.editMode { + case .create: + title = "New Quote" + case .edit: + title = "Edit Quote" + } + + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(handleCancelButtonTapped) + ) + + navigationItem.largeTitleDisplayMode = .never + } + + private func setupActions() { + doneButton.addAction(UIAction { [weak self] _ in self?.handleDoneButtonTapped() }, for: .primaryActionTriggered) + + quoteTextView.delegate = self + pageTextField.delegate = self + pageTextField.addAction(UIAction { [weak self] _ in self?.handlePageTextFieldChanged() }, for: .editingChanged) + + setupKeyboardToolbar() + } + + private func setupKeyboardToolbar() { + let toolbar = UIToolbar() + toolbar.sizeToFit() + + let flexSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleKeyboardDoneButtonTapped)) + + toolbar.setItems([flexSpace, doneButton], animated: false) + + quoteTextView.inputAccessoryView = toolbar + pageTextField.inputAccessoryView = toolbar + } + + @objc private func handleKeyboardDoneButtonTapped() { + view.endEditing(true) + } + + private func updateUI() { + quoteTextView.text = viewModel.content + pageTextField.text = "\(viewModel.page)" + + placeholderLabel.isHidden = !viewModel.content.isEmpty + + if viewModel.totalPages > 0 { + pageRangeLabel.text = "of \(viewModel.totalPages)" + } else { + pageRangeLabel.text = "No limit" + } + } + + @objc private func handleCancelButtonTapped() { + if viewModel.hasChanges { + let alert = UIAlertController( + title: "Discard Changes?", + message: "You have unsaved changes.", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Keep Editing", style: .cancel)) + alert.addAction(UIAlertAction(title: "Discard", style: .destructive) { _ in + self.dismiss(animated: true) + }) + + present(alert, animated: true) + } else { + dismiss(animated: true) + } + } + + private func handleDoneButtonTapped() { + guard viewModel.isValid else { return } + + Task { + do { + try await viewModel.save() + await MainActor.run { + self.dismiss(animated: true) { + self.onSaveCompletion?() + } + } + } catch { + await MainActor.run { + let alert = UIAlertController( + title: "Error", + message: "Failed to save quote: \(error.localizedDescription)", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + self.present(alert, animated: true) + } + } + } + } + + private func handlePageTextFieldChanged() { + guard let text = pageTextField.text, let page = Int(text) else { + viewModel.page = 1 + pageTextField.text = "1" + return + } + + let validPage = max(1, min(page, viewModel.totalPages > 0 ? viewModel.totalPages : Int.max)) + if validPage != page { + pageTextField.text = "\(validPage)" + } + + viewModel.page = validPage + updateDoneButtonState() + } + + + private func updateDoneButtonState() { + doneButton.isEnabled = viewModel.isValid + + if viewModel.isValid { + doneButton.backgroundColor = .label + doneButton.setTitleColor(.systemBackground, for: .normal) + doneButton.alpha = 1.0 + } else { + doneButton.backgroundColor = .tertiaryLabel + doneButton.setTitleColor(.secondaryLabel, for: .normal) + doneButton.alpha = 0.6 + } + } + +} + +extension QuoteEditorViewController: UITextViewDelegate { + public func textViewDidChange(_ textView: UITextView) { + viewModel.content = textView.text + placeholderLabel.isHidden = !textView.text.isEmpty + updateDoneButtonState() + } + + public func textViewDidBeginEditing(_ textView: UITextView) { + if textView == quoteTextView { + UIView.animate(withDuration: 0.2) { + textView.layer.borderColor = UIColor.label.cgColor + textView.layer.borderWidth = 1.5 + textView.layer.shadowOpacity = 0.1 + textView.layer.shadowRadius = 4 + } + } + } + + public func textViewDidEndEditing(_ textView: UITextView) { + if textView == quoteTextView { + UIView.animate(withDuration: 0.2) { + textView.layer.borderColor = UIColor.separator.cgColor + textView.layer.borderWidth = 1.0 + textView.layer.shadowOpacity = 0.05 + textView.layer.shadowRadius = 2 + } + } + } +} + +extension QuoteEditorViewController: UITextFieldDelegate { + public func textFieldDidBeginEditing(_ textField: UITextField) { + if textField == pageTextField { + UIView.animate(withDuration: 0.2) { + textField.layer.borderColor = UIColor.label.cgColor + textField.layer.borderWidth = 1.5 + textField.layer.shadowOpacity = 0.1 + textField.layer.shadowRadius = 4 + } + } + } + + public func textFieldDidEndEditing(_ textField: UITextField) { + if textField == pageTextField { + UIView.animate(withDuration: 0.2) { + textField.layer.borderColor = UIColor.separator.cgColor + textField.layer.borderWidth = 1.0 + textField.layer.shadowOpacity = 0.05 + textField.layer.shadowRadius = 2 + } + } + } +} diff --git a/ONMIR/Feature/QuoteEditor/QuoteEditorViewModel.swift b/ONMIR/Feature/QuoteEditor/QuoteEditorViewModel.swift new file mode 100644 index 0000000..c6a669c --- /dev/null +++ b/ONMIR/Feature/QuoteEditor/QuoteEditorViewModel.swift @@ -0,0 +1,90 @@ +import Foundation +import Observation + +@MainActor +@Observable +public final class QuoteEditorViewModel { + public enum EditMode: @unchecked Sendable { + case create + case edit(QuoteEntity) + } + + public let book: BookEntity + public let editMode: EditMode + + @ObservationIgnored + private let contextManager: ContextManager + + @ObservationIgnored + private let originalContent: String + @ObservationIgnored + private let originalPage: Int + + public var content: String = "" + public var page: Int = 1 + public var totalPages: Int = 0 + + public init(book: BookEntity, editMode: EditMode = .create, contextManager: ContextManager = .shared) { + self.book = book + self.editMode = editMode + self.contextManager = contextManager + self.totalPages = Int(book.pageCount) + + let initialContent: String + let initialPage: Int + + switch editMode { + case .create: + initialContent = "" + initialPage = 1 + + case .edit(let quote): + initialContent = quote.content ?? "" + initialPage = Int(quote.page) + } + + self.content = initialContent + self.page = initialPage + + self.originalContent = initialContent + self.originalPage = initialPage + } + + public var hasChanges: Bool { + return content != originalContent || + page != originalPage + } + + public var isValid: Bool { + return !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + page >= 1 && + page <= totalPages + } + + public func save() async throws { + let content = self.content.trimmingCharacters(in: .whitespacesAndNewlines) + let page = self.page + let book = self.book + let editMode = self.editMode + + switch editMode { + case .create: + let createInteractor = CreateQuoteInteractor(contextManager: contextManager) + let request = CreateQuoteInteractor.Request( + content: content, + page: Int64(page), + bookObjectID: book.objectID + ) + try await createInteractor(request: request) + + case .edit(let existingQuote): + let updateInteractor = UpdateQuoteInteractor(contextManager: contextManager) + let request = UpdateQuoteInteractor.Request( + quoteObjectID: existingQuote.objectID, + content: content, + page: Int64(page) + ) + try await updateInteractor(request: request) + } + } +} \ No newline at end of file