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
- }
- }
-}