Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ONMIR/Core/GoogleBooks/GoogleBooksClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public struct GoogleBooksClient: Sendable {
case unexpectedResponse
case decodingError(Error)
case underlying(Error)
case cancelled
}

public enum OrderByType: String, Sendable {
Expand Down Expand Up @@ -56,6 +57,12 @@ public struct GoogleBooksClient: Sendable {
return try decoder.decode(BookSearchResponse.self, from: data)
} catch let decodingError as DecodingError {
throw GoogleBooksError.decodingError(decodingError)
} catch let urlError as URLError {
if urlError.code == .cancelled {
throw GoogleBooksError.cancelled
} else {
throw GoogleBooksError.underlying(urlError)
}
} catch {
throw GoogleBooksError.underlying(error)
}
Expand Down
2 changes: 2 additions & 0 deletions ONMIR/Feature/NewBook/Components/SearchResultBookCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ extension NewBookViewController {
await MainActor.run {
self.coverImageView.image = image
}
} catch let error as CancellationError {
Logger.info(error)
} catch {
Logger.error(error)
}
Expand Down
2 changes: 2 additions & 0 deletions ONMIR/Feature/NewBook/Components/SelectedBookView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ extension NewBookViewController {
let image = try await imageTask.image
guard Task.isCancelled == false else { return }
coverImageView.image = image
} catch let error as CancellationError {
Logger.info(error)
} catch {
Logger.error(error)
}
Expand Down
47 changes: 45 additions & 2 deletions ONMIR/Feature/NewBook/NewBookViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ public final class NewBookViewController: UIViewController {
private var searchTask: Task<Void, Never>?
private let searchDebounceTime: TimeInterval = 0.3

private let loadingIndicator: UIActivityIndicatorView = {
let indicator = UIActivityIndicatorView(style: .medium)
indicator.hidesWhenStopped = true
return indicator
}()

init(completion: @MainActor @escaping () -> Void) {
self.completion = completion
super.init(nibName: nil, bundle: nil)
Expand Down Expand Up @@ -84,6 +90,7 @@ public final class NewBookViewController: UIViewController {
private func setupBindings() {
observeBooks()
observeSelectedBooks()
observeLoadingState()
}

private func observeBooks() {
Expand Down Expand Up @@ -112,12 +119,31 @@ public final class NewBookViewController: UIViewController {
}
}
}

private func observeLoadingState() {
withObservationTracking {
_ = viewModel.isLoading
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }

if self.viewModel.isLoading {
self.loadingIndicator.startAnimating()
} else {
self.loadingIndicator.stopAnimating()
}

self.observeLoadingState()
}
}
}

private func setupUI() {
view.backgroundColor = .secondarySystemBackground

view.addSubview(selectedBookView)
view.addSubview(collectionView)
view.addSubview(loadingIndicator)

selectedBookView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
Expand All @@ -128,6 +154,11 @@ public final class NewBookViewController: UIViewController {
make.top.equalTo(selectedBookView.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}

loadingIndicator.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-20)
}

updateCollectionViewConstraints()
}
Expand Down Expand Up @@ -230,7 +261,7 @@ public final class NewBookViewController: UIViewController {
if isEmpty {
var configuration = UIContentUnavailableConfiguration.search()
configuration.image = .init(systemName: "book.closed")
configuration.text = "magnifyingglass.circle.fill"
configuration.text = "No Books Found"
configuration.secondaryText = "Try different keywords or check for typos in your search"

self.contentUnavailableConfiguration = configuration
Expand All @@ -251,7 +282,7 @@ public final class NewBookViewController: UIViewController {
searchTask = Task { [weak self] in
guard let self = self else { return }

try? await Task.sleep(for: .seconds(0.5))
try? await Task.sleep(for: .seconds(0.3))

guard !Task.isCancelled else { return }

Expand All @@ -268,6 +299,18 @@ extension NewBookViewController: UICollectionViewDelegate {
guard let book = dataSource.itemIdentifier(for: indexPath) else { return }
viewModel.toggleBookSelection(book: book)
}

public func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
let height = scrollView.frame.size.height

if offsetY > contentHeight - height {
if let visibleItems = collectionView.indexPathsForVisibleItems.map({ $0.row }).max() {
viewModel.loadMoreBooksIfNeeded(currentIndex: visibleItems)
}
}
}
}

extension NewBookViewController: UISearchBarDelegate {
Expand Down
51 changes: 46 additions & 5 deletions ONMIR/Feature/NewBook/NewBookViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ final class NewBookViewModel {
private(set) var books: [BookSearchRepresentation] = []
private(set) var selectedBook: BookSearchRepresentation?

private(set) var isLoading = false
private(set) var currentPage = 0
private(set) var hasMorePages = true
private(set) var lastQuery = ""
private let maxResultsPerPage = 20

@ObservationIgnored
private let googleBooksClient = GoogleBooksClient()

Expand All @@ -16,21 +22,56 @@ final class NewBookViewModel {
}

func fetchBooks(query: String) async {
if query != lastQuery {
lastQuery = query
currentPage = 0
books = []
hasMorePages = true
}

guard !isLoading && hasMorePages else { return }

isLoading = true

do {
let startIndex = currentPage * maxResultsPerPage
let response = try await googleBooksClient.searchBooks(
query: query,
startIndex: 0
startIndex: startIndex,
maxResults: maxResultsPerPage
)

if let items = response.items {
if let items = response.items, !items.isEmpty {
let bookRepresentations = items.map { BookSearchRepresentation(from: $0) }

await MainActor.run {
self.books = bookRepresentations
if currentPage == 0 {
self.books = bookRepresentations
} else {
self.books.append(contentsOf: bookRepresentations)
}

currentPage += 1
hasMorePages = items.count == maxResultsPerPage && self.books.count < response.totalItems
}
} else {
hasMorePages = false
}
} catch .cancelled {
Logger.info("Book Fetch Cancelled")
} catch {
Logger.error("Error fetching books: \(error)")
Logger.error(error)
}

isLoading = false
}

func loadMoreBooksIfNeeded(currentIndex: Int) {
let thresholdIndex = books.count - 5
if currentIndex >= thresholdIndex && !isLoading && hasMorePages {
Task {
await fetchBooks(query: lastQuery)
}
}
}

Expand All @@ -45,4 +86,4 @@ final class NewBookViewModel {
func clearSelection() {
selectedBook = nil
}
}
}