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 [] + } + } +}