diff --git a/TestApp/Sources/OPDS/Base.lproj/OPDS.storyboard b/TestApp/Sources/OPDS/Base.lproj/OPDS.storyboard deleted file mode 100644 index 7f3702714..000000000 --- a/TestApp/Sources/OPDS/Base.lproj/OPDS.storyboard +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/TestApp/Sources/OPDS/OPDSFactory.swift b/TestApp/Sources/OPDS/OPDSFactory.swift index 89bba69bd..99bfe2d50 100644 --- a/TestApp/Sources/OPDS/OPDSFactory.swift +++ b/TestApp/Sources/OPDS/OPDSFactory.swift @@ -14,14 +14,4 @@ final class OPDSFactory { static let shared = OPDSFactory() weak var delegate: OPDSModuleDelegate? - private let storyboard = UIStoryboard(name: "OPDS", bundle: nil) -} - -extension OPDSFactory: OPDSPublicationInfoViewControllerFactory { - func make(publication: Publication) -> OPDSPublicationInfoViewController { - let controller = storyboard.instantiateViewController(withIdentifier: "OPDSPublicationInfoViewController") as! OPDSPublicationInfoViewController - controller.publication = publication - controller.moduleDelegate = delegate - return controller - } } diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift index 7dac50ce9..ae29c3e1f 100644 --- a/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift @@ -7,13 +7,173 @@ import ReadiumShared import SwiftUI -/// A SwiftUI wrapper for the UIKit OPDSPublicationInfoViewController. -struct OPDSPublicationInfoView: UIViewControllerRepresentable { +struct OPDSPublicationInfoView: View { let publication: Publication + let download: (ReadiumShared.Link, @escaping (Double) -> Void) async throws -> Book - func makeUIViewController(context: Context) -> OPDSPublicationInfoViewController { - OPDSFactory.shared.make(publication: publication) + @State private var isDownloading = false + @State private var alert: AlertMessage? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + HStack(alignment: .top, spacing: 20) { + coverView + .frame(width: 120, height: 180) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4) + + VStack(alignment: .leading, spacing: 8) { + Text(publication.metadata.title ?? "") + .font(.title3) + .fontWeight(.semibold) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + + Text(publication.metadata.authors.map(\.name).joined(separator: ", ")) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + Divider() + + if let description = publication.metadata.description { + VStack(alignment: .leading, spacing: 10) { + Text("About") + .font(.headline) + + Text(description) + .font(.body) + .foregroundStyle(.secondary) + } + } + } + .padding() + } + .safeAreaInset(edge: .bottom) { + if let downloadLink = publication.downloadLinks.first { + VStack { + Button(action: { + Task { + await download(link: downloadLink) + } + }) { + HStack { + if isDownloading { + ProgressView() + .tint(.white) + } else { + Text("Download") + .fontWeight(.bold) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isDownloading) + } + .padding() + .background(.bar) + .overlay(Divider(), alignment: .top) + } + } + .navigationBarTitleDisplayMode(.inline) + .alert(item: $alert) { message in + Alert(title: Text(message.title), message: Text(message.message), dismissButton: .cancel()) + } + } + + // MARK: - Subviews + + @ViewBuilder + private var coverView: some View { + if let coverURL = imageURL { + AsyncImage(url: coverURL) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } else if phase.error != nil { + PlaceholderView(publication: publication) + } else { + ZStack { + Color(.secondarySystemBackground) + ProgressView() + } + } + } + } else { + PlaceholderView(publication: publication) + } + } + + // MARK: - Logic + + private var imageURL: URL? { + let primaryURL = publication.coverLink?.url(relativeTo: publication.baseURL).httpURL?.url + let fallbackURL = publication.images.first?.url(relativeTo: publication.baseURL).httpURL?.url + return primaryURL ?? fallbackURL } - func updateUIViewController(_ uiViewController: OPDSPublicationInfoViewController, context: Context) {} + private func download(link: ReadiumShared.Link) async { + isDownloading = true + do { + let book = try await download(link) { _ in } + alert = AlertMessage( + title: NSLocalizedString("success_title", comment: "Title of the alert when a publication is successfully downloaded"), + message: String(format: NSLocalizedString("library_download_success_message", comment: "Message of the alert when a publication is successfully downloaded"), book.title) + ) + } catch { + alert = AlertMessage( + title: NSLocalizedString("error_title", comment: "Title of the alert when an error occurred"), + message: error.localizedDescription + ) + } + isDownloading = false + } + + struct AlertMessage: Identifiable { + var id: String { title + message } + let title: String + let message: String + } + + struct PlaceholderView: View { + let publication: Publication + + var body: some View { + GeometryReader { _ in + ZStack { + Color(red: 0.06, green: 0.18, blue: 0.25) + + VStack { + if let title = publication.metadata.title { + Text(title) + Text("_________") + } + + Text(publication.metadata.authors.map(\.name).joined(separator: ", ")) + } + .font(.system(size: 9)) + .foregroundColor(Color(red: 0.86, green: 0.86, blue: 0.86)) + .padding() + } + .border(Color(red: 0.08, green: 0.26, blue: 0.36), width: 5) + } + } + } +} + +private extension Publication { + /// Finds the first link with `cover` or thumbnail relations. + var coverLink: ReadiumShared.Link? { + links.firstWithRel(.cover) + ?? links.firstWithRel("http://opds-ps.org/image") + ?? links.firstWithRel("http://opds-ps.org/image/thumbnail") + } } diff --git a/TestApp/Sources/OPDS/OPDSModule.swift b/TestApp/Sources/OPDS/OPDSModule.swift index 74b248d0f..b522c97ba 100644 --- a/TestApp/Sources/OPDS/OPDSModule.swift +++ b/TestApp/Sources/OPDS/OPDSModule.swift @@ -12,6 +12,7 @@ import UIKit enum OPDSError: Error { case invalidURL(String) + case delegateNotAvailable } /// The OPDS module handles the presentation of OPDS catalogs. @@ -58,7 +59,17 @@ final class OPDSModule: OPDSModuleAPI { OPDSFeedView(feedURL: url, delegate: self.delegate) } .navigationDestination(for: OPDSFeedView.NavigablePublication.self) { navPublication in - OPDSPublicationInfoView(publication: navPublication.publication) + OPDSPublicationInfoView(publication: navPublication.publication) { link, progress in + guard let delegate = self.delegate else { + throw OPDSError.delegateNotAvailable + } + return try await delegate.opdsDownloadPublication( + navPublication.publication, + at: link, + sender: self.rootViewController, + progress: progress + ) + } } } diff --git a/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift b/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift deleted file mode 100644 index 9d35d2d6a..000000000 --- a/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Combine -import Kingfisher -import ReadiumShared -import UIKit - -protocol OPDSPublicationInfoViewControllerFactory { - func make(publication: Publication) -> OPDSPublicationInfoViewController -} - -class OPDSPublicationInfoViewController: UIViewController, Loggable { - weak var moduleDelegate: OPDSModuleDelegate? - - var publication: Publication? - - @IBOutlet var imageView: UIImageView! - @IBOutlet var fxImageView: UIImageView! - @IBOutlet var titleLabel: UILabel! - @IBOutlet var authorLabel: UILabel! - @IBOutlet var descriptionLabel: UILabel! - @IBOutlet var downloadButton: UIButton! - @IBOutlet var downloadActivityIndicator: UIActivityIndicatorView! - - private lazy var downloadLink: Link? = publication?.downloadLinks.first - private var subscriptions = Set() - - override func viewDidLoad() { - fxImageView.clipsToBounds = true - fxImageView!.contentMode = .scaleAspectFill - imageView!.contentMode = .scaleAspectFit - - let titleTextView = OPDSPlaceholderPublicationView( - frame: imageView.frame, - title: publication?.metadata.title, - author: publication?.metadata.authors - .map(\.name) - .joined(separator: ", ") - ) - - if let images = publication?.images { - if images.count > 0 { - let coverURL = URL(string: images[0].href) - if coverURL != nil { - imageView.kf.setImage( - with: coverURL, - placeholder: titleTextView, - options: [.transition(ImageTransition.fade(0.5))], - progressBlock: nil - ) { result in - switch result { - case let .success(image): - self.fxImageView?.image = image.image - UIView.transition( - with: self.fxImageView, - duration: 0.3, - options: .transitionCrossDissolve, - animations: { self.fxImageView?.image = image.image }, - completion: nil - ) - case .failure: - break - } - } - } - } - } - - titleLabel.text = publication?.metadata.title - authorLabel.text = publication?.metadata.authors - .map(\.name) - .joined(separator: ", ") - descriptionLabel.text = publication?.metadata.description - descriptionLabel.sizeToFit() - - downloadActivityIndicator.stopAnimating() - - // If we are not able to get a free link, we hide the download button - // TODO: handle payment or redirection for others links? - if downloadLink == nil { - downloadButton.isHidden = true - } - } - - @IBAction func downloadBook(_ sender: UIButton) { - guard let delegate = moduleDelegate, let downloadLink = downloadLink else { - return - } - - Task { - downloadActivityIndicator.startAnimating() - downloadButton.isEnabled = false - - do { - let book = try await delegate.opdsDownloadPublication(publication, at: downloadLink, sender: self, progress: { _ in }) - delegate.presentAlert( - NSLocalizedString("success_title", comment: "Title of the alert when a publication is successfully downloaded"), - message: String(format: NSLocalizedString("library_download_success_message", comment: "Message of the alert when a publication is successfully downloaded"), book.title), - from: self - ) - } catch { - delegate.presentError(UserError(error), from: self) - } - - downloadActivityIndicator.stopAnimating() - downloadButton.isEnabled = true - } - } -}