diff --git a/ONMIR/Core/Enum/BookStatus/BookStatusTypeKind.swift b/ONMIR/Core/Enum/BookStatus/BookStatusTypeKind.swift index 55e2389..11a6fd9 100644 --- a/ONMIR/Core/Enum/BookStatus/BookStatusTypeKind.swift +++ b/ONMIR/Core/Enum/BookStatus/BookStatusTypeKind.swift @@ -11,6 +11,17 @@ public final class BookStatusTypeKind: NSObject, NSSecureCoding, @unchecked Send let status: BookStatusType + var displayName: String { + switch status { + case .toRead: + return "To Read" + case .reading: + return "Reading" + case .completed: + return "Completed" + } + } + init(status: BookStatusType) { self.status = status super.init() diff --git a/ONMIR/Domain/Representation/BookRepresentation.swift b/ONMIR/Domain/Representation/BookRepresentation.swift index 6f34020..0ad9795 100644 --- a/ONMIR/Domain/Representation/BookRepresentation.swift +++ b/ONMIR/Domain/Representation/BookRepresentation.swift @@ -1,8 +1,7 @@ import Foundation public struct BookRepresentation: Sendable, Hashable { - // Core book properties matching BookEntity - public let originalBookID: String + public let originalBookID: String? public let title: String public let author: String? public let isbn: String? @@ -11,7 +10,7 @@ public struct BookRepresentation: Sendable, Hashable { public let publishedDate: Date? public let publisher: String? public let rating: Double - public let source: BookSourceType + public let source: BookSourceType? public let status: BookStatusType? public let coverImageURL: URL? @@ -46,7 +45,7 @@ public struct BookRepresentation: Sendable, Hashable { extension BookRepresentation { public init(from bookEntity: BookEntity) { - self.originalBookID = bookEntity.originalBookID ?? "" + self.originalBookID = bookEntity.originalBookID self.title = bookEntity.title ?? "" self.author = bookEntity.author self.isbn = bookEntity.isbn @@ -55,25 +54,8 @@ extension BookRepresentation { self.publishedDate = bookEntity.publishedDate self.publisher = bookEntity.publisher self.rating = bookEntity.rating - self.source = bookEntity.source?.sourceType ?? .googleBooks + self.source = bookEntity.source?.sourceType self.status = bookEntity.status?.status self.coverImageURL = bookEntity.coverImageURL } - - public func toBookEntity(in context: NSManagedObjectContext) -> BookEntity { - let bookEntity = BookEntity(context: context) - bookEntity.originalBookID = originalBookID - bookEntity.title = title - bookEntity.author = author - bookEntity.isbn = isbn - bookEntity.isbn13 = isbn13 - bookEntity.pageCount = pageCount - bookEntity.publishedDate = publishedDate - bookEntity.publisher = publisher - bookEntity.rating = rating - bookEntity.source = BookSourceTypeKind(sourceType: source) - bookEntity.status = status.map { BookStatusTypeKind(status: $0) } - bookEntity.coverImageURL = coverImageURL - return bookEntity - } } diff --git a/ONMIR/Feature/BookDetail/BookDetailViewController.swift b/ONMIR/Feature/BookDetail/BookDetailViewController.swift index 5c4a474..20e4b32 100644 --- a/ONMIR/Feature/BookDetail/BookDetailViewController.swift +++ b/ONMIR/Feature/BookDetail/BookDetailViewController.swift @@ -8,6 +8,7 @@ final class BookDetailViewController: UIViewController { case bookInfo case readingLogs case quotes + case bookDetails } enum Item: Hashable, @unchecked Sendable { @@ -16,6 +17,7 @@ final class BookDetailViewController: UIViewController { case quote(QuoteEntity) case addRecord case addQuote + case bookDetails(BookEntity) } private let viewModel = BookDetailViewModel() @@ -208,10 +210,11 @@ final class BookDetailViewController: UIViewController { private func updateSnapshot() { var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.bookInfo, .readingLogs, .quotes]) + snapshot.appendSections([.bookInfo, .readingLogs, .quotes, .bookDetails]) if let book = viewModel.book { snapshot.appendItems([.book(book)], toSection: .bookInfo) + snapshot.appendItems([.bookDetails(book)], toSection: .bookDetails) } let logItems: [Item] = [.addRecord] + viewModel.recentReadingLogs.map { Item.readingLog($0) } @@ -253,6 +256,14 @@ final class BookDetailViewController: UIViewController { } } + let bookDetailsInfoCellRegistration = UICollectionView.CellRegistration< + BookDetailsInfoCell, BookEntity + > { [weak self] cell, indexPath, book in + cell.configure(with: book) { + self?.collectionView.performBatchUpdates(nil, completion: nil) + } + } + let headerRegistration = UICollectionView.SupplementaryRegistration< SectionHeaderView >(elementKind: UICollectionView.elementKindSectionHeader) { @@ -319,6 +330,12 @@ final class BookDetailViewController: UIViewController { for: indexPath, item: .newQuote ) + case .bookDetails(let book): + return collectionView.dequeueConfiguredReusableCell( + using: bookDetailsInfoCellRegistration, + for: indexPath, + item: book + ) } } @@ -346,6 +363,8 @@ final class BookDetailViewController: UIViewController { return self.createReadingLogsSection() case .quotes: return self.createQuotesSection() + case .bookDetails: + return self.createBookDetailsSection() } } } @@ -453,6 +472,33 @@ final class BookDetailViewController: UIViewController { return section } + private func createBookDetailsSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(200) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(200) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: [item] + ) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets( + top: 0, + leading: 0, + bottom: 24, + trailing: 0 + ) + + return section + } + private func updateBackgroundImage(with book: BookEntity) { guard let coverURL = book.coverImageURL else { return } diff --git a/ONMIR/Feature/BookDetail/Cells/BookDetail+BookDetailsInfoCell.swift b/ONMIR/Feature/BookDetail/Cells/BookDetail+BookDetailsInfoCell.swift new file mode 100644 index 0000000..86a927e --- /dev/null +++ b/ONMIR/Feature/BookDetail/Cells/BookDetail+BookDetailsInfoCell.swift @@ -0,0 +1,339 @@ +import SnapKit +import UIKit + +private final class PaddedLabel: UILabel { + var textInsets = UIEdgeInsets.zero { + didSet { invalidateIntrinsicContentSize() } + } + + override func textRect(forBounds bounds: CGRect, limitedToNumberOfLines numberOfLines: Int) -> CGRect { + let insetRect = bounds.inset(by: textInsets) + let textRect = super.textRect(forBounds: insetRect, limitedToNumberOfLines: numberOfLines) + let invertedInsets = UIEdgeInsets( + top: -textInsets.top, + left: -textInsets.left, + bottom: -textInsets.bottom, + right: -textInsets.right + ) + return textRect.inset(by: invertedInsets) + } + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: textInsets)) + } +} + +extension BookDetailViewController { + final class BookDetailsInfoCell: UICollectionViewCell { + private let cardView = { + let view = UIView() + view.backgroundColor = .secondarySystemBackground + view.layer.cornerRadius = 16 + view.layer.masksToBounds = false + return view + }() + + private let stackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 16 + stackView.alignment = .fill + return stackView + }() + + private let statusContainer = { + let container = UIView() + return container + }() + + private let statusLabel = { + let label = PaddedLabel() + label.font = .systemFont(ofSize: 14, weight: .medium) + label.textColor = .systemBlue + label.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.1) + label.layer.cornerRadius = 8 + label.layer.masksToBounds = true + label.textAlignment = .center + label.textInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + return label + }() + + private let descriptionContainer = { + let container = UIView() + return container + }() + + private let descriptionTitleLabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.text = "Description" + label.textColor = .label + return label + }() + + private let descriptionLabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .regular) + label.numberOfLines = 3 + label.textColor = .secondaryLabel + label.lineBreakMode = .byTruncatingTail + return label + }() + + private let expandButton = { + let button = UIButton(type: .system) + button.setTitle("Expand", for: .normal) + button.setTitle("Collapse", for: .selected) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + button.setTitleColor(.systemBlue, for: .normal) + button.contentHorizontalAlignment = .leading + return button + }() + + private let metadataContainer = { + let container = UIView() + container.backgroundColor = .tertiarySystemBackground + container.layer.cornerRadius = 12 + container.layer.masksToBounds = true + return container + }() + + private let metadataStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 8 + stackView.alignment = .fill + return stackView + }() + + private let isbnLabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 14, weight: .regular) + label.textColor = .secondaryLabel + return label + }() + + private let publisherLabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 14, weight: .regular) + label.textColor = .secondaryLabel + return label + }() + + private let publishedDateLabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 14, weight: .regular) + label.textColor = .secondaryLabel + return label + }() + + private var isDescriptionExpanded = false + private var fullDescriptionText: String? + private var onLayoutUpdate: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + checkIfTextNeedsTruncation() + } + + override func prepareForReuse() { + super.prepareForReuse() + statusLabel.text = nil + descriptionLabel.text = nil + isbnLabel.text = nil + publisherLabel.text = nil + publishedDateLabel.text = nil + + statusContainer.isHidden = false + descriptionContainer.isHidden = false + metadataContainer.isHidden = false + + isDescriptionExpanded = false + fullDescriptionText = nil + expandButton.isSelected = false + expandButton.isHidden = true + } + + private func setupUI() { + contentView.addSubview(cardView) + cardView.addSubview(stackView) + + setupStatusContainer() + setupDescriptionContainer() + setupMetadataContainer() + + stackView.addArrangedSubview(statusContainer) + stackView.addArrangedSubview(descriptionContainer) + stackView.addArrangedSubview(metadataContainer) + + setupConstraints() + setupActions() + } + + private func setupStatusContainer() { + statusContainer.addSubview(statusLabel) + + statusLabel.snp.makeConstraints { make in + make.leading.equalToSuperview() + make.top.bottom.equalToSuperview() + make.height.equalTo(28) + } + } + + private func setupDescriptionContainer() { + descriptionContainer.addSubview(descriptionTitleLabel) + descriptionContainer.addSubview(descriptionLabel) + descriptionContainer.addSubview(expandButton) + + descriptionTitleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + + descriptionLabel.snp.makeConstraints { make in + make.top.equalTo(descriptionTitleLabel.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview() + } + + expandButton.snp.makeConstraints { make in + make.top.equalTo(descriptionLabel.snp.bottom).offset(8) + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(20) + } + } + + private func setupMetadataContainer() { + metadataContainer.addSubview(metadataStackView) + + metadataStackView.addArrangedSubview(isbnLabel) + metadataStackView.addArrangedSubview(publisherLabel) + metadataStackView.addArrangedSubview(publishedDateLabel) + + metadataStackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(12) + } + } + + private func setupConstraints() { + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + } + + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(20) + } + } + + private func setupActions() { + expandButton.addTarget(self, action: #selector(expandButtonTapped), for: .touchUpInside) + } + + @objc private func expandButtonTapped() { + isDescriptionExpanded.toggle() + expandButton.isSelected = isDescriptionExpanded + + UIView.animate(withDuration: 0.3, animations: { + if self.isDescriptionExpanded { + self.descriptionLabel.numberOfLines = 0 + } else { + self.descriptionLabel.numberOfLines = 3 + } + self.layoutIfNeeded() + }) { _ in + self.onLayoutUpdate?() + } + } + + func configure(with book: BookEntity, onLayoutUpdate: @escaping () -> Void = {}) { + self.onLayoutUpdate = onLayoutUpdate + updateStatusInfo(status: book.status) + updateDescriptionInfo(description: book.bookDescription) + updateMetadataInfo(book: book) + } + + private func updateStatusInfo(status: BookStatusTypeKind?) { + if let status = status { + statusLabel.text = status.displayName + statusContainer.isHidden = false + } else { + statusContainer.isHidden = true + } + } + + private func updateDescriptionInfo(description: String?) { + if let description = description, !description.isEmpty { + fullDescriptionText = description + descriptionLabel.text = description + descriptionContainer.isHidden = false + } else { + descriptionContainer.isHidden = true + expandButton.isHidden = true + } + } + + private func checkIfTextNeedsTruncation() { + guard let description = fullDescriptionText else { + expandButton.isHidden = true + return + } + + let labelWidth = descriptionLabel.bounds.width + guard labelWidth > 0 else { + expandButton.isHidden = true + return + } + + let tempLabel = UILabel() + tempLabel.font = descriptionLabel.font + tempLabel.numberOfLines = 3 + tempLabel.text = description + tempLabel.lineBreakMode = .byTruncatingTail + + let constrainedSize = CGSize(width: labelWidth, height: .greatestFiniteMagnitude) + let threeLineSize = tempLabel.sizeThatFits(constrainedSize) + + tempLabel.numberOfLines = 0 + let fullSize = tempLabel.sizeThatFits(constrainedSize) + + expandButton.isHidden = fullSize.height <= threeLineSize.height + } + + private func updateMetadataInfo(book: BookEntity) { + var hasMetadata = false + + if let isbn = book.isbn, !isbn.isEmpty { + isbnLabel.text = "ISBN: \(isbn)" + isbnLabel.isHidden = false + hasMetadata = true + } else { + isbnLabel.isHidden = true + } + + if let publisher = book.publisher, !publisher.isEmpty { + publisherLabel.text = "Publisher: \(publisher)" + publisherLabel.isHidden = false + hasMetadata = true + } else { + publisherLabel.isHidden = true + } + + if let publishedDate = book.publishedDate { + publishedDateLabel.text = "Published: \(publishedDate.formatted(.dateTime.year().month().day()))" + publishedDateLabel.isHidden = false + hasMetadata = true + } else { + publishedDateLabel.isHidden = true + } + + metadataContainer.isHidden = !hasMetadata + } + } +} diff --git a/ONMIR/Resources/OnmirModel.xcdatamodeld/OnmirModel.xcdatamodel/contents b/ONMIR/Resources/OnmirModel.xcdatamodeld/OnmirModel.xcdatamodel/contents index 95eed5c..e3bbdc8 100644 --- a/ONMIR/Resources/OnmirModel.xcdatamodeld/OnmirModel.xcdatamodel/contents +++ b/ONMIR/Resources/OnmirModel.xcdatamodeld/OnmirModel.xcdatamodel/contents @@ -2,6 +2,7 @@ + diff --git a/ONMIR/Resources/README.md b/ONMIR/Resources/README.md index 652adff..5088901 100644 --- a/ONMIR/Resources/README.md +++ b/ONMIR/Resources/README.md @@ -15,6 +15,7 @@ - rating: Double - status: BookStatusTypeKind - title: String +- bookDescription: String Relationships: - logs: [ReadingLogEntity] - Cascade