diff --git a/ONMIR/Domain/Representation/BookRepresentation.swift b/ONMIR/Domain/Representation/BookRepresentation.swift new file mode 100644 index 0000000..5e70bb8 --- /dev/null +++ b/ONMIR/Domain/Representation/BookRepresentation.swift @@ -0,0 +1,86 @@ +import Foundation + +public struct BookRepresentation: Sendable, Hashable { + public let id: String + public let volumeInfo: VolumeInfo + public let saleInfo: SaleInfo? + + public init( + id: String, + volumeInfo: VolumeInfo, + saleInfo: SaleInfo? + ) { + self.id = id + self.volumeInfo = volumeInfo + self.saleInfo = saleInfo + } + + public struct VolumeInfo: Sendable, Hashable { + public let title: String + public let subtitle: String? + public let authors: [String]? + public let publisher: String? + public let publishedDate: String? + public let description: String? + public let pageCount: Int? + public let categories: [String]? + public let language: String? + public let imageLinks: ImageLinks? + + public init( + title: String, + subtitle: String?, + authors: [String]?, + publisher: String?, + publishedDate: String?, + description: String?, + pageCount: Int?, + categories: [String]?, + language: String?, + imageLinks: ImageLinks? + ) { + self.title = title + self.subtitle = subtitle + self.authors = authors + self.publisher = publisher + self.publishedDate = publishedDate + self.description = description + self.pageCount = pageCount + self.categories = categories + self.language = language + self.imageLinks = imageLinks + } + + public struct ImageLinks: Sendable, Hashable { + public let smallThumbnail: String? + public let thumbnail: String? + + public init(smallThumbnail: String?, thumbnail: String?) { + self.smallThumbnail = smallThumbnail + self.thumbnail = thumbnail + } + } + } + + public struct SaleInfo: Sendable, Hashable { + public let listPrice: Price? + public let retailPrice: Price? + public let buyLink: String? + + public init(listPrice: Price?, retailPrice: Price?, buyLink: String?) { + self.listPrice = listPrice + self.retailPrice = retailPrice + self.buyLink = buyLink + } + + public struct Price: Sendable, Hashable { + public let amount: Double? + public let currencyCode: String? + + public init(amount: Double?, currencyCode: String?) { + self.amount = amount + self.currencyCode = currencyCode + } + } + } +} diff --git a/ONMIR/Feature/NewBookRecord/Components/BookInfoCell.swift b/ONMIR/Feature/NewBookRecord/Components/BookInfoCell.swift new file mode 100644 index 0000000..40a3710 --- /dev/null +++ b/ONMIR/Feature/NewBookRecord/Components/BookInfoCell.swift @@ -0,0 +1,116 @@ +import Nuke +import SnapKit +import UIKit + +extension NewBookRecordViewController { + final class BookInfoCell: UICollectionViewCell { + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.layer.cornerRadius = 12 + return view + }() + + private let coverImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.backgroundColor = .tertiarySystemBackground + imageView.layer.cornerRadius = 8 + imageView.clipsToBounds = true + return imageView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 16, weight: .bold) + label.numberOfLines = 0 + label.textAlignment = .left + return label + }() + + private let authorsLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 14) + label.textColor = .secondaryLabel + label.numberOfLines = 0 + label.textAlignment = .left + return label + }() + + private var imageLoadTask: Task? + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + imageLoadTask?.cancel() + imageLoadTask = nil + coverImageView.image = nil + titleLabel.text = nil + authorsLabel.text = nil + } + + private func setupView() { + contentView.addSubview(containerView) + containerView.addSubview(coverImageView) + containerView.addSubview(titleLabel) + containerView.addSubview(authorsLabel) + + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + coverImageView.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview() + make.height.equalTo(100) + make.width.equalTo(coverImageView.snp.height).multipliedBy(0.67) + } + + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(coverImageView.snp.trailing).offset(16) + make.top.equalToSuperview() + make.trailing.equalToSuperview() + } + + authorsLabel.snp.makeConstraints { make in + make.leading.equalTo(coverImageView.snp.trailing).offset(16) + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.trailing.equalToSuperview() + } + } + + func configure(with book: BookRepresentation) { + titleLabel.text = book.volumeInfo.title + + if let authors = book.volumeInfo.authors, !authors.isEmpty { + authorsLabel.text = authors.joined(separator: ", ") + } + + if let thumbnailURLString = book.volumeInfo.imageLinks?.thumbnail { + let secureURL = thumbnailURLString.replacingOccurrences( + of: "http://", + with: "https://" + ) + if let thumbnailURL = URL(string: secureURL) { + imageLoadTask = Task { + do { + let image = try await ImagePipeline.shared.image(for: thumbnailURL) + if !Task.isCancelled { + self.coverImageView.image = image + } + } catch { + print("이미지 로딩 실패: \(error)") + } + } + } + } + } + } +} diff --git a/ONMIR/Feature/NewBookRecord/Components/NoteCell.swift b/ONMIR/Feature/NewBookRecord/Components/NoteCell.swift new file mode 100644 index 0000000..489ec25 --- /dev/null +++ b/ONMIR/Feature/NewBookRecord/Components/NoteCell.swift @@ -0,0 +1,165 @@ +import SnapKit +import UIKit + +extension NewBookRecordViewController { + final class NoteCell: UICollectionViewCell { + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .secondarySystemGroupedBackground + view.layer.cornerRadius = 12 + return view + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 16, weight: .bold) + label.textColor = .label + return label + }() + + private let textView: UITextView = { + let textView = UITextView() + textView.font = .systemFont(ofSize: 14) + textView.backgroundColor = .clear + textView.isScrollEnabled = false + textView.textContainerInset = UIEdgeInsets( + top: 8, + left: 8, + bottom: 8, + right: 8 + ) + textView.layer.borderColor = UIColor.systemGray5.cgColor + textView.layer.borderWidth = 0.5 + textView.layer.cornerRadius = 8 + return textView + }() + + private var textChangedHandler: (@MainActor (String) -> Void)? + + private var textViewHeightConstraint: Constraint? + private var currentTextHeight: CGFloat = 100 + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + contentView.addSubview(containerView) + containerView.addSubview(titleLabel) + containerView.addSubview(textView) + + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + titleLabel.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview().inset(16) + } + + textView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.horizontalEdges.equalToSuperview().inset(16) + make.bottom.lessThanOrEqualToSuperview().inset(16) + textViewHeightConstraint = make.height.equalTo(100).constraint + } + + textView.delegate = self + } + + override func layoutSubviews() { + super.layoutSubviews() + + if textView.bounds.width > 0 { + updateTextViewHeight() + } + } + + func configure( + title: String, + note: String, + textChangedHandler: @MainActor @escaping (String) -> Void + ) { + titleLabel.text = title + self.textChangedHandler = textChangedHandler + + textView.text = note + + setNeedsLayout() + layoutIfNeeded() + updateTextViewHeight() + } + + private func updateTextViewHeight() { + guard textView.bounds.width > 0 else { + return + } + + let fixedWidth = + textView.frame.width - textView.textContainerInset.left + - textView.textContainerInset.right - 2 + * textView.textContainer.lineFragmentPadding + + let textString = textView.text ?? "" + let textStorage = NSTextStorage( + string: textString, + attributes: [ + .font: textView.font ?? UIFont.systemFont(ofSize: 14) + ] + ) + + let textContainer = NSTextContainer( + size: CGSize(width: fixedWidth, height: .greatestFiniteMagnitude)) + textContainer.lineFragmentPadding = + textView.textContainer.lineFragmentPadding + textContainer.lineBreakMode = .byWordWrapping + + let layoutManager = NSLayoutManager() + layoutManager.addTextContainer(textContainer) + textStorage.addLayoutManager(layoutManager) + + layoutManager.ensureLayout(for: textContainer) + + let usedRect = layoutManager.usedRect(for: textContainer) + let newHeight = max( + 100, + ceil(usedRect.height + textView.textContainerInset.top + + textView.textContainerInset.bottom) + ) + + if abs(newHeight - currentTextHeight) > 1 { + textViewHeightConstraint?.update(offset: newHeight) + currentTextHeight = newHeight + + invalidateIntrinsicContentSize() + } + } + + override func preferredLayoutAttributesFitting( + _ layoutAttributes: UICollectionViewLayoutAttributes + ) -> UICollectionViewLayoutAttributes { + let attributes = super.preferredLayoutAttributesFitting(layoutAttributes) + + let titleHeight = titleLabel.frame.height + let spacing: CGFloat = 8 + let verticalInsets: CGFloat = 32 + + let totalHeight = + titleHeight + spacing + currentTextHeight + verticalInsets + attributes.frame.size.height = totalHeight + + return attributes + } + } +} + +extension NewBookRecordViewController.NoteCell: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + textChangedHandler?(textView.text) + updateTextViewHeight() + } +} diff --git a/ONMIR/Feature/NewBookRecord/Components/ReadingProgressCell.swift b/ONMIR/Feature/NewBookRecord/Components/ReadingProgressCell.swift new file mode 100644 index 0000000..4624ab8 --- /dev/null +++ b/ONMIR/Feature/NewBookRecord/Components/ReadingProgressCell.swift @@ -0,0 +1,246 @@ +import SnapKit +import UIKit + +extension NewBookRecordViewController { + final class ReadingProgressCell: UICollectionViewCell { + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .secondarySystemGroupedBackground + view.layer.cornerRadius = 12 + return view + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 16, weight: .bold) + label.textColor = .label + return label + }() + + private let pageInfoContainer: UIControl = { + let view = UIControl() + view.isUserInteractionEnabled = true + return view + }() + + private let pageInfoLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 14) + label.textColor = .secondaryLabel + return label + }() + + private let pageTextField: UITextField = { + let textField = UITextField() + textField.font = .systemFont(ofSize: 14) + textField.textColor = .secondaryLabel + textField.textAlignment = .left + textField.keyboardType = .numberPad + textField.borderStyle = .none + textField.isHidden = true + return textField + }() + + private let pencilImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = UIImage(systemName: "pencil") + imageView.tintColor = .gray + return imageView + }() + + private let slider: UISlider = { + let slider = UISlider() + slider.minimumTrackTintColor = .systemBlue + return slider + }() + + private let minLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 14) + label.textColor = .tertiaryLabel + label.text = "0" + return label + }() + + private let maxLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 14) + label.textColor = .tertiaryLabel + label.textAlignment = .right + return label + }() + + private var totalPages: Int = 0 + private var valueChangedHandler: (@MainActor (Int) -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupSubview() + setupBinding() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure( + title: String, + currentPage: Int, + totalPages: Int, + valueChangedHandler: @MainActor @escaping (Int) -> Void + ) { + titleLabel.text = title + self.totalPages = totalPages + self.valueChangedHandler = valueChangedHandler + + updatePageInfoDisplay(page: currentPage) + maxLabel.text = "\(totalPages)" + + slider.minimumValue = 0 + slider.maximumValue = Float(totalPages) + slider.value = Float(currentPage) + } + + private func setupSubview() { + contentView.addSubview(containerView) + + containerView.addSubview(titleLabel) + containerView.addSubview(pageInfoContainer) + pageInfoContainer.addSubview(pageInfoLabel) + pageInfoContainer.addSubview(pageTextField) + + containerView.addSubview(pencilImageView) + containerView.addSubview(slider) + containerView.addSubview(minLabel) + containerView.addSubview(maxLabel) + + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(16) + } + + pageInfoContainer.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(4) + make.leading.equalToSuperview().inset(16) + make.height.equalTo(30) + } + + pageInfoLabel.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + pageTextField.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.greaterThanOrEqualTo(60) + } + + pencilImageView.snp.makeConstraints { make in + make.centerY.equalTo(pageInfoContainer) + make.leading.equalTo(pageInfoContainer.snp.trailing).offset(2) + make.height.equalTo(16) + make.width.equalTo(14) + } + + slider.snp.makeConstraints { make in + make.top.equalTo(pageInfoContainer.snp.bottom).offset(20) + make.horizontalEdges.equalToSuperview().inset(16) + } + + minLabel.snp.makeConstraints { make in + make.top.equalTo(slider.snp.bottom).offset(8) + make.leading.equalToSuperview().inset(16) + make.bottom.equalToSuperview().inset(16) + } + + maxLabel.snp.makeConstraints { make in + make.top.equalTo(slider.snp.bottom).offset(8) + make.trailing.equalToSuperview().inset(16) + make.bottom.equalToSuperview().inset(16) + } + + pageTextField.delegate = self + } + + private func setupBinding() { + slider.addAction( + UIAction(handler: { [weak self] _ in + self?.sliderValueChanged() + }), + for: .valueChanged + ) + + pageInfoContainer.addAction( + UIAction(handler: { [weak self] _ in + self?.pageInfoTapped() + }), + for: .touchUpInside + ) + + let toolBar = UIToolbar() + toolBar.sizeToFit() + let doneButton = UIBarButtonItem( + title: "Done", + primaryAction: UIAction(handler: { [weak self] _ in + self?.doneButtonTapped() + }) + ) + let flexSpace = UIBarButtonItem( + barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + toolBar.items = [flexSpace, doneButton] + pageTextField.inputAccessoryView = toolBar + } + + private func updatePageInfoDisplay(page: Int) { + pageInfoLabel.text = "\(page) - \(totalPages)" + pageTextField.text = "\(page)" + } + + private func sliderValueChanged() { + let value = Int(slider.value) + updatePageInfoDisplay(page: value) + valueChangedHandler?(value) + } + + private func pageInfoTapped() { + pageInfoLabel.isHidden = true + pageTextField.isHidden = false + pageTextField.becomeFirstResponder() + } + + private func doneButtonTapped() { + submitTextFieldValue() + } + + private func submitTextFieldValue() { + if let text = pageTextField.text, let page = Int(text) { + let validPage = min(max(0, page), totalPages) + slider.value = Float(validPage) + updatePageInfoDisplay(page: validPage) + valueChangedHandler?(validPage) + } + + pageTextField.resignFirstResponder() + pageTextField.isHidden = true + pageInfoLabel.isHidden = false + } + } +} + +extension NewBookRecordViewController.ReadingProgressCell: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + submitTextFieldValue() + return true + } + + func textField( + _ textField: UITextField, shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + let allowedCharacters = CharacterSet.decimalDigits + let characterSet = CharacterSet(charactersIn: string) + return allowedCharacters.isSuperset(of: characterSet) + } +} diff --git a/ONMIR/Feature/NewBookRecord/Components/ReadingTimeCell.swift b/ONMIR/Feature/NewBookRecord/Components/ReadingTimeCell.swift new file mode 100644 index 0000000..ad5f361 --- /dev/null +++ b/ONMIR/Feature/NewBookRecord/Components/ReadingTimeCell.swift @@ -0,0 +1,109 @@ +import SnapKit +import UIKit + +extension NewBookRecordViewController { + final class ReadingTimeCell: UICollectionViewCell { + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .secondarySystemGroupedBackground + view.layer.cornerRadius = 12 + return view + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 16, weight: .bold) + label.textColor = .label + return label + }() + + private let timeLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 14) + label.textColor = .label + return label + }() + + private let datePicker: UIDatePicker = { + let picker = UIDatePicker() + picker.datePickerMode = .countDownTimer + picker.backgroundColor = .secondarySystemGroupedBackground + picker.preferredDatePickerStyle = .wheels + return picker + }() + + private let dateComponentsFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .abbreviated + return formatter + }() + + private var durationChangedHandler: (@MainActor (TimeInterval) -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure( + title: String, + duration: TimeInterval, + durationChangedHandler: @MainActor @escaping (TimeInterval) -> Void + ) { + titleLabel.text = title + self.durationChangedHandler = durationChangedHandler + + timeLabel.text = dateComponentsFormatter.string(from: duration) + + datePicker.countDownDuration = duration + } + + private func setupView() { + contentView.addSubview(containerView) + containerView.addSubview(titleLabel) + containerView.addSubview(timeLabel) + containerView.addSubview(datePicker) + + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + titleLabel.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview().inset(16) + } + + timeLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(4) + make.leading.equalToSuperview().inset(16) + } + + datePicker.snp.makeConstraints { make in + make.top.equalTo(timeLabel.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview().inset(16) + make.height.equalTo(160) + } + + datePicker.addAction( + UIAction(handler: { [weak self] _ in + self?.datePickerValueChanged() + }), + for: .valueChanged + ) + } + + private func datePickerValueChanged() { + let duration = datePicker.countDownDuration + let string = dateComponentsFormatter.string(from: duration) + + timeLabel.text = string + + durationChangedHandler?(duration) + } + } +} diff --git a/ONMIR/Feature/NewBookRecord/NewBookRecordViewController.swift b/ONMIR/Feature/NewBookRecord/NewBookRecordViewController.swift new file mode 100644 index 0000000..8935268 --- /dev/null +++ b/ONMIR/Feature/NewBookRecord/NewBookRecordViewController.swift @@ -0,0 +1,312 @@ +import Nuke +import SnapKit +import UIKit + +public final class NewBookRecordViewController: UIViewController { + enum Section: Int, CaseIterable { + case bookInfo + case readingProgress + case readingTime + case note + } + + enum Item: Hashable { + case bookInfo(BookRepresentation) + case readingProgress + case readingTime + case note + } + + private lazy var collectionView: UICollectionView = { + let layout = createLayout() + let collectionView = UICollectionView( + frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .systemGroupedBackground + return collectionView + }() + + 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 lazy var dataSource: UICollectionViewDiffableDataSource = makeDataSource() + private let viewModel: NewBookRecordViewModel + + init(viewModel: NewBookRecordViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + setupLayout() + applySnapshot() + } + + private func setupNavigationBar() { + title = "New Record" + navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "Cancel", + primaryAction: UIAction(handler: { [weak self] _ in + self?.cancelButtonTapped() + }) + ) + } + + private func setupBinding() { + doneButton.addAction( + UIAction(handler: { [weak self] _ in + self?.doneButtonTapped() + }), + for: .primaryActionTriggered + ) + } + + private func setupLayout() { + view.backgroundColor = .systemGroupedBackground + + view.addSubview(collectionView) + view.addSubview(doneButton) + + collectionView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(doneButton.snp.top).offset(-20) + } + + doneButton.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(20) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-20) + make.height.equalTo(50) + } + } + + private func createLayout() -> UICollectionViewLayout { + let layout = UICollectionViewCompositionalLayout { + [weak self] sectionIndex, _ in + guard let self = self, + let section = Section(rawValue: sectionIndex) + else { + return nil + } + + switch section { + case .bookInfo: + return createBookInfoSection() + case .readingProgress: + return createReadingProgressSection() + case .readingTime: + return createReadingTimeSection() + case .note: + return createNoteSection() + } + } + + let configuration = UICollectionViewCompositionalLayoutConfiguration() + configuration.interSectionSpacing = 15 + layout.configuration = configuration + + return layout + } + + private func createBookInfoSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(150) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(150) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets( + top: 10, leading: 20, bottom: 0, trailing: 20 + ) + + return section + } + + private func createReadingProgressSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(150) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(150) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets( + top: 10, leading: 20, bottom: 0, trailing: 20 + ) + + return section + } + + private func createReadingTimeSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(180) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(180) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets( + top: 10, leading: 20, bottom: 0, trailing: 20 + ) + + return section + } + + private func createNoteSection() -> NSCollectionLayoutSection { + let estimatedHeight: CGFloat = 200 + + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(estimatedHeight) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(estimatedHeight) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets( + top: 10, leading: 20, bottom: 0, trailing: 20 + ) + + return section + } + + private func makeDataSource() -> UICollectionViewDiffableDataSource { + let bookInfoCellRegistration = UICollectionView.CellRegistration< + BookInfoCell, BookRepresentation + > { cell, _, book in + cell.configure(with: book) + } + + let readingProgressCellRegistration = UICollectionView.CellRegistration< + ReadingProgressCell, Item + > { [viewModel] cell, _, _ in + cell.configure( + title: "Reading Progress", + currentPage: viewModel.currentPage, + totalPages: viewModel.totalPages, + valueChangedHandler: { value in + viewModel.currentPage = value + } + ) + } + + let readingTimeCellRegistration = UICollectionView.CellRegistration< + ReadingTimeCell, Item + > { [viewModel] cell, _, _ in + cell.configure( + title: "Reading Time", + duration: viewModel.duration, + durationChangedHandler: { duration in + viewModel.duration = duration + } + ) + } + + let noteCellRegistration = UICollectionView.CellRegistration + { [viewModel] cell, _, _ in + cell.configure( + title: "Note", + note: viewModel.note, + textChangedHandler: { text in + viewModel.note = text + } + ) + } + + return UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { (collectionView, indexPath, item) -> UICollectionViewCell? in + switch item { + case .bookInfo(let book): + return collectionView.dequeueConfiguredReusableCell( + using: bookInfoCellRegistration, + for: indexPath, + item: book + ) + case .readingProgress: + return collectionView.dequeueConfiguredReusableCell( + using: readingProgressCellRegistration, + for: indexPath, + item: item + ) + case .readingTime: + return collectionView.dequeueConfiguredReusableCell( + using: readingTimeCellRegistration, + for: indexPath, + item: item + ) + case .note: + return collectionView.dequeueConfiguredReusableCell( + using: noteCellRegistration, + for: indexPath, + item: item + ) + } + } + } + + private func applySnapshot(animatingDifferences: Bool = false) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections(Section.allCases) + + snapshot.appendItems([.bookInfo(viewModel.book)], toSection: .bookInfo) + snapshot.appendItems([.readingProgress], toSection: .readingProgress) + snapshot.appendItems([.readingTime], toSection: .readingTime) + snapshot.appendItems([.note], toSection: .note) + + dataSource.apply(snapshot, animatingDifferences: animatingDifferences) + } + + private func cancelButtonTapped() { + dismiss(animated: true) + } + + private func doneButtonTapped() { + Task { + await viewModel.save() + + await MainActor.run { + dismiss(animated: true) + } + } + } +} diff --git a/ONMIR/Feature/NewBookRecord/NewBookRecordViewModel.swift b/ONMIR/Feature/NewBookRecord/NewBookRecordViewModel.swift new file mode 100644 index 0000000..1c660ec --- /dev/null +++ b/ONMIR/Feature/NewBookRecord/NewBookRecordViewModel.swift @@ -0,0 +1,29 @@ +import Foundation +import Observation + +@MainActor +@Observable +public final class NewBookRecordViewModel { + public let book: BookRepresentation + + public var currentPage: Int + public var totalPages: Int = 586 + + public var duration: TimeInterval = 60 * 5 + + public var note: String = "" + + public init(book: BookRepresentation) { + self.book = book + self.currentPage = 0 + } + + public init(book: BookRepresentation, currentPage: Int) { + self.book = book + self.currentPage = currentPage + } + + public func save() async { + + } +}