Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions ONMIR/Domain/Quote/Interactor/CreateQuoteInteractor.swift
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

강제 타입 캐스팅(as!)은 request.bookObjectID에 해당하는 객체가 BookEntity가 아닐 경우 런타임에 크래시를 유발할 수 있습니다. 보다 안전한 앱을 위해 guard let과 옵셔널 캐스팅(as?)을 사용하여 이 경우를 처리하고, 실패 시 에러를 던지는 것을 권장합니다.

Suggested change
let book = context.object(with: request.bookObjectID) as! BookEntity
guard let book = context.object(with: request.bookObjectID) as? BookEntity else { throw NSError(domain: "CreateQuoteInteractor", code: 1, userInfo: [NSLocalizedDescriptionKey: "Book not found"]) }

quoteEntity.book = book

context.insert(quoteEntity)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

QuoteEntity(context: context)를 통해 NSManagedObject를 생성하면 이미 해당 context에 삽입(insert)됩니다. 따라서 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
}
}
}
33 changes: 33 additions & 0 deletions ONMIR/Domain/Quote/Interactor/DeleteQuoteInteractor.swift
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]
}
}
}
36 changes: 36 additions & 0 deletions ONMIR/Domain/Quote/Interactor/UpdateQuoteInteractor.swift
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

강제 타입 캐스팅(as!)은 request.quoteObjectID에 해당하는 객체가 QuoteEntity가 아닐 경우 런타임에 크래시를 유발할 수 있습니다. guard let과 옵셔널 캐스팅(as?)을 사용하여 안전하게 처리하는 것이 좋습니다.

Suggested change
let quote = context.object(with: request.quoteObjectID) as! QuoteEntity
guard let quote = context.object(with: request.quoteObjectID) as? QuoteEntity else { throw NSError(domain: "UpdateQuoteInteractor", code: 1, userInfo: [NSLocalizedDescriptionKey: "Quote not found"]) }

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
}
}
}
239 changes: 239 additions & 0 deletions ONMIR/Feature/BookDetail/AllQuotes/AllQuotesViewController.swift
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

QuoteEditorViewController를 생성하고 표시하는 코드가 addQuoteTapped, tableView(_:didSelectRowAt:), tableView(_:trailingSwipeActionsConfigurationForRowAt:) 등 여러 곳에서 중복되고 있습니다. 코드 중복을 줄이고 유지보수성을 높이기 위해 이 로직을 별도의 private helper 메서드로 추출하는 것을 고려해 보세요. 예를 들어, private func presentQuoteEditor(for editMode: QuoteEditorViewModel.EditMode)와 같은 메서드를 만들 수 있습니다.


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)
}
}
Loading