-
Notifications
You must be signed in to change notification settings - Fork 0
책 상세 Quote 액션 추가 #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/book-redcord-edit
Are you sure you want to change the base?
책 상세 Quote 액션 추가 #26
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| } | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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] | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 강제 타입 캐스팅(
Suggested change
|
||||||
| 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 | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
|
Comment on lines
+55
to
+70
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| private func getBook() -> BookEntity? { | ||
| let context = ContextManager.shared.mainContext | ||
| return context.object(with: bookObjectID) as? BookEntity | ||
| } | ||
|
|
||
| private func makeDataSource() -> UITableViewDiffableDataSource<Int, NSManagedObjectID> { | ||
| let dataSource = UITableViewDiffableDataSource<Int, NSManagedObjectID>(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<QuoteEntity> { | ||
| 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> = 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<Int, NSManagedObjectID>() | ||
| 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<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { | ||
| let typedSnapshot = snapshot as NSDiffableDataSourceSnapshot<Int, NSManagedObjectID> | ||
| dataSource.apply(typedSnapshot, animatingDifferences: true) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
강제 타입 캐스팅(
as!)은request.bookObjectID에 해당하는 객체가BookEntity가 아닐 경우 런타임에 크래시를 유발할 수 있습니다. 보다 안전한 앱을 위해guard let과 옵셔널 캐스팅(as?)을 사용하여 이 경우를 처리하고, 실패 시 에러를 던지는 것을 권장합니다.