diff --git a/ONMIR.xcodeproj/project.pbxproj b/ONMIR.xcodeproj/project.pbxproj
index 25bba07..06c85e5 100644
--- a/ONMIR.xcodeproj/project.pbxproj
+++ b/ONMIR.xcodeproj/project.pbxproj
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
46EE698D2DC10341001736D6 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 46EE698C2DC10341001736D6 /* SnapKit */; };
+ 46EE6A232DC196D9001736D6 /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 46EE6A222DC196D9001736D6 /* Nuke */; };
D0FD53152DB4DDC400F2593B /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = D0FD53142DB4DDC400F2593B /* .gitignore */; };
/* End PBXBuildFile section */
@@ -72,6 +73,7 @@
buildActionMask = 2147483647;
files = (
46EE698D2DC10341001736D6 /* SnapKit in Frameworks */,
+ 46EE6A232DC196D9001736D6 /* Nuke in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -134,6 +136,7 @@
name = ONMIR;
packageProductDependencies = (
46EE698C2DC10341001736D6 /* SnapKit */,
+ 46EE6A222DC196D9001736D6 /* Nuke */,
);
productName = ONMIR;
productReference = D011D3032DB4DAAB00412C5C /* ONMIR.app */;
@@ -219,6 +222,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
46EE698B2DC10341001736D6 /* XCRemoteSwiftPackageReference "SnapKit" */,
+ 46EE6A212DC196D9001736D6 /* XCRemoteSwiftPackageReference "Nuke" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = D011D3042DB4DAAB00412C5C /* Products */;
@@ -586,6 +590,14 @@
minimumVersion = 5.7.1;
};
};
+ 46EE6A212DC196D9001736D6 /* XCRemoteSwiftPackageReference "Nuke" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/kean/Nuke.git";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 12.8.0;
+ };
+ };
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -594,6 +606,11 @@
package = 46EE698B2DC10341001736D6 /* XCRemoteSwiftPackageReference "SnapKit" */;
productName = SnapKit;
};
+ 46EE6A222DC196D9001736D6 /* Nuke */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 46EE6A212DC196D9001736D6 /* XCRemoteSwiftPackageReference "Nuke" */;
+ productName = Nuke;
+ };
/* End XCSwiftPackageProductDependency section */
};
rootObject = D011D2FB2DB4DAAB00412C5C /* Project object */;
diff --git a/ONMIR.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ONMIR.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 3f64b3c..9b220b6 100644
--- a/ONMIR.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/ONMIR.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,6 +1,15 @@
{
- "originHash" : "dd27728c8848101841bd8d45243ef43144e233aa3767c82656934b73447347b8",
+ "originHash" : "be6f8f43ae9fcbbd48c12fb9cee2adfa4fdb9650305a8c92a692fd62d3ec851f",
"pins" : [
+ {
+ "identity" : "nuke",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/kean/Nuke.git",
+ "state" : {
+ "revision" : "0ead44350d2737db384908569c012fe67c421e4d",
+ "version" : "12.8.0"
+ }
+ },
{
"identity" : "snapkit",
"kind" : "remoteSourceControl",
diff --git a/ONMIR.xcodeproj/xcshareddata/xcschemes/ONMIR.xcscheme b/ONMIR.xcodeproj/xcshareddata/xcschemes/ONMIR.xcscheme
new file mode 100644
index 0000000..f21e265
--- /dev/null
+++ b/ONMIR.xcodeproj/xcshareddata/xcschemes/ONMIR.xcscheme
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ONMIR/Feature/NewBook/BookSearchRepresentation.swift b/ONMIR/Feature/NewBook/BookSearchRepresentation.swift
new file mode 100644
index 0000000..4b54020
--- /dev/null
+++ b/ONMIR/Feature/NewBook/BookSearchRepresentation.swift
@@ -0,0 +1,44 @@
+import Foundation
+
+public struct BookSearchRepresentation: Sendable, Hashable {
+ public let id: String
+ 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 thumbnailURL: URL?
+
+ init(from bookItem: GoogleBooksClient.BookSearchResponse.BookItem) {
+ self.id = bookItem.id
+ self.title = bookItem.volumeInfo.title
+ self.subtitle = bookItem.volumeInfo.subtitle
+ self.authors = bookItem.volumeInfo.authors
+ self.publisher = bookItem.volumeInfo.publisher
+ self.publishedDate = bookItem.volumeInfo.publishedDate
+ self.description = bookItem.volumeInfo.description
+
+ if let thumbnailURLString = bookItem.volumeInfo.imageLinks?.thumbnail {
+ let secureURL = thumbnailURLString.replacingOccurrences(
+ of: "http://",
+ with: "https://"
+ )
+ self.thumbnailURL = URL(string: secureURL)
+ } else {
+ self.thumbnailURL = nil
+ }
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+
+ public static func == (lhs: BookSearchRepresentation, rhs: BookSearchRepresentation) -> Bool {
+ return lhs.id == rhs.id && lhs.title == rhs.title
+ && lhs.subtitle == rhs.subtitle && lhs.authors == rhs.authors
+ && lhs.publisher == rhs.publisher
+ && lhs.publishedDate == rhs.publishedDate
+ && lhs.description == rhs.description
+ }
+}
diff --git a/ONMIR/Feature/NewBook/Components/SearchResultBookCell.swift b/ONMIR/Feature/NewBook/Components/SearchResultBookCell.swift
new file mode 100644
index 0000000..c26263d
--- /dev/null
+++ b/ONMIR/Feature/NewBook/Components/SearchResultBookCell.swift
@@ -0,0 +1,103 @@
+import Nuke
+import SnapKit
+import UIKit
+
+extension NewBookViewController {
+ final class BookCell: UICollectionViewCell {
+ private let coverImageView: UIImageView = {
+ let imageView = UIImageView()
+ imageView.contentMode = .scaleAspectFit
+ imageView.layer.cornerRadius = 5
+ imageView.clipsToBounds = true
+ return imageView
+ }()
+
+ private let titleLabel: UILabel = {
+ let label = UILabel()
+ label.font = .systemFont(ofSize: 13, weight: .bold)
+ label.numberOfLines = 2
+ label.minimumScaleFactor = 0.75
+ label.adjustsFontSizeToFitWidth = true
+ label.textColor = .label
+ return label
+ }()
+
+ private let authorLabel: UILabel = {
+ let label = UILabel()
+ label.font = .systemFont(ofSize: 12)
+ label.textColor = .tertiaryLabel
+ label.numberOfLines = 2
+ label.adjustsFontSizeToFitWidth = true
+ label.minimumScaleFactor = 0.75
+ return label
+ }()
+
+ private var imageDownloadTask: Task?
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ setupUI()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func prepareForReuse() {
+ super.prepareForReuse()
+ imageDownloadTask?.cancel()
+ imageDownloadTask = nil
+ coverImageView.image = nil
+ titleLabel.text = nil
+ authorLabel.text = nil
+ }
+
+ private func setupUI() {
+ self.backgroundColor = .clear
+ contentView.backgroundColor = .clear
+
+ contentView.addSubview(coverImageView)
+ contentView.addSubview(titleLabel)
+ contentView.addSubview(authorLabel)
+
+ coverImageView.snp.makeConstraints { make in
+ make.leading.equalToSuperview().inset(32)
+ make.verticalEdges.equalToSuperview().inset(8)
+ make.height.equalTo(100)
+ make.width.equalTo(coverImageView.snp.height).multipliedBy(0.75)
+ }
+
+ titleLabel.snp.makeConstraints { make in
+ make.top.equalToSuperview().inset(8)
+ make.leading.equalTo(coverImageView.snp.trailing).offset(10)
+ make.trailing.equalToSuperview().inset(12)
+ }
+
+ authorLabel.snp.makeConstraints { make in
+ make.top.equalTo(titleLabel.snp.bottom).offset(0)
+ make.leading.equalTo(coverImageView.snp.trailing).offset(10)
+ make.trailing.equalToSuperview().inset(32)
+ make.bottom.lessThanOrEqualToSuperview()
+ }
+ }
+
+ func configure(with book: BookSearchRepresentation) {
+ titleLabel.text = book.title
+ authorLabel.text = book.authors?.joined(separator: ", ")
+
+ if let thumbnailURL = book.thumbnailURL {
+ self.imageDownloadTask = Task {
+ do {
+ let image = try await ImagePipeline.shared.image(for: thumbnailURL)
+
+ await MainActor.run {
+ self.coverImageView.image = image
+ }
+ } catch {
+ Logger.error(error)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/ONMIR/Feature/NewBook/Components/SelectedBookView.swift b/ONMIR/Feature/NewBook/Components/SelectedBookView.swift
new file mode 100644
index 0000000..dcd752f
--- /dev/null
+++ b/ONMIR/Feature/NewBook/Components/SelectedBookView.swift
@@ -0,0 +1,114 @@
+import Nuke
+import SnapKit
+import UIKit
+
+extension NewBookViewController {
+ final class SelectedBookView: UIView {
+ private let containerView: UIView = {
+ let view = UIView()
+ view.layer.cornerRadius = 12
+ view.clipsToBounds = true
+ view.backgroundColor = .clear
+ return view
+ }()
+
+ private let coverImageView: UIImageView = {
+ let imageView = UIImageView()
+ imageView.contentMode = .scaleAspectFit
+ imageView.backgroundColor = .clear
+ imageView.layer.cornerRadius = 5
+ imageView.clipsToBounds = true
+ return imageView
+ }()
+
+ private let titleLabel: UILabel = {
+ let label = UILabel()
+ label.font = .systemFont(ofSize: 22, weight: .bold)
+ label.numberOfLines = 2
+ label.minimumScaleFactor = 0.75
+ label.adjustsFontSizeToFitWidth = true
+ label.textAlignment = .center
+ label.textColor = .label
+ return label
+ }()
+
+ private let authorLabel: UILabel = {
+ let label = UILabel()
+ label.font = .systemFont(ofSize: 12)
+ label.numberOfLines = 2
+ label.minimumScaleFactor = 0.75
+ label.adjustsFontSizeToFitWidth = true
+ label.textColor = .tertiaryLabel
+ label.textAlignment = .center
+ return label
+ }()
+
+ private var imageDownloadTask: Task?
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ setupSubviews()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func setupSubviews() {
+ addSubview(containerView)
+
+ containerView.snp.makeConstraints { make in
+ make.edges.equalToSuperview().inset(20)
+ }
+
+ containerView.addSubview(coverImageView)
+ containerView.addSubview(titleLabel)
+ containerView.addSubview(authorLabel)
+
+ coverImageView.snp.makeConstraints { make in
+ make.top.equalToSuperview()
+ make.centerX.equalToSuperview()
+ make.horizontalEdges.equalToSuperview().inset(20)
+ make.height.equalTo(225)
+ }
+
+ titleLabel.snp.makeConstraints { make in
+ make.top.equalTo(coverImageView.snp.bottom).offset(12)
+ make.leading.trailing.equalToSuperview().inset(20)
+ }
+
+ authorLabel.snp.makeConstraints { make in
+ make.top.equalTo(titleLabel.snp.bottom).offset(0)
+ make.leading.trailing.equalToSuperview().inset(20)
+ make.bottom.lessThanOrEqualToSuperview()
+ }
+ }
+
+ func configure(with book: BookSearchRepresentation) {
+ titleLabel.text = book.title
+ authorLabel.text = book.authors?.joined(separator: ", ")
+
+ imageDownloadTask?.cancel()
+ imageDownloadTask = nil
+ if let thumbnailURL = book.thumbnailURL {
+ let imageTask = ImagePipeline.shared.imageTask(with: thumbnailURL)
+
+ imageDownloadTask = Task {
+ do {
+ let image = try await imageTask.image
+ guard Task.isCancelled == false else { return }
+ coverImageView.image = image
+ } catch {
+ Logger.error(error)
+ }
+ }
+ }
+ }
+
+ func reset() {
+ coverImageView.image = nil
+ titleLabel.text = nil
+ authorLabel.text = nil
+ }
+ }
+}
diff --git a/ONMIR/Feature/NewBook/NewBookViewController.swift b/ONMIR/Feature/NewBook/NewBookViewController.swift
new file mode 100644
index 0000000..36b37bf
--- /dev/null
+++ b/ONMIR/Feature/NewBook/NewBookViewController.swift
@@ -0,0 +1,290 @@
+import SnapKit
+import UIKit
+
+public final class NewBookViewController: UIViewController {
+ private lazy var searchController: UISearchController = {
+ let searchController = UISearchController(searchResultsController: nil)
+ searchController.searchBar.placeholder = "Search by title, author or keyword"
+ searchController.obscuresBackgroundDuringPresentation = false
+ searchController.searchResultsUpdater = self
+ searchController.searchBar.delegate = self
+ return searchController
+ }()
+
+ private let selectedBookView: SelectedBookView = {
+ let view = SelectedBookView()
+ view.isHidden = true
+ view.backgroundColor = .clear
+ return view
+ }()
+
+ private lazy var collectionView: UICollectionView = {
+ let layout = createCompositionalLayout()
+ let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
+ collectionView.backgroundColor = .clear
+ collectionView.delegate = self
+ return collectionView
+ }()
+
+ private let viewModel = NewBookViewModel()
+
+ private typealias DataSource = UICollectionViewDiffableDataSource
+ private typealias Snapshot = NSDiffableDataSourceSnapshot
+
+ private let bookCellRegistration: UICollectionView.CellRegistration =
+ UICollectionView.CellRegistration {
+ cell, indexPath, book in
+ cell.configure(with: book)
+ }
+
+ private lazy var dataSource: DataSource = DataSource(
+ collectionView: collectionView
+ ) { [weak self] (collectionView, indexPath, book) -> UICollectionViewCell? in
+ guard let self = self else { return nil }
+ return collectionView.dequeueConfiguredReusableCell(
+ using: self.bookCellRegistration,
+ for: indexPath,
+ item: book
+ )
+ }
+
+ private let completion: @MainActor () -> Void
+
+ private var searchTask: Task?
+ private let searchDebounceTime: TimeInterval = 0.3
+
+ init(completion: @MainActor @escaping () -> Void) {
+ self.completion = completion
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ public override func viewDidLoad() {
+ super.viewDidLoad()
+ setupUI()
+ setupNavigationBar()
+ setupBindings()
+
+ var configuration = UIContentUnavailableConfiguration.search()
+ configuration.image = .init(systemName: "magnifyingglass.circle")
+ configuration.text = "Discover Your Next Great Read"
+ configuration.secondaryText = "Search for books by title, author, or genre to begin exploring"
+
+ self.contentUnavailableConfiguration = configuration
+ }
+
+ public override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ searchController.searchBar.becomeFirstResponder()
+ }
+
+ private func setupBindings() {
+ observeBooks()
+ observeSelectedBooks()
+ }
+
+ private func observeBooks() {
+ withObservationTracking {
+ _ = viewModel.books
+ } onChange: { [weak self] in
+ Task { @MainActor in
+ guard let self else { return }
+
+ self.updateDataSource(books: self.viewModel.books)
+ self.observeBooks()
+ }
+ }
+ }
+
+ private func observeSelectedBooks() {
+ withObservationTracking {
+ _ = viewModel.selectedBook
+ } onChange: { [weak self] in
+ Task { @MainActor in
+ guard let self else { return }
+
+ self.updateSelectedBook(selectedBook: self.viewModel.selectedBook)
+ self.navigationItem.rightBarButtonItem?.isEnabled = self.viewModel.selectedBook != nil
+ self.observeSelectedBooks()
+ }
+ }
+ }
+
+ private func setupUI() {
+ view.backgroundColor = .secondarySystemBackground
+
+ view.addSubview(selectedBookView)
+ view.addSubview(collectionView)
+
+ selectedBookView.snp.makeConstraints { make in
+ make.top.equalTo(view.safeAreaLayoutGuide)
+ make.leading.trailing.equalToSuperview()
+ }
+
+ collectionView.snp.makeConstraints { make in
+ make.top.equalTo(selectedBookView.snp.bottom)
+ make.leading.trailing.bottom.equalToSuperview()
+ }
+
+ updateCollectionViewConstraints()
+ }
+
+ private func updateCollectionViewConstraints() {
+ if viewModel.selectedBook == nil {
+ collectionView.snp.remakeConstraints { make in
+ make.top.equalTo(view.safeAreaLayoutGuide)
+ make.leading.trailing.bottom.equalToSuperview()
+ }
+ selectedBookView.isHidden = true
+ } else {
+ collectionView.snp.remakeConstraints { make in
+ make.top.equalTo(selectedBookView.snp.bottom)
+ make.leading.trailing.bottom.equalToSuperview()
+ }
+ selectedBookView.isHidden = false
+ }
+
+ UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.3, delay: 0.0, options: .curveEaseInOut) {
+ self.view.layoutIfNeeded()
+ }
+ }
+
+ private func setupNavigationBar() {
+ navigationItem.title = "New Book"
+ navigationItem.searchController = searchController
+ navigationItem.hidesSearchBarWhenScrolling = false
+ navigationItem.leftBarButtonItem = UIBarButtonItem(
+ title: "Cancel",
+ primaryAction: UIAction { [weak self] _ in
+ self?.cancelButtonTapped()
+ }
+ )
+ navigationItem.rightBarButtonItem = UIBarButtonItem(
+ title: "Done",
+ primaryAction: UIAction { [weak self] _ in
+ self?.doneButtonTapped()
+ }
+ )
+ navigationItem.rightBarButtonItem?.style = .done
+ navigationItem.rightBarButtonItem?.isEnabled = false
+ }
+
+ private func updateDataSource(books: [BookSearchRepresentation]) {
+ var snapshot = Snapshot()
+
+ snapshot.appendSections([0])
+ snapshot.appendItems(books)
+
+ dataSource.apply(snapshot, animatingDifferences: true)
+
+ updateContentUnavailableConfiguration(isEmpty: books.isEmpty)
+ }
+
+ private func updateSelectedBook(selectedBook: BookSearchRepresentation?) {
+ if let selectedBook = viewModel.selectedBook {
+ selectedBookView.configure(with: selectedBook)
+ } else {
+ selectedBookView.reset()
+ }
+
+ updateCollectionViewConstraints()
+ }
+
+ private func cancelButtonTapped() {
+ dismiss(animated: true)
+ }
+
+ private func doneButtonTapped() {
+ guard viewModel.hasSelectedBook else {
+ let alert = UIAlertController(
+ title: "No Book Selected",
+ message: "Please select at least one book to continue.",
+ preferredStyle: .alert
+ )
+ alert.addAction(UIAlertAction(title: "OK", style: .default))
+ present(alert, animated: true)
+ return
+ }
+
+ dismiss(animated: true) { [weak self] in
+ self?.completion()
+ }
+ }
+
+ private func createCompositionalLayout() -> UICollectionViewCompositionalLayout {
+ return UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in
+ var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
+ configuration.backgroundColor = .clear
+ configuration.separatorConfiguration.bottomSeparatorInsets = .init(top: 0, leading: 90, bottom: 0, trailing: 0)
+
+ let section = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment)
+ section.interGroupSpacing = 16
+ return section
+ }
+ }
+
+ private func updateContentUnavailableConfiguration(isEmpty: Bool) {
+ if isEmpty {
+ var configuration = UIContentUnavailableConfiguration.search()
+ configuration.image = .init(systemName: "book.closed")
+ configuration.text = "magnifyingglass.circle.fill"
+ configuration.secondaryText = "Try different keywords or check for typos in your search"
+
+ self.contentUnavailableConfiguration = configuration
+ } else {
+ self.contentUnavailableConfiguration = nil
+ }
+ }
+
+ private func debouncedSearch(query: String) {
+ searchTask?.cancel()
+ searchTask = nil
+
+ if query.isEmpty {
+ updateContentUnavailableConfiguration(isEmpty: true)
+ return
+ }
+
+ searchTask = Task { [weak self] in
+ guard let self = self else { return }
+
+ try? await Task.sleep(for: .seconds(0.5))
+
+ guard !Task.isCancelled else { return }
+
+ await self.viewModel.fetchBooks(query: query)
+ }
+ }
+}
+
+extension NewBookViewController: UICollectionViewDelegate {
+ public func collectionView(
+ _ collectionView: UICollectionView,
+ didSelectItemAt indexPath: IndexPath
+ ) {
+ guard let book = dataSource.itemIdentifier(for: indexPath) else { return }
+ viewModel.toggleBookSelection(book: book)
+ }
+}
+
+extension NewBookViewController: UISearchBarDelegate {
+ public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
+ if searchText.isEmpty {
+ searchTask?.cancel()
+ searchTask = nil
+ return
+ }
+
+ debouncedSearch(query: searchText)
+ }
+}
+
+extension NewBookViewController: UISearchResultsUpdating {
+ public func updateSearchResults(for searchController: UISearchController) {
+ guard let searchText = searchController.searchBar.text, !searchText.isEmpty else { return }
+ debouncedSearch(query: searchText)
+ }
+}
diff --git a/ONMIR/Feature/NewBook/NewBookViewModel.swift b/ONMIR/Feature/NewBook/NewBookViewModel.swift
new file mode 100644
index 0000000..b04a8e2
--- /dev/null
+++ b/ONMIR/Feature/NewBook/NewBookViewModel.swift
@@ -0,0 +1,48 @@
+import Foundation
+import Combine
+import Observation
+
+@MainActor
+@Observable
+final class NewBookViewModel {
+ private(set) var books: [BookSearchRepresentation] = []
+ private(set) var selectedBook: BookSearchRepresentation?
+
+ @ObservationIgnored
+ private let googleBooksClient = GoogleBooksClient()
+
+ var hasSelectedBook: Bool {
+ return selectedBook != nil
+ }
+
+ func fetchBooks(query: String) async {
+ do {
+ let response = try await googleBooksClient.searchBooks(
+ query: query,
+ startIndex: 0
+ )
+
+ if let items = response.items {
+ let bookRepresentations = items.map { BookSearchRepresentation(from: $0) }
+
+ await MainActor.run {
+ self.books = bookRepresentations
+ }
+ }
+ } catch {
+ Logger.error("Error fetching books: \(error)")
+ }
+ }
+
+ func toggleBookSelection(book: BookSearchRepresentation) {
+ if let currentSelectedBook = selectedBook, currentSelectedBook.id == book.id {
+ selectedBook = nil
+ } else {
+ selectedBook = book
+ }
+ }
+
+ func clearSelection() {
+ selectedBook = nil
+ }
+}
diff --git a/ONMIR/SceneDelegate.swift b/ONMIR/SceneDelegate.swift
index 4092977..5deb6aa 100644
--- a/ONMIR/SceneDelegate.swift
+++ b/ONMIR/SceneDelegate.swift
@@ -7,33 +7,26 @@
import UIKit
-class SceneDelegate: UIResponder, UIWindowSceneDelegate {
-
- var window: UIWindow?
-
-
- func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
- guard let windowScence = (scene as? UIWindowScene) else { return }
- window = UIWindow(frame: windowScence.coordinateSpace.bounds)
- window?.rootViewController = UINavigationController(rootViewController: ViewController())
- window?.makeKeyAndVisible()
- window?.windowScene = windowScence
- }
-
- func sceneDidDisconnect(_ scene: UIScene) {
- }
-
- func sceneDidBecomeActive(_ scene: UIScene) {}
-
- func sceneWillResignActive(_ scene: UIScene) {
- }
-
- func sceneWillEnterForeground(_ scene: UIScene) {
- }
-
- func sceneDidEnterBackground(_ scene: UIScene) {
- }
-
-
-}
+final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
+ var window: UIWindow?
+
+ func scene(
+ _ scene: UIScene, willConnectTo session: UISceneSession,
+ options connectionOptions: UIScene.ConnectionOptions
+ ) {
+ guard let windowScence = (scene as? UIWindowScene) else { return }
+ window = UIWindow(windowScene: windowScence)
+ window?.rootViewController = UINavigationController(rootViewController: UIViewController())
+ window?.makeKeyAndVisible()
+ }
+
+ func sceneDidDisconnect(_ scene: UIScene) {}
+
+ func sceneDidBecomeActive(_ scene: UIScene) {}
+ func sceneWillResignActive(_ scene: UIScene) {}
+
+ func sceneWillEnterForeground(_ scene: UIScene) {}
+
+ func sceneDidEnterBackground(_ scene: UIScene) {}
+}
diff --git a/ONMIR/Shared/Logger/Logger.swift b/ONMIR/Shared/Logger/Logger.swift
new file mode 100644
index 0000000..77ca272
--- /dev/null
+++ b/ONMIR/Shared/Logger/Logger.swift
@@ -0,0 +1,127 @@
+import Foundation
+import OSLog
+import os
+
+public enum LogLevel {
+ case error
+ case warning
+ case info
+ case debug
+ case verbose
+
+ var symbol: String {
+ switch self {
+ case .error: return "🚨 ERROR"
+ case .warning: return "⚠️ WARNING"
+ case .info: return "🔨 INFO"
+ case .debug: return "🐛 DEBUG"
+ case .verbose: return "👾 VERBOSE"
+ }
+ }
+
+ var osLogType: OSLogType {
+ switch self {
+ case .error: return .error
+ case .warning: return .error
+ case .info: return .info
+ case .debug: return .default
+ case .verbose: return .default
+ }
+ }
+}
+
+public struct Logger: Sendable {
+ private static let subsystem = Bundle.main.bundleIdentifier ?? "Logger"
+ private static let systemLogger: os.Logger = os.Logger(subsystem: subsystem, category: "Default")
+
+ public static func error(
+ _ items: Any...,
+ file: StaticString = #file,
+ function: StaticString = #function,
+ line: UInt = #line
+ ) {
+ assertionFailure("\(items.map { String(describing: $0) }.joined(separator: " "))")
+ log(level: .error, items: items, file: file, function: function, line: line)
+ }
+
+ public static func warning(
+ _ items: Any...,
+ file: StaticString = #file,
+ function: StaticString = #function,
+ line: UInt = #line
+ ) {
+ log(level: .warning, items: items, file: file, function: function, line: line)
+ }
+
+ public static func info(
+ _ items: Any...,
+ file: StaticString = #file,
+ function: StaticString = #function,
+ line: UInt = #line
+ ) {
+ log(level: .info, items: items, file: file, function: function, line: line)
+ }
+
+ public static func debug(
+ _ items: Any...,
+ file: StaticString = #file,
+ function: StaticString = #function,
+ line: UInt = #line
+ ) {
+ log(level: .debug, items: items, file: file, function: function, line: line)
+ }
+
+ public static func verbose(
+ _ items: Any...,
+ file: StaticString = #file,
+ function: StaticString = #function,
+ line: UInt = #line
+ ) {
+ log(level: .verbose, items: items, file: file, function: function, line: line)
+ }
+
+ // MARK: - Private
+
+ private static func log(
+ level: LogLevel,
+ items: [Any],
+ file: StaticString,
+ function: StaticString,
+ line: UInt
+ ) {
+ let message = items.map { String(describing: $0) }.joined(separator: " ")
+ let metadata =
+ "[\(file.description.components(separatedBy: "/").last ?? "")][\(function)][\(line)]"
+
+ systemLogger.log(
+ level: level.osLogType,
+ "\(level.symbol, privacy: .public) \(metadata, privacy: .public) - \(message, privacy: .public)"
+ )
+ }
+
+ public static func export() -> [Data] {
+ do {
+ let store = try OSLogStore(scope: .currentProcessIdentifier)
+ let threeDaysAgo = Calendar.current.date(byAdding: .day, value: -3, to: Date())!
+ let position = store.position(date: threeDaysAgo)
+
+ let entries = try store.getEntries(at: position)
+ .compactMap { $0 as? OSLogEntryLog }
+ .filter { $0.subsystem == "com.msg.onmir" }
+
+ let datas = entries.compactMap { logEntry -> Data? in
+ let formattedLog = """
+ \(logEntry.date) \(logEntry.category) \
+ [\(logEntry.subsystem)] \
+ \(logEntry.composedMessage)\n
+ """
+
+ return formattedLog.data(using: .utf8)
+ }
+ return datas
+ } catch {
+ print("Failed to retrieve logs: \(error)")
+ return []
+ }
+ }
+}