diff --git a/ONMIR/Domain/Representation/BookRepresentation.swift b/ONMIR/Domain/Representation/BookRepresentation.swift index 5e70bb8..6f34020 100644 --- a/ONMIR/Domain/Representation/BookRepresentation.swift +++ b/ONMIR/Domain/Representation/BookRepresentation.swift @@ -1,86 +1,79 @@ import Foundation public struct BookRepresentation: Sendable, Hashable { - public let id: String - public let volumeInfo: VolumeInfo - public let saleInfo: SaleInfo? + // Core book properties matching BookEntity + public let originalBookID: String + public let title: String + public let author: String? + public let isbn: String? + public let isbn13: String? + public let pageCount: Int64 + public let publishedDate: Date? + public let publisher: String? + public let rating: Double + public let source: BookSourceType + public let status: BookStatusType? + public let coverImageURL: URL? public init( - id: String, - volumeInfo: VolumeInfo, - saleInfo: SaleInfo? + originalBookID: String, + title: String, + author: String? = nil, + isbn: String? = nil, + isbn13: String? = nil, + pageCount: Int64 = 0, + publishedDate: Date? = nil, + publisher: String? = nil, + rating: Double = 0.0, + source: BookSourceType = .googleBooks, + status: BookStatusType? = nil, + coverImageURL: URL? = nil ) { - self.id = id - self.volumeInfo = volumeInfo - self.saleInfo = saleInfo + self.originalBookID = originalBookID + self.title = title + self.author = author + self.isbn = isbn + self.isbn13 = isbn13 + self.pageCount = pageCount + self.publishedDate = publishedDate + self.publisher = publisher + self.rating = rating + self.source = source + self.status = status + self.coverImageURL = coverImageURL } +} - 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 - } - } +extension BookRepresentation { + public init(from bookEntity: BookEntity) { + self.originalBookID = bookEntity.originalBookID ?? "" + self.title = bookEntity.title ?? "" + self.author = bookEntity.author + self.isbn = bookEntity.isbn + self.isbn13 = bookEntity.isbn13 + self.pageCount = bookEntity.pageCount + self.publishedDate = bookEntity.publishedDate + self.publisher = bookEntity.publisher + self.rating = bookEntity.rating + self.source = bookEntity.source?.sourceType ?? .googleBooks + self.status = bookEntity.status?.status + self.coverImageURL = bookEntity.coverImageURL } - - 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 - } - } + + 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/NewBookRecord/NewBookRecordViewController.swift b/ONMIR/Feature/BookRecordEditor/BookRecordEditorViewController.swift similarity index 60% rename from ONMIR/Feature/NewBookRecord/NewBookRecordViewController.swift rename to ONMIR/Feature/BookRecordEditor/BookRecordEditorViewController.swift index 8935268..f026d88 100644 --- a/ONMIR/Feature/NewBookRecord/NewBookRecordViewController.swift +++ b/ONMIR/Feature/BookRecordEditor/BookRecordEditorViewController.swift @@ -2,16 +2,18 @@ import Nuke import SnapKit import UIKit -public final class NewBookRecordViewController: UIViewController { +public final class BookRecordEditorViewController: UIViewController { enum Section: Int, CaseIterable { case bookInfo + case date case readingProgress case readingTime case note } enum Item: Hashable { - case bookInfo(BookRepresentation) + case bookInfo(BookEntity) + case date case readingProgress case readingTime case note @@ -36,9 +38,9 @@ public final class NewBookRecordViewController: UIViewController { }() private lazy var dataSource: UICollectionViewDiffableDataSource = makeDataSource() - private let viewModel: NewBookRecordViewModel + private let viewModel: BookRecordEditorViewModel - init(viewModel: NewBookRecordViewModel) { + init(viewModel: BookRecordEditorViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } @@ -51,16 +53,31 @@ public final class NewBookRecordViewController: UIViewController { super.viewDidLoad() setupNavigationBar() setupLayout() + setupBinding() applySnapshot() + setupPresentationController() + } + + private func setupPresentationController() { + if let presentationController = presentationController as? UISheetPresentationController { + presentationController.delegate = self + } } private func setupNavigationBar() { - title = "New Record" + switch viewModel.editMode { + case .create: + title = "New Log" + case .edit: + title = "Edit Log" + } + navigationItem.leftBarButtonItem = UIBarButtonItem( - title: "Cancel", + systemItem: .close, primaryAction: UIAction(handler: { [weak self] _ in self?.cancelButtonTapped() - }) + }), + menu: nil ) } @@ -81,7 +98,7 @@ public final class NewBookRecordViewController: UIViewController { collectionView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() - make.bottom.equalTo(doneButton.snp.top).offset(-20) + make.bottom.equalTo(doneButton.snp.top).offset(-8) } doneButton.snp.makeConstraints { make in @@ -103,6 +120,8 @@ public final class NewBookRecordViewController: UIViewController { switch section { case .bookInfo: return createBookInfoSection() + case .date: + return createDateSection() case .readingProgress: return createReadingProgressSection() case .readingTime: @@ -141,16 +160,38 @@ public final class NewBookRecordViewController: UIViewController { return section } + private func createDateSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(80) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(80) + ) + 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) + heightDimension: .estimated(100) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), - heightDimension: .estimated(150) + heightDimension: .estimated(100) ) let group = NSCollectionLayoutGroup.horizontal( layoutSize: groupSize, subitems: [item]) @@ -211,20 +252,34 @@ public final class NewBookRecordViewController: UIViewController { private func makeDataSource() -> UICollectionViewDiffableDataSource { let bookInfoCellRegistration = UICollectionView.CellRegistration< - BookInfoCell, BookRepresentation + BookInfoCell, BookEntity > { cell, _, book in cell.configure(with: book) } + let dateCellRegistration = UICollectionView.CellRegistration< + DateCell, Item + > { [viewModel] cell, _, _ in + cell.configure( + title: "Date", + date: viewModel.date, + dateChangedHandler: { date in + viewModel.date = date + } + ) + } + let readingProgressCellRegistration = UICollectionView.CellRegistration< - ReadingProgressCell, Item + ReadingRangeCell, Item > { [viewModel] cell, _, _ in cell.configure( title: "Reading Progress", - currentPage: viewModel.currentPage, + startPage: viewModel.startPage, + endPage: viewModel.currentPage, totalPages: viewModel.totalPages, - valueChangedHandler: { value in - viewModel.currentPage = value + rangeChangedHandler: { startPage, endPage in + viewModel.startPage = startPage + viewModel.currentPage = endPage } ) } @@ -262,6 +317,12 @@ public final class NewBookRecordViewController: UIViewController { for: indexPath, item: book ) + case .date: + return collectionView.dequeueConfiguredReusableCell( + using: dateCellRegistration, + for: indexPath, + item: item + ) case .readingProgress: return collectionView.dequeueConfiguredReusableCell( using: readingProgressCellRegistration, @@ -289,6 +350,7 @@ public final class NewBookRecordViewController: UIViewController { snapshot.appendSections(Section.allCases) snapshot.appendItems([.bookInfo(viewModel.book)], toSection: .bookInfo) + snapshot.appendItems([.date], toSection: .date) snapshot.appendItems([.readingProgress], toSection: .readingProgress) snapshot.appendItems([.readingTime], toSection: .readingTime) snapshot.appendItems([.note], toSection: .note) @@ -297,7 +359,37 @@ public final class NewBookRecordViewController: UIViewController { } private func cancelButtonTapped() { - dismiss(animated: true) + if viewModel.hasChanges { + showDiscardChangesAlert() + } else { + dismiss(animated: true) + } + } + + private func showDiscardChangesAlert() { + let alert = UIAlertController( + title: "Discard Changes?", + message: "Are you sure you want to discard your changes?", + preferredStyle: .alert + ) + alert.popoverPresentationController?.sourceItem = navigationItem.leftBarButtonItem + + let discardAction = UIAlertAction( + title:"Discard", + style: .destructive + ) { [weak self] _ in + self?.dismiss(animated: true) + } + + let cancelAction = UIAlertAction( + title: "Cancel", + style: .cancel + ) + + alert.addAction(discardAction) + alert.addAction(cancelAction) + + present(alert, animated: true) } private func doneButtonTapped() { @@ -310,3 +402,64 @@ public final class NewBookRecordViewController: UIViewController { } } } + +extension BookRecordEditorViewController: UISheetPresentationControllerDelegate { + public func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + return !viewModel.hasChanges + } + + public func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { + showDiscardChangesAlert() + } +} + +#Preview("Create Mode") { + { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + + let sampleBook = BookEntity(context: ContextManager.shared.mainContext) + sampleBook.originalBookID = "sample-book-id" + sampleBook.title = "Advanced Apple Debugging & Reverse Engineering" + sampleBook.author = "Derek Selander, Walter Tyree" + sampleBook.isbn = "1942878842" + sampleBook.isbn13 = "9781942878841" + sampleBook.pageCount = 586 + sampleBook.publishedDate = dateFormatter.date(from: "2024-03-15") + sampleBook.publisher = "Razeware LLC" + sampleBook.rating = 4.8 + sampleBook.source = BookSourceTypeKind(sourceType: .googleBooks) + sampleBook.status = BookStatusTypeKind(status: .reading) + sampleBook.coverImageURL = URL(string: "https://m.media-amazon.com/images/I/619+wjNLTyL._SY522_.jpg") + + let viewModel = BookRecordEditorViewModel(book: sampleBook, editMode: .create) + let viewController = BookRecordEditorViewController(viewModel: viewModel) + return UINavigationController(rootViewController: viewController) + }() +} + +#Preview("Edit Mode") { + { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + + let sampleBook = BookEntity(context: ContextManager.shared.mainContext) + sampleBook.originalBookID = "sample-book-id" + sampleBook.title = "Advanced Apple Debugging & Reverse Engineering" + sampleBook.author = "Derek Selander, Walter Tyree" + sampleBook.pageCount = 586 + + // Create sample reading log + let readingLog = ReadingLogEntity(context: ContextManager.shared.mainContext) + readingLog.date = dateFormatter.date(from: "2024-03-10") + readingLog.startPage = 50 + readingLog.endPage = 75 + readingLog.readingSeconds = 45 * 60 // 45 minutes + readingLog.note = "Great chapter on advanced debugging techniques!" + readingLog.book = sampleBook + + let viewModel = BookRecordEditorViewModel(book: sampleBook, editMode: .edit(readingLog)) + let viewController = BookRecordEditorViewController(viewModel: viewModel) + return UINavigationController(rootViewController: viewController) + }() +} diff --git a/ONMIR/Feature/BookRecordEditor/BookRecordEditorViewModel.swift b/ONMIR/Feature/BookRecordEditor/BookRecordEditorViewModel.swift new file mode 100644 index 0000000..a3cbe43 --- /dev/null +++ b/ONMIR/Feature/BookRecordEditor/BookRecordEditorViewModel.swift @@ -0,0 +1,123 @@ +import Foundation +import Observation + +@MainActor +@Observable +public final class BookRecordEditorViewModel { + public enum EditMode: @unchecked Sendable { + case create + case edit(ReadingLogEntity) + } + + public let book: BookEntity + public let editMode: EditMode + + @ObservationIgnored + private let contextManager: ContextManager + + @ObservationIgnored + private let originalDate: Date + @ObservationIgnored + private let originalStartPage: Int + @ObservationIgnored + private let originalCurrentPage: Int + @ObservationIgnored + private let originalDuration: TimeInterval + @ObservationIgnored + private let originalNote: String + + public var date: Date = Date() + public var startPage: Int = 0 + public var currentPage: Int + public var totalPages: Int = 586 + + public var duration: TimeInterval = 60 * 5 + + public var note: String = "" + + 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 initialDate: Date + let initialStartPage: Int + let initialCurrentPage: Int + let initialDuration: TimeInterval + let initialNote: String + + switch editMode { + case .create: + initialDate = Date() + initialStartPage = 0 + initialCurrentPage = 0 + initialDuration = TimeInterval(60 * 5) + initialNote = "" + + case .edit(let readingLog): + initialDate = readingLog.date ?? Date() + initialStartPage = Int(readingLog.startPage) + initialCurrentPage = Int(readingLog.endPage) + initialDuration = readingLog.readingSeconds + initialNote = readingLog.note ?? "" + } + + self.date = initialDate + self.startPage = initialStartPage + self.currentPage = initialCurrentPage + self.duration = initialDuration + self.note = initialNote + + self.originalDate = initialDate + self.originalStartPage = initialStartPage + self.originalCurrentPage = initialCurrentPage + self.originalDuration = initialDuration + self.originalNote = initialNote + } + + public var hasChanges: Bool { + return date != originalDate || + startPage != originalStartPage || + currentPage != originalCurrentPage || + abs(duration - originalDuration) > 1.0 || + note != originalNote + } + + public func save() async { + do { + let date = self.date + let startPage = self.startPage + let currentPage = self.currentPage + let duration = self.duration + let note = self.note + let book = self.book + let editMode = self.editMode + + try await contextManager.performAndSave { context in + let readingLog: ReadingLogEntity + + switch editMode { + case .create: + readingLog = ReadingLogEntity(context: context) + let book = context.object(with: book.objectID) as? BookEntity + assert(book != nil) + readingLog.book = book + + context.insert(readingLog) + + case .edit(let existingLog): + readingLog = existingLog + } + + readingLog.date = date + readingLog.startPage = Int64(startPage) + readingLog.endPage = Int64(currentPage) + readingLog.readingSeconds = duration + readingLog.note = note.isEmpty ? nil : note + } + } catch { + print("Failed to save reading record: \(error)") + } + } +} diff --git a/ONMIR/Feature/NewBookRecord/Components/BookInfoCell.swift b/ONMIR/Feature/BookRecordEditor/Components/BookInfoCell.swift similarity index 72% rename from ONMIR/Feature/NewBookRecord/Components/BookInfoCell.swift rename to ONMIR/Feature/BookRecordEditor/Components/BookInfoCell.swift index 40a3710..302f4b3 100644 --- a/ONMIR/Feature/NewBookRecord/Components/BookInfoCell.swift +++ b/ONMIR/Feature/BookRecordEditor/Components/BookInfoCell.swift @@ -2,7 +2,7 @@ import Nuke import SnapKit import UIKit -extension NewBookRecordViewController { +extension BookRecordEditorViewController { final class BookInfoCell: UICollectionViewCell { private let containerView: UIView = { let view = UIView() @@ -14,7 +14,7 @@ extension NewBookRecordViewController { private let coverImageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFit - imageView.backgroundColor = .tertiarySystemBackground + imageView.backgroundColor = .clear imageView.layer.cornerRadius = 8 imageView.clipsToBounds = true return imageView @@ -81,33 +81,27 @@ extension NewBookRecordViewController { authorsLabel.snp.makeConstraints { make in make.leading.equalTo(coverImageView.snp.trailing).offset(16) - make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.top.equalTo(titleLabel.snp.bottom).offset(4) make.trailing.equalToSuperview() } } - func configure(with book: BookRepresentation) { - titleLabel.text = book.volumeInfo.title + func configure(with book: BookEntity) { + titleLabel.text = book.title - if let authors = book.volumeInfo.authors, !authors.isEmpty { - authorsLabel.text = authors.joined(separator: ", ") + if let authors = book.author { + authorsLabel.text = authors } - 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)") + if let thumbnailURL = book.coverImageURL { + 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/BookRecordEditor/Components/DateCell.swift b/ONMIR/Feature/BookRecordEditor/Components/DateCell.swift new file mode 100644 index 0000000..622095c --- /dev/null +++ b/ONMIR/Feature/BookRecordEditor/Components/DateCell.swift @@ -0,0 +1,96 @@ +import SnapKit +import UIKit + +extension BookRecordEditorViewController { + final class DateCell: 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 dateLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 14) + label.textColor = .secondaryLabel + return label + }() + + private let datePicker: UIDatePicker = { + let picker = UIDatePicker() + picker.datePickerMode = .date + picker.preferredDatePickerStyle = .compact + picker.maximumDate = Date() + return picker + }() + + private var dateChangedHandler: (@MainActor (Date) -> 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, + date: Date, + dateChangedHandler: @MainActor @escaping (Date) -> Void + ) { + titleLabel.text = title + self.dateChangedHandler = dateChangedHandler + + dateLabel.text = date.formatted(.dateTime.year().month().day()) + datePicker.date = date + } + + private func setupView() { + contentView.addSubview(containerView) + containerView.addSubview(titleLabel) + containerView.addSubview(dateLabel) + containerView.addSubview(datePicker) + + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + titleLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().inset(16) + } + + dateLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(0) + make.leading.equalToSuperview().inset(16) + } + + datePicker.snp.makeConstraints { make in + make.verticalEdges.equalToSuperview().inset(16) + make.trailing.equalToSuperview().inset(16) + } + + datePicker.addAction( + UIAction(handler: { [weak self] _ in + self?.datePickerValueChanged() + }), + for: .valueChanged + ) + } + + private func datePickerValueChanged() { + let selectedDate = datePicker.date + dateLabel.text = selectedDate.formatted(.dateTime.year().month().day()) + dateChangedHandler?(selectedDate) + } + } +} diff --git a/ONMIR/Feature/NewBookRecord/Components/NoteCell.swift b/ONMIR/Feature/BookRecordEditor/Components/NoteCell.swift similarity index 97% rename from ONMIR/Feature/NewBookRecord/Components/NoteCell.swift rename to ONMIR/Feature/BookRecordEditor/Components/NoteCell.swift index 489ec25..4664665 100644 --- a/ONMIR/Feature/NewBookRecord/Components/NoteCell.swift +++ b/ONMIR/Feature/BookRecordEditor/Components/NoteCell.swift @@ -1,7 +1,7 @@ import SnapKit import UIKit -extension NewBookRecordViewController { +extension BookRecordEditorViewController { final class NoteCell: UICollectionViewCell { private let containerView: UIView = { let view = UIView() @@ -157,7 +157,7 @@ extension NewBookRecordViewController { } } -extension NewBookRecordViewController.NoteCell: UITextViewDelegate { +extension BookRecordEditorViewController.NoteCell: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { textChangedHandler?(textView.text) updateTextViewHeight() diff --git a/ONMIR/Feature/BookRecordEditor/Components/ReadingRangeCell.swift b/ONMIR/Feature/BookRecordEditor/Components/ReadingRangeCell.swift new file mode 100644 index 0000000..d358c85 --- /dev/null +++ b/ONMIR/Feature/BookRecordEditor/Components/ReadingRangeCell.swift @@ -0,0 +1,210 @@ +import SnapKit +import UIKit + +extension BookRecordEditorViewController { + final class ReadingRangeCell: 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 rangeDisplayLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 14) + label.textColor = .secondaryLabel + return label + }() + + private let stackView: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 16 + stack.alignment = .center + stack.distribution = .fillEqually + return stack + }() + + private let startPageTextField: UITextField = { + let textField = UITextField() + textField.font = .systemFont(ofSize: 16, weight: .medium) + textField.textColor = .label + textField.textAlignment = .center + textField.keyboardType = .numberPad + textField.borderStyle = .roundedRect + textField.backgroundColor = .tertiarySystemGroupedBackground + return textField + }() + + private let separatorLabel: UILabel = { + let label = UILabel() + label.text = "–" + label.font = .systemFont(ofSize: 18, weight: .medium) + label.textColor = .secondaryLabel + label.textAlignment = .center + return label + }() + + private let endPageTextField: UITextField = { + let textField = UITextField() + textField.font = .systemFont(ofSize: 16, weight: .medium) + textField.textColor = .label + textField.textAlignment = .center + textField.keyboardType = .numberPad + textField.borderStyle = .roundedRect + textField.backgroundColor = .tertiarySystemGroupedBackground + return textField + }() + + private var totalPages: Int = 0 + private var rangeChangedHandler: (@MainActor (Int, 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, + startPage: Int, + endPage: Int, + totalPages: Int, + rangeChangedHandler: @MainActor @escaping (Int, Int) -> Void + ) { + titleLabel.text = title + self.totalPages = totalPages + self.rangeChangedHandler = rangeChangedHandler + + startPageTextField.text = "\(startPage)" + endPageTextField.text = "\(endPage)" + + updateRangeDisplay(start: startPage, end: endPage, total: totalPages) + } + + private func setupSubview() { + contentView.addSubview(containerView) + containerView.addSubview(titleLabel) + containerView.addSubview(rangeDisplayLabel) + containerView.addSubview(stackView) + + stackView.addArrangedSubview(startPageTextField) + stackView.addArrangedSubview(separatorLabel) + stackView.addArrangedSubview(endPageTextField) + + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(16) + } + + rangeDisplayLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(4) + make.leading.trailing.equalToSuperview().inset(16) + } + + stackView.snp.makeConstraints { make in + make.top.equalTo(rangeDisplayLabel.snp.bottom).offset(12) + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalToSuperview().inset(16) + make.height.equalTo(44) + } + + separatorLabel.snp.makeConstraints { make in + make.width.equalTo(20) + } + + startPageTextField.delegate = self + endPageTextField.delegate = self + } + + private func setupBinding() { + startPageTextField.addAction( + UIAction(handler: { [weak self] _ in + self?.textFieldChanged() + }), + for: .editingChanged + ) + + endPageTextField.addAction( + UIAction(handler: { [weak self] _ in + self?.textFieldChanged() + }), + for: .editingChanged + ) + + let toolBar = UIToolbar() + toolBar.sizeToFit() + let doneButton = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(doneButtonTapped) + ) + let flexSpace = UIBarButtonItem( + barButtonSystemItem: .flexibleSpace, + target: nil, + action: nil + ) + toolBar.items = [flexSpace, doneButton] + + startPageTextField.inputAccessoryView = toolBar + endPageTextField.inputAccessoryView = toolBar + } + + private func textFieldChanged() { + let startPage = Int(startPageTextField.text ?? "0") ?? 0 + let endPage = Int(endPageTextField.text ?? "0") ?? 0 + + let validStartPage = max(0, min(startPage, totalPages)) + let validEndPage = max(validStartPage, min(endPage, totalPages)) + + updateRangeDisplay(start: validStartPage, end: validEndPage, total: totalPages) + rangeChangedHandler?(validStartPage, validEndPage) + } + + private func updateRangeDisplay(start: Int, end: Int, total: Int) { + let pagesRead = max(0, end - start) + rangeDisplayLabel.text = "\(pagesRead) pages read of \(total) total" + } + + @objc private func doneButtonTapped() { + startPageTextField.resignFirstResponder() + endPageTextField.resignFirstResponder() + } + } +} + +extension BookRecordEditorViewController.ReadingRangeCell: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if textField == startPageTextField { + endPageTextField.becomeFirstResponder() + } else { + textField.resignFirstResponder() + } + 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/BookRecordEditor/Components/ReadingTimeCell.swift similarity index 98% rename from ONMIR/Feature/NewBookRecord/Components/ReadingTimeCell.swift rename to ONMIR/Feature/BookRecordEditor/Components/ReadingTimeCell.swift index ad5f361..c573d4c 100644 --- a/ONMIR/Feature/NewBookRecord/Components/ReadingTimeCell.swift +++ b/ONMIR/Feature/BookRecordEditor/Components/ReadingTimeCell.swift @@ -1,7 +1,7 @@ import SnapKit import UIKit -extension NewBookRecordViewController { +extension BookRecordEditorViewController { final class ReadingTimeCell: UICollectionViewCell { private let containerView: UIView = { let view = UIView() diff --git a/ONMIR/Feature/NewBookRecord/Components/ReadingProgressCell.swift b/ONMIR/Feature/NewBookRecord/Components/ReadingProgressCell.swift deleted file mode 100644 index 4624ab8..0000000 --- a/ONMIR/Feature/NewBookRecord/Components/ReadingProgressCell.swift +++ /dev/null @@ -1,246 +0,0 @@ -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/NewBookRecordViewModel.swift b/ONMIR/Feature/NewBookRecord/NewBookRecordViewModel.swift deleted file mode 100644 index 1c660ec..0000000 --- a/ONMIR/Feature/NewBookRecord/NewBookRecordViewModel.swift +++ /dev/null @@ -1,29 +0,0 @@ -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 { - - } -}