diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift index 3cca9cf6c..e3aecc3ab 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift @@ -6,7 +6,7 @@ import Foundation -struct OPDSCatalog: Identifiable, Equatable { +struct OPDSCatalog: Identifiable, Equatable, Hashable { let id: String var title: String var url: URL diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift index fa27bb251..029b496a0 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift @@ -14,11 +14,6 @@ struct OPDSCatalogRow: View { Image(systemName: "books.vertical.fill") .foregroundColor(.accentColor) Text(title) - - Spacer() - - Image(systemName: "chevron.right") - .foregroundColor(.gray) } } } diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift index df4c247a0..5c7dd8ce5 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift @@ -9,30 +9,32 @@ import SwiftUI struct OPDSCatalogsView: View { @State private var viewModel: OPDSCatalogsViewModel - init(viewModel: OPDSCatalogsViewModel) { + private var delegate: OPDSModuleDelegate? + + init(viewModel: OPDSCatalogsViewModel, delegate: OPDSModuleDelegate?) { self.viewModel = viewModel + self.delegate = delegate } var body: some View { List(viewModel.catalogs) { catalog in - OPDSCatalogRow(title: catalog.title) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.onCatalogTap(id: catalog.id) + NavigationLink(value: catalog) { + OPDSCatalogRow(title: catalog.title) + } + .contentShape(Rectangle()) + .swipeActions(allowsFullSwipe: false) { + Button(role: .destructive) { + viewModel.onDeleteCatalogTap(id: catalog.id) + } label: { + Label("Delete", systemImage: "trash") } - .swipeActions(allowsFullSwipe: false) { - Button(role: .destructive) { - viewModel.onDeleteCatalogTap(id: catalog.id) - } label: { - Label("Delete", systemImage: "trash") - } - Button { - viewModel.onEditCatalogTap(id: catalog.id) - } label: { - Label("Edit", systemImage: "pencil") - } + Button { + viewModel.onEditCatalogTap(id: catalog.id) + } label: { + Label("Edit", systemImage: "pencil") } + } } .listStyle(.plain) .onAppear { @@ -56,5 +58,7 @@ struct OPDSCatalogsView: View { } #Preview { - OPDSCatalogsView(viewModel: OPDSCatalogsViewModel()) + NavigationStack { + OPDSCatalogsView(viewModel: OPDSCatalogsViewModel(), delegate: nil) + } } diff --git a/TestApp/Sources/OPDS/OPDSFacets/Feed+preview.swift b/TestApp/Sources/OPDS/OPDSFacets/Feed+preview.swift deleted file mode 100644 index 6938b7bfb..000000000 --- a/TestApp/Sources/OPDS/OPDSFacets/Feed+preview.swift +++ /dev/null @@ -1,222 +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 Foundation -import ReadiumOPDS -import ReadiumShared - -extension Feed { - static var preview: Feed { - try! OPDS2Parser.parse( - jsonData: .preview, - url: URL(string: "http://opds-spec.org/opds.json")!, - response: URLResponse() - ).feed! - } -} - -private extension Data { - static var preview: Data { - let jsonString = """ - { - "@context": "http://opds-spec.org/opds.json", - "metadata": { - "title": "Example Library", - "modified": "2024-11-05T12:00:00Z", - "numberOfItems": 5000, - "itemsPerPage": 30 - }, - "links": [ - { - "rel": "self", - "href": "/opds", - "type": "application/opds+json" - } - ], - "facets": [ - { - "metadata": { - "title": "Genre" - }, - "links": [ - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?genre=fiction", - "title": "Fiction", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1250 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?genre=mystery", - "title": "Mystery & Detective", - "type": "application/opds+json", - "properties": { - "numberOfItems": 850 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?genre=scifi", - "title": "Science Fiction", - "type": "application/opds+json", - "properties": { - "numberOfItems": 725 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?genre=non-fiction", - "title": "Non-Fiction", - "type": "application/opds+json", - "properties": { - "numberOfItems": 2175 - } - } - ] - }, - { - "metadata": { - "title": "Language" - }, - "links": [ - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?language=en", - "title": "English", - "type": "application/opds+json", - "properties": { - "numberOfItems": 3000 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?language=es", - "title": "Spanish", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1000 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?language=ru", - "title": "Russian", - "type": "application/opds+json", - "properties": { - "numberOfItems": 800 - } - } - ] - }, - { - "metadata": { - "title": "Availability" - }, - "links": [ - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?availability=free", - "title": "Free", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1500 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?availability=subscription", - "title": "Subscription", - "type": "application/opds+json", - "properties": { - "numberOfItems": 2500 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?availability=buy", - "title": "Purchase Required", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1000 - } - } - ] - }, - { - "metadata": { - "title": "Reading Age" - }, - "links": [ - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?age=children", - "title": "Children (0-11)", - "type": "application/opds+json", - "properties": { - "numberOfItems": 800 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?age=teen", - "title": "Teen (12-18)", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1200 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?age=adult", - "title": "Adult (18+)", - "type": "application/opds+json", - "properties": { - "numberOfItems": 3000 - } - } - ] - } - ], - "publications": [ - { - "metadata": { - "title": "Sample Book", - "identifier": "urn:uuid:6409a00b-7bf2-405e-826c-3fdff0fd0734", - "modified": "2024-11-05T12:00:00Z", - "language": ["en"], - "published": "2024", - "author": [ - { - "name": "Sample Author" - } - ], - "subject": [ - { - "name": "Fiction", - "code": "fiction" - } - ] - }, - "links": [ - { - "rel": "http://opds-spec.org/acquisition", - "href": "/books/sample.epub", - "type": "application/epub+zip" - } - ] - } - ] - } - """ - guard let data = jsonString.data(using: .utf8) else { - return Data() - } - return data - } -} diff --git a/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetLink.swift b/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetLink.swift deleted file mode 100644 index a0e0a9d96..000000000 --- a/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetLink.swift +++ /dev/null @@ -1,39 +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 ReadiumShared -import SwiftUI - -struct OPDSFacetLink: View { - let link: ReadiumShared.Link - - var body: some View { - HStack { - if let title = link.title { - Text(title) - .foregroundStyle(Color.primary) - } - - Spacer() - - if let count = link.properties.numberOfItems { - Text("\(count)") - .foregroundStyle(Color.secondary) - .font(.subheadline) - } - - Image(systemName: "chevron.right") - } - .font(.body) - } -} - -#Preview { - OPDSFacetLink( - link: Feed.preview.facets[0].links[0] - ) - .padding() -} diff --git a/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetList.swift b/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetList.swift deleted file mode 100644 index ee73d979d..000000000 --- a/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetList.swift +++ /dev/null @@ -1,51 +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 ReadiumShared -import SwiftUI - -struct OPDSFacetList: View { - @Environment(\.dismiss) private var dismiss - - let feed: Feed - let onLinkSelected: (ReadiumShared.Link) -> Void - - var body: some View { - NavigationView { - facets - .toolbar { cancelButton } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Facets") - } - } - - private var facets: some View { - List(feed.facets, id: \.metadata.title) { facet in - Section(facet.metadata.title) { - ForEach(facet.links, id: \.href) { link in - OPDSFacetLink(link: link) - .contentShape(Rectangle()) - .onTapGesture { - onLinkSelected(link) - dismiss() - } - } - } - } - } - - private var cancelButton: some ToolbarContent { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { dismiss() } - } - } -} - -#Preview { - OPDSFacetList(feed: .preview) { link in - print("Tap on link \(link.href)") - } -} diff --git a/TestApp/Sources/OPDS/OPDSFactory.swift b/TestApp/Sources/OPDS/OPDSFactory.swift index e10e41b3b..89bba69bd 100644 --- a/TestApp/Sources/OPDS/OPDSFactory.swift +++ b/TestApp/Sources/OPDS/OPDSFactory.swift @@ -17,16 +17,6 @@ final class OPDSFactory { private let storyboard = UIStoryboard(name: "OPDS", bundle: nil) } -extension OPDSFactory: OPDSRootTableViewControllerFactory { - func make(feedURL: URL, indexPath: IndexPath?) -> OPDSRootTableViewController { - let controller = storyboard.instantiateViewController(withIdentifier: "OPDSRootTableViewController") as! OPDSRootTableViewController - controller.factory = self - controller.originalFeedURL = feedURL - controller.originalFeedIndexPath = nil - return controller - } -} - extension OPDSFactory: OPDSPublicationInfoViewControllerFactory { func make(publication: Publication) -> OPDSPublicationInfoViewController { let controller = storyboard.instantiateViewController(withIdentifier: "OPDSPublicationInfoViewController") as! OPDSPublicationInfoViewController diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift new file mode 100644 index 000000000..83424e416 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift @@ -0,0 +1,50 @@ +// +// 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 ReadiumShared +import SwiftUI + +struct OPDSFacetView: View { + let facets: [Facet] + + /// This closure is called when a facet link is tapped. + /// The parent view (OPDSFeedView) will handle the navigation. + let onLinkTapped: (ReadiumShared.Link) -> Void + + /// The dismiss action provided by the environment. + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + ForEach(facets, id: \.metadata.title) { facet in + Section(header: Text(facet.metadata.title)) { + ForEach(facet.links, id: \.href) { link in + Button { + // When tapped, dismiss this sheet + // and tell the parent to navigate. + dismiss() + onLinkTapped(link) + } label: { + OPDSNavigationRow(link: link) + .foregroundColor(.primary) + } + } + } + } + } + .listStyle(.grouped) + .navigationTitle(NSLocalizedString("filter_button", comment: "Filter the OPDS feed")) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(NSLocalizedString("ok_button", comment: "Alert button")) { + dismiss() + } + } + } + } + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift new file mode 100644 index 000000000..a903c7b44 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift @@ -0,0 +1,286 @@ +// +// 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 ReadiumShared +import SwiftUI + +struct OPDSFeedView: View { + @StateObject private var viewModel: OPDSFeedViewModel + + private var delegate: OPDSModuleDelegate? + + @State private var facetNavigationURL: URL? + + struct NavigablePublication: Identifiable, Hashable { + let id: String + let publication: ReadiumShared.Publication + + init(publication: ReadiumShared.Publication) { + self.publication = publication + + id = publication.links.first(where: { $0.rels.contains(.self) })?.href + ?? publication.metadata.identifier + ?? publication.metadata.title + ?? UUID().uuidString + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: NavigablePublication, rhs: NavigablePublication) -> Bool { + lhs.id == rhs.id + } + } + + init(feedURL: URL, delegate: OPDSModuleDelegate?) { + _viewModel = StateObject(wrappedValue: OPDSFeedViewModel(feedURL: feedURL, delegate: delegate)) + self.delegate = delegate + } + + var body: some View { + mainContent + .navigationTitle(viewModel.feed?.metadata.title ?? "Loading...") + .navigationBarTitleDisplayMode(.inline) // Keeps title small + .onAppear { + if viewModel.feed == nil { + viewModel.parseFeed() + } + } + .toolbar { + buildToolbar() + } + .sheet(isPresented: $viewModel.isShowingFacets) { + buildFacetView() + } + .navigationDestination( + isPresented: Binding( + get: { facetNavigationURL != nil }, + set: { if !$0 { facetNavigationURL = nil } } + ) + ) { + facetDestinationView() + } + } + + @ViewBuilder + private var mainContent: some View { + Group { + // If the feed is only publications, show a grid. + if viewModel.isPublicationOnly { + buildPublicationOnlyView(viewModel.publications) + } else { + // Otherwise, show a list view. + buildListView() + } + } + } + + @ViewBuilder + private func facetDestinationView() -> some View { + if let url = facetNavigationURL { + OPDSFeedView(feedURL: url, delegate: delegate) + } else { + EmptyView() + } + } + + // MARK: - Toolbar & Sheet Builders + + @ToolbarContentBuilder + private func buildToolbar() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + if !(viewModel.feed?.facets.isEmpty ?? true) { + Button { + viewModel.isShowingFacets = true + } label: { + Text(NSLocalizedString("filter_button", comment: "Filter the OPDS feed")) + } + } + } + } + + @ViewBuilder + private func buildFacetView() -> some View { + OPDSFacetView(facets: viewModel.feed?.facets ?? []) { link in + if let url = URL(string: link.href) { + facetNavigationURL = url + } + } + } + + // MARK: - List View Builders + + @ViewBuilder + private func buildListView() -> some View { + ScrollView { + LazyVStack(spacing: 0) { + if viewModel.feed != nil { + if !viewModel.navigation.isEmpty { + buildNavigationSection(viewModel.navigation) + } + + if !viewModel.groups.isEmpty { + buildGroupsSection(viewModel.groups) + } + + if let group = viewModel.rootPublicationsGroup { + buildGroupsSection([group]) + } + + if !viewModel.hasContent { + buildNoneView() + .padding() + } + + } else if viewModel.error != nil { + Text("Failed to load feed. Please try again.") + .padding() + } else { + ProgressView() + .padding() + } + } + } + } + + @ViewBuilder + private func buildNoneView() -> some View { + if let error = viewModel.error { + Text("Failed to load feed: \(error.localizedDescription)") + } else { + Text("No content in this feed.") + } + } + + // MARK: - Publication Grid Builder + + @ViewBuilder + private func buildPublicationOnlyView(_ publications: [ReadiumShared.Publication]) -> some View { + let columns = [ + GridItem(.adaptive(minimum: 140), spacing: 16), + ] + let navPublications = publications.map(NavigablePublication.init).unique() + + ScrollView { + LazyVGrid(columns: columns, spacing: 20) { + ForEach(navPublications) { navPublication in + NavigationLink(value: navPublication) { + OPDSPublicationItemView(publication: navPublication.publication) + } + .buttonStyle(.plain) + .onAppear { + if navPublication == navPublications.last { + viewModel.loadNextPage() + } + } + } + } + .padding() + + if viewModel.isLoadingNextPage { + ProgressView() + .padding() + } + } + } + + // MARK: - Section Builders + + @ViewBuilder + private func buildNavigationSection(_ navigation: [ReadiumShared.Link]) -> some View { + HStack { + Text(NSLocalizedString("opds_browse_title", comment: "Title of the section displaying the feeds")) + .font(.title3.bold()) + .textCase(nil) + Spacer() + } + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 8) + + Divider() + .padding(.horizontal) + + buildNavigationList(navigation, isRootList: true) + } + + @ViewBuilder + private func buildGroupsSection(_ groups: [ReadiumShared.Group]) -> some View { + ForEach(groups, id: \.metadata.title) { group in + HStack { + Text(group.metadata.title) + .font(.title3.bold()) + .textCase(nil) + + Spacer() + + if let moreLink = group.links.first { + Button { + if let url = URL(string: moreLink.href) { + facetNavigationURL = url + } + } label: { + Text(NSLocalizedString("opds_more_button", comment: "Button to expand a feed gallery")) + .font(.title3.bold()) + .foregroundColor(.secondary) + } + } + } + .padding(.horizontal) + .padding(.top, 32) + .padding(.bottom, 8) + + if !group.publications.isEmpty { + let navPublications = group.publications.map(NavigablePublication.init).unique() + + OPDSGroupRow( + group: group, + publications: navPublications, + isLoading: viewModel.isLoadingNextPage, + onLastItemAppeared: { + viewModel.loadNextPage() + } + ) + } else if !group.navigation.isEmpty { + Divider() + .padding(.horizontal) + + buildNavigationList(group.navigation, isRootList: false) + } + } + } + + @ViewBuilder + private func buildNavigationList(_ navigation: [ReadiumShared.Link], isRootList: Bool) -> some View { + ForEach(navigation.indices, id: \.self) { index in + let link = navigation[index] + + if let url = URL(string: link.href) { + NavigationLink(value: url) { + OPDSNavigationRow(link: link) + .padding(.horizontal) + } + .buttonStyle(.plain) + if isRootList { + Divider() + .padding(.horizontal) + } else { + Divider() + .padding(.leading) + } + } + } + } +} + +extension Sequence where Element: Hashable { + /// Returns an array containing only the unique elements of the sequence. + func unique() -> [Element] { + var seen = Set() + return filter { seen.insert($0).inserted } + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift new file mode 100644 index 000000000..725862b87 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift @@ -0,0 +1,149 @@ +// +// 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 Foundation +import ReadiumOPDS +import ReadiumShared + +@MainActor +class OPDSFeedViewModel: ObservableObject { + let feedURL: URL + + @Published var feed: Feed? + @Published var error: Error? + @Published var isShowingFacets = false + + /// Tracks if a pagination request is in progress. + @Published var isLoadingNextPage = false + + weak var delegate: OPDSModuleDelegate? + + /// Stores the URL for the next page of results. + private var nextPageURL: URL? + + init(feedURL: URL, delegate: OPDSModuleDelegate?) { + self.feedURL = feedURL + self.delegate = delegate + } + + /// Fetches and parses the initial OPDS feed. + func parseFeed() { + feed = nil + error = nil + nextPageURL = nil // Reset next page URL + + OPDSParser.parseURL(url: feedURL) { [weak self] data, error in + DispatchQueue.main.async { + guard let self = self else { return } + + if let data = data, let feed = data.feed { + self.feed = feed + // Find and store the next page URL + self.nextPageURL = self.findNextPageURL(feed: feed) + } else if let error = error { + self.error = error + print("Failed to parse feed: \(error)") + } else { + self.error = OPDSError.invalidURL(self.feedURL.absoluteString) + } + } + } + } + + /// Fetches and parses the next page of the feed. + func loadNextPage() { + // Don't load if already loading or if there's no next page + guard !isLoadingNextPage, let url = nextPageURL else { + return + } + + isLoadingNextPage = true + + OPDSParser.parseURL(url: url) { [weak self] data, error in + DispatchQueue.main.async { + guard let self = self else { return } + + if let data = data, let newFeed = data.feed { + // Append new publications to the existing feed + self.feed?.publications.append(contentsOf: newFeed.publications) + // Find the *next* next page URL + self.nextPageURL = self.findNextPageURL(feed: newFeed) + } else if let error = error { + print("Failed to load next page: \(error)") + } + + self.isLoadingNextPage = false + } + } + } + + /// Finds the "next" link in the feed's links. + private func findNextPageURL(feed: Feed) -> URL? { + guard let href = feed.links.firstWithRel(.next)?.href else { + return nil + } + return URL(string: href) + } + + // MARK: - View-Ready Computed Properties + + /// Provides the navigation links, or an empty array. + var navigation: [ReadiumShared.Link] { + feed?.navigation ?? [] + } + + /// Provides the feed groups, or an empty array. + var groups: [ReadiumShared.Group] { + feed?.groups ?? [] + } + + /// Provides the publications, or an empty array. + var publications: [ReadiumShared.Publication] { + feed?.publications ?? [] + } + + /// True if the feed contains only publications and no navigation or groups. + /// The View uses this to decide whether to show a grid or a list. + var isPublicationOnly: Bool { + guard let feed = feed else { return false } + return !feed.publications.isEmpty + && feed.navigation.isEmpty + && feed.groups.isEmpty + } + + /// True if the feed contains any content at all. + var hasContent: Bool { + guard let feed = feed else { return false } + return !feed.navigation.isEmpty + || !feed.groups.isEmpty + || !feed.publications.isEmpty + } + + /// Creates a group for publications at the feed's root. + /// This allows the View to render them as just another group in the list. + var rootPublicationsGroup: ReadiumShared.Group? { + guard let feed = feed, !feed.publications.isEmpty else { + return nil + } + + if isPublicationOnly { + return nil + } + + let title: String + if feed.groups.isEmpty { + title = NSLocalizedString("opds_browse_title", comment: "Title of the section displaying the feeds") + } else { + title = feed.metadata.title + } + + // Create the group and assign publications + let pubGroup = ReadiumShared.Group(title: title) + pubGroup.publications = feed.publications + return pubGroup + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift new file mode 100644 index 000000000..fa8daaff5 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift @@ -0,0 +1,47 @@ +// +// 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 ReadiumShared +import SwiftUI + +struct OPDSGroupRow: View { + let group: ReadiumShared.Group + + typealias NavigablePublication = OPDSFeedView.NavigablePublication + let publications: [NavigablePublication] + + let isLoading: Bool + let onLastItemAppeared: () -> Void + + private let rowHeight: CGFloat = 230 + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 16) { + ForEach(publications) { navPublication in + NavigationLink(value: navPublication) { + OPDSPublicationItemView(publication: navPublication.publication) + } + .buttonStyle(.plain) + .onAppear { + if navPublication == publications.last { + onLastItemAppeared() + } + } + } + + if isLoading { + ZStack { + ProgressView() + } + .frame(width: 140, height: rowHeight) + } + } + .padding(.horizontal) + } + .frame(height: rowHeight) + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift new file mode 100644 index 000000000..3a22b81fe --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift @@ -0,0 +1,40 @@ +// +// 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 ReadiumShared +import SwiftUI + +/// A view for a single navigation link in an OPDS feed. +struct OPDSNavigationRow: View { + let link: ReadiumShared.Link + + var body: some View { + rowContent + } + + @ViewBuilder + private var rowContent: some View { + HStack { + Text(link.title ?? "Untitled") + .font(.body) + .padding(.vertical, 12) + .lineLimit(1) + + Spacer() + + if let count = link.properties.numberOfItems { + Text("\(count)") + .font(.body) + .foregroundColor(.secondary) + } + + Image(systemName: "chevron.right") + .font(.body.weight(.bold)) + .foregroundColor(Color(uiColor: .tertiaryLabel)) + } + .contentShape(Rectangle()) + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift new file mode 100644 index 000000000..7dac50ce9 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift @@ -0,0 +1,19 @@ +// +// 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 ReadiumShared +import SwiftUI + +/// A SwiftUI wrapper for the UIKit OPDSPublicationInfoViewController. +struct OPDSPublicationInfoView: UIViewControllerRepresentable { + let publication: Publication + + func makeUIViewController(context: Context) -> OPDSPublicationInfoViewController { + OPDSFactory.shared.make(publication: publication) + } + + func updateUIViewController(_ uiViewController: OPDSPublicationInfoViewController, context: Context) {} +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationItemView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationItemView.swift new file mode 100644 index 000000000..c99395357 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationItemView.swift @@ -0,0 +1,57 @@ +// +// 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 ReadiumShared +import SwiftUI + +struct OPDSPublicationItemView: View { + let publication: Publication + + private let coverHeight: CGFloat = 200 + private let coverWidth: CGFloat = 140 + + 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 + } + + var body: some View { + VStack(alignment: .leading) { + AsyncImage(url: imageURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Color.gray.opacity(0.3) + .overlay(Image(systemName: "book.closed")) + } + .frame(width: coverWidth, height: coverHeight) + .clipped() + + Text(publication.metadata.title ?? "") + .font(.caption) + .lineLimit(2) + + Text(publication.metadata.authors.map(\.name).joined(separator: ", ")) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + .frame(width: coverWidth) + } +} + +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/OPDSGroupCollectionViewCell.swift b/TestApp/Sources/OPDS/OPDSGroupCollectionViewCell.swift deleted file mode 100644 index c5e9e189a..000000000 --- a/TestApp/Sources/OPDS/OPDSGroupCollectionViewCell.swift +++ /dev/null @@ -1,12 +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 UIKit - -class OPDSGroupCollectionViewCell: UICollectionViewCell { - @IBOutlet var navigationTitleLabel: UILabel! - @IBOutlet var navigationCountLabel: UILabel! -} diff --git a/TestApp/Sources/OPDS/OPDSGroupTableViewCell.swift b/TestApp/Sources/OPDS/OPDSGroupTableViewCell.swift deleted file mode 100644 index 99cc77d2e..000000000 --- a/TestApp/Sources/OPDS/OPDSGroupTableViewCell.swift +++ /dev/null @@ -1,176 +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 Kingfisher -import ReadiumShared -import UIKit - -class OPDSGroupTableViewCell: UITableViewCell { - var group: Group? - weak var opdsRootTableViewController: OPDSRootTableViewController? - weak var collectionView: UICollectionView? - - var browsingState: FeedBrowsingState = .None - - static let iPadLayoutNumberPerRow: [ScreenOrientation: Int] = [.portrait: 4, .landscape: 5] - static let iPhoneLayoutNumberPerRow: [ScreenOrientation: Int] = [.portrait: 3, .landscape: 4] - - lazy var layoutNumberPerRow: [UIUserInterfaceIdiom: [ScreenOrientation: Int]] = [ - .pad: OPDSGroupTableViewCell.iPadLayoutNumberPerRow, - .phone: OPDSGroupTableViewCell.iPhoneLayoutNumberPerRow, - ] - - override func awakeFromNib() { - super.awakeFromNib() - // Initialization code - } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - // Configure the view for the selected state - } - - override func prepareForReuse() { - super.prepareForReuse() - collectionView?.setContentOffset(.zero, animated: false) - collectionView?.reloadData() - } - - override func layoutSubviews() { - super.layoutSubviews() - collectionView?.collectionViewLayout.invalidateLayout() - } -} - -extension OPDSGroupTableViewCell: UICollectionViewDataSource { - // MARK: - Collection view data source - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - var count = 0 - - if let group = group { - if group.publications.count > 0 { - count = group.publications.count - browsingState = .Publication - } else if group.navigation.count > 0 { - count = group.navigation.count - browsingState = .Navigation - } - } - - return count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - self.collectionView = collectionView - - if browsingState == .Publication { - collectionView.register(UINib(nibName: "PublicationCollectionViewCell", bundle: nil), - forCellWithReuseIdentifier: "publicationCollectionViewCell") - - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "publicationCollectionViewCell", - for: indexPath) as! PublicationCollectionViewCell - - cell.isAccessibilityElement = true - cell.accessibilityHint = NSLocalizedString("opds_show_detail_view_a11y_hint", comment: "Accessibility hint for OPDS publication cell") - - if let publication = group?.publications[indexPath.row] { - cell.accessibilityLabel = publication.metadata.title - - let titleTextView = OPDSPlaceholderListView( - frame: cell.frame, - title: publication.metadata.title, - author: publication.metadata.authors - .map(\.name) - .joined(separator: ", ") - ) - - let coverURL: URL? = publication.linkWithRel(.cover)?.url(relativeTo: publication.baseURL).url - ?? publication.images.first.flatMap { URL(string: $0.href) } - - if let coverURL = coverURL { - cell.coverImageView.kf.setImage( - with: coverURL, - placeholder: titleTextView, - options: [.transition(ImageTransition.fade(0.5))], - progressBlock: nil - ) { _ in } - } - - cell.titleLabel.text = publication.metadata.title - cell.authorLabel.text = publication.metadata.authors - .map(\.name) - .joined(separator: ", ") - } - - return cell - - } else { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "opdsNavigationCollectionViewCell", - for: indexPath) as! OPDSGroupCollectionViewCell - - if let navigation = group?.navigation[indexPath.row] { - cell.accessibilityLabel = navigation.title - - cell.navigationTitleLabel.text = navigation.title - if let count = navigation.properties.numberOfItems { - cell.navigationCountLabel.text = "\(count)" - } else { - cell.navigationCountLabel.text = "" - } - } - - return cell - } - } -} - -extension OPDSGroupTableViewCell: UICollectionViewDelegateFlowLayout { - // MARK: - Collection view delegate - - func collectionView(_ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath) -> CGSize - { - if browsingState == .Publication { - let idiom = { () -> UIUserInterfaceIdiom in - let tempIdion = UIDevice.current.userInterfaceIdiom - return (tempIdion != .pad) ? .phone : .pad // ignnore carplay and others - }() - - guard let deviceLayoutNumberPerRow = layoutNumberPerRow[idiom] else { return CGSize(width: 0, height: 0) } - guard let numberPerRow = deviceLayoutNumberPerRow[.current] else { return CGSize(width: 0, height: 0) } - - let minimumSpacing: CGFloat = 5.0 - let labelHeight: CGFloat = 50.0 - let coverRatio: CGFloat = 1.5 - - let itemWidth = (collectionView.frame.width / CGFloat(numberPerRow)) - (CGFloat(minimumSpacing) * CGFloat(numberPerRow)) - minimumSpacing - let itemHeight = (itemWidth * coverRatio) + labelHeight - - return CGSize(width: itemWidth, height: itemHeight) - - } else { - return CGSize(width: 200, height: 50) - } - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - if browsingState == .Publication { - if let publication = group?.publications[indexPath.row] { - let opdsPublicationInfoViewController: OPDSPublicationInfoViewController = OPDSFactory.shared.make(publication: publication) - opdsRootTableViewController?.navigationController?.pushViewController(opdsPublicationInfoViewController, animated: true) - } - - } else { - if let href = group?.navigation[indexPath.row].href, let url = URL(string: href) { - let newOPDSRootTableViewController: OPDSRootTableViewController = OPDSFactory.shared.make(feedURL: url, indexPath: nil) - opdsRootTableViewController?.navigationController?.pushViewController(newOPDSRootTableViewController, animated: true) - } - } - } -} diff --git a/TestApp/Sources/OPDS/OPDSModule.swift b/TestApp/Sources/OPDS/OPDSModule.swift index d1d61049d..74b248d0f 100644 --- a/TestApp/Sources/OPDS/OPDSModule.swift +++ b/TestApp/Sources/OPDS/OPDSModule.swift @@ -46,21 +46,29 @@ final class OPDSModule: OPDSModuleAPI { private(set) lazy var rootViewController: UINavigationController = { let viewModel = OPDSCatalogsViewModel() - let catalogViewController = UIHostingController( - rootView: OPDSCatalogsView(viewModel: viewModel) - ) + let rootView = NavigationStack { + OPDSCatalogsView(viewModel: viewModel, delegate: self.delegate) + .navigationDestination(for: OPDSCatalog.self) { catalog in + OPDSFeedView( + feedURL: catalog.url, + delegate: self.delegate + ) + } + .navigationDestination(for: URL.self) { url in + OPDSFeedView(feedURL: url, delegate: self.delegate) + } + .navigationDestination(for: OPDSFeedView.NavigablePublication.self) { navPublication in + OPDSPublicationInfoView(publication: navPublication.publication) + } + } - let navigationController = UINavigationController( - rootViewController: catalogViewController - ) + let catalogViewController = UIHostingController(rootView: rootView) - viewModel.openCatalog = { [weak navigationController] url, indexPath in - let viewController = OPDSFactory.shared.make( - feedURL: url, - indexPath: indexPath - ) - navigationController?.pushViewController(viewController, animated: true) - } + let navigationController = UINavigationController(rootViewController: catalogViewController) + + navigationController.isNavigationBarHidden = true + + viewModel.openCatalog = nil return navigationController }() diff --git a/TestApp/Sources/OPDS/OPDSNavigationTableViewCell.swift b/TestApp/Sources/OPDS/OPDSNavigationTableViewCell.swift deleted file mode 100644 index c5aa72ee4..000000000 --- a/TestApp/Sources/OPDS/OPDSNavigationTableViewCell.swift +++ /dev/null @@ -1,23 +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 UIKit - -class OPDSNavigationTableViewCell: UITableViewCell { - @IBOutlet var title: UILabel! - @IBOutlet var count: UILabel! - - override func awakeFromNib() { - super.awakeFromNib() - // Initialization code - } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - // Configure the view for the selected state - } -} diff --git a/TestApp/Sources/OPDS/OPDSPublicationTableViewCell.swift b/TestApp/Sources/OPDS/OPDSPublicationTableViewCell.swift deleted file mode 100644 index 18452a7bb..000000000 --- a/TestApp/Sources/OPDS/OPDSPublicationTableViewCell.swift +++ /dev/null @@ -1,129 +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 Kingfisher -import ReadiumShared -import UIKit - -class OPDSPublicationTableViewCell: UITableViewCell { - @IBOutlet var collectionView: UICollectionView! - - var feed: Feed? - weak var opdsRootTableViewController: OPDSRootTableViewController? - - static let iPadLayoutNumberPerRow: [ScreenOrientation: Int] = [.portrait: 4, .landscape: 5] - static let iPhoneLayoutNumberPerRow: [ScreenOrientation: Int] = [.portrait: 3, .landscape: 4] - - lazy var layoutNumberPerRow: [UIUserInterfaceIdiom: [ScreenOrientation: Int]] = [ - .pad: OPDSPublicationTableViewCell.iPadLayoutNumberPerRow, - .phone: OPDSPublicationTableViewCell.iPhoneLayoutNumberPerRow, - ] - - override func awakeFromNib() { - super.awakeFromNib() - collectionView.register(UINib(nibName: "PublicationCollectionViewCell", bundle: nil), forCellWithReuseIdentifier: "publicationCollectionViewCell") - } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - // Configure the view for the selected state - } - - override func layoutSubviews() { - super.layoutSubviews() - collectionView.collectionViewLayout.invalidateLayout() - } -} - -extension OPDSPublicationTableViewCell: UICollectionViewDataSource { - // MARK: - Collection view data source - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - feed?.publications.count ?? 0 - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "publicationCollectionViewCell", - for: indexPath) as! PublicationCollectionViewCell - - cell.isAccessibilityElement = true - cell.accessibilityHint = NSLocalizedString("opds_show_detail_view_a11y_hint", comment: "Accessibility hint for OPDS publication cell") - - if let publications = feed?.publications, let publication = feed?.publications[indexPath.row] { - cell.accessibilityLabel = publication.metadata.title - - let titleTextView = OPDSPlaceholderListView( - frame: cell.frame, - title: publication.metadata.title, - author: publication.metadata.authors - .map(\.name) - .joined(separator: ", ") - ) - - let coverURL: URL? = publication.linkWithRel(.cover)?.url(relativeTo: publication.baseURL).url - ?? publication.images.first.flatMap { URL(string: $0.href) } - - if let coverURL = coverURL { - cell.coverImageView.kf.setImage( - with: coverURL, - placeholder: titleTextView, - options: [.transition(ImageTransition.fade(0.5))], - progressBlock: nil - ) { _ in } - } else { - cell.coverImageView.addSubview(titleTextView) - } - - cell.titleLabel.text = publication.metadata.title - cell.authorLabel.text = publication.metadata.authors - .map(\.name) - .joined(separator: ", ") - - if indexPath.row == publications.count - 3 { - opdsRootTableViewController?.loadNextPage(completionHandler: { feed in - self.feed = feed - collectionView.reloadData() - }) - } - } - - return cell - } -} - -extension OPDSPublicationTableViewCell: UICollectionViewDelegateFlowLayout { - // MARK: - Collection view delegate - - func collectionView(_ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath) -> CGSize - { - let idiom = { () -> UIUserInterfaceIdiom in - let tempIdion = UIDevice.current.userInterfaceIdiom - return (tempIdion != .pad) ? .phone : .pad // ignnore carplay and others - }() - - guard let deviceLayoutNumberPerRow = layoutNumberPerRow[idiom] else { return CGSize(width: 0, height: 0) } - guard let numberPerRow = deviceLayoutNumberPerRow[.current] else { return CGSize(width: 0, height: 0) } - - let minimumSpacing: CGFloat = 5.0 - let labelHeight: CGFloat = 50.0 - let coverRatio: CGFloat = 1.5 - - let itemWidth = (collectionView.frame.width / CGFloat(numberPerRow)) - (CGFloat(minimumSpacing) * CGFloat(numberPerRow)) - minimumSpacing - let itemHeight = (itemWidth * coverRatio) + labelHeight - - return CGSize(width: itemWidth, height: itemHeight) - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - if let publication = feed?.publications[indexPath.row] { - let opdsPublicationInfoViewController: OPDSPublicationInfoViewController = OPDSFactory.shared.make(publication: publication) - opdsRootTableViewController?.navigationController?.pushViewController(opdsPublicationInfoViewController, animated: true) - } - } -} diff --git a/TestApp/Sources/OPDS/OPDSRootTableViewController.swift b/TestApp/Sources/OPDS/OPDSRootTableViewController.swift deleted file mode 100644 index a56bc3acf..000000000 --- a/TestApp/Sources/OPDS/OPDSRootTableViewController.swift +++ /dev/null @@ -1,603 +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 ReadiumOPDS -import ReadiumShared -import SwiftUI -import UIKit - -enum FeedBrowsingState { - case Navigation - case Publication - case MixedGroup - case MixedNavigationPublication - case MixedNavigationGroup - case MixedNavigationGroupPublication - case None -} - -protocol OPDSRootTableViewControllerFactory { - func make(feedURL: URL, indexPath: IndexPath?) -> OPDSRootTableViewController -} - -class OPDSRootTableViewController: UITableViewController { - typealias Factory = - OPDSRootTableViewControllerFactory - - var factory: Factory! - var originalFeedURL: URL? - - var nextPageURL: URL? - var originalFeedIndexPath: IndexPath? - var mustEditFeed = false - - var parseData: ParseData? - var feed: Feed? - var publication: Publication? - - var browsingState: FeedBrowsingState = .None - - static let iPadLayoutHeightForRow: [ScreenOrientation: CGFloat] = [.portrait: 330, .landscape: 340] - static let iPhoneLayoutHeightForRow: [ScreenOrientation: CGFloat] = [.portrait: 230, .landscape: 280] - - lazy var layoutHeightForRow: [UIUserInterfaceIdiom: [ScreenOrientation: CGFloat]] = [ - .pad: OPDSRootTableViewController.iPadLayoutHeightForRow, - .phone: OPDSRootTableViewController.iPhoneLayoutHeightForRow, - ] - - override func viewDidLoad() { - super.viewDidLoad() - navigationController?.delegate = self - - parseFeed() - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - tableView.reloadData() - } - - // MARK: - OPDS feed parsing - - func parseFeed() { - if let url = originalFeedURL { - OPDSParser.parseURL(url: url) { data, _ in - DispatchQueue.main.async { - if let data = data { - self.parseData = data - } - self.finishFeedInitialization() - } - } - } - } - - func finishFeedInitialization() { - if let feed = parseData?.feed { - self.feed = feed - - navigationItem.title = feed.metadata.title - nextPageURL = findNextPageURL(feed: feed) - - if feed.facets.count > 0 { - let filterButton = UIBarButtonItem( - title: NSLocalizedString("filter_button", comment: "Filter the OPDS feed"), - style: UIBarButtonItem.Style.plain, - target: self, - action: #selector(OPDSRootTableViewController.filterMenuClicked) - ) - navigationItem.rightBarButtonItem = filterButton - } - - // Check feed compozition. Then, browsingState will be used to build the UI. - if feed.navigation.count > 0, feed.groups.count == 0, feed.publications.count == 0 { - browsingState = .Navigation - } else if feed.publications.count > 0, feed.groups.count == 0, feed.navigation.count == 0 { - browsingState = .Publication - tableView.separatorStyle = .none - tableView.isScrollEnabled = false - } else if feed.groups.count > 0, feed.publications.count == 0, feed.navigation.count == 0 { - browsingState = .MixedGroup - } else if feed.navigation.count > 0, feed.groups.count == 0, feed.publications.count > 0 { - browsingState = .MixedNavigationPublication - } else if feed.navigation.count > 0, feed.groups.count > 0, feed.publications.count == 0 { - browsingState = .MixedNavigationGroup - } else if feed.navigation.count > 0, feed.groups.count > 0, feed.publications.count > 0 { - browsingState = .MixedNavigationGroupPublication - } else { - browsingState = .None - } - - } else { - tableView.backgroundView = UIView(frame: UIScreen.main.bounds) - tableView.separatorStyle = .none - - let frame = CGRect(x: 0, y: tableView.backgroundView!.bounds.height / 2, width: tableView.backgroundView!.bounds.width, height: 20) - - let messageLabel = UILabel(frame: frame) - messageLabel.textColor = UIColor.darkGray - messageLabel.textAlignment = .center - messageLabel.text = NSLocalizedString("opds_failure_message", comment: "Error message when the feed couldn't be loaded") - - let editButton = UIButton(type: .system) - editButton.frame = frame - editButton.setTitle(NSLocalizedString("opds_edit_button", comment: "Button to edit the OPDS catalog"), for: .normal) - editButton.addTarget(self, action: #selector(editButtonClicked), for: .touchUpInside) - editButton.isHidden = originalFeedIndexPath == nil ? true : false - - let stackView = UIStackView(arrangedSubviews: [messageLabel, editButton]) - stackView.axis = .vertical - stackView.distribution = .equalSpacing - let spacing: CGFloat = 15 - stackView.spacing = spacing - - tableView.backgroundView?.addSubview(stackView) - - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.widthAnchor.constraint(equalTo: tableView.backgroundView!.widthAnchor).isActive = true - stackView.heightAnchor.constraint(equalToConstant: messageLabel.frame.height + editButton.frame.height + spacing).isActive = true - stackView.centerYAnchor.constraint(equalTo: tableView.backgroundView!.centerYAnchor).isActive = true - } - - DispatchQueue.main.async { - self.tableView.reloadData() - } - } - - @objc func editButtonClicked(_ sender: UIBarButtonItem) { - mustEditFeed = true - navigationController?.popViewController(animated: true) - } - - func findNextPageURL(feed: Feed) -> URL? { - guard let href = feed.links.firstWithRel(.next)?.href else { - return nil - } - return URL(string: href) - } - - public func loadNextPage(completionHandler: @escaping (Feed?) -> Void) { - if let nextPageURL = nextPageURL { - OPDSParser.parseURL(url: nextPageURL) { data, _ in - DispatchQueue.main.async { - guard let newFeed = data?.feed else { - return - } - - self.nextPageURL = self.findNextPageURL(feed: newFeed) - self.feed?.publications.append(contentsOf: newFeed.publications) - completionHandler(self.feed) - } - } - } - } - - // MARK: - Facets - - @objc func filterMenuClicked(_ sender: UIBarButtonItem) { - guard let feed = feed else { - return - } - - let facetViewController = UIHostingController(rootView: OPDSFacetList( - feed: feed, - onLinkSelected: { [weak self] link in - self?.pushOpdsRootViewController(href: link.href) - } - )) - - facetViewController.modalPresentationStyle = UIModalPresentationStyle.popover - - present(facetViewController, animated: true, completion: nil) - - if let popoverPresentationController = facetViewController.popoverPresentationController { - popoverPresentationController.barButtonItem = sender - } - } - - // MARK: - Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - var numberOfSections = 0 - - switch browsingState { - case .Navigation, .Publication: - numberOfSections = 1 - - case .MixedGroup: - numberOfSections = feed!.groups.count - - case .MixedNavigationPublication: - numberOfSections = 2 - - case .MixedNavigationGroup: - // 1 section for the nav + groups count for the next sections - numberOfSections = 1 + feed!.groups.count - - case .MixedNavigationGroupPublication: - numberOfSections = 1 + feed!.groups.count + 1 - - default: - numberOfSections = 0 - } - - return numberOfSections - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - var numberOfRowsInSection = 0 - - switch browsingState { - case .Navigation: - numberOfRowsInSection = feed!.navigation.count - - case .Publication: - numberOfRowsInSection = 1 - - case .MixedGroup: - if feed!.groups[section].navigation.count > 0 { - numberOfRowsInSection = feed!.groups[section].navigation.count - } else { - numberOfRowsInSection = 1 - } - - case .MixedNavigationPublication: - if section == 0 { - numberOfRowsInSection = feed!.navigation.count - } - if section == 1 { - numberOfRowsInSection = 1 - } - - case .MixedNavigationGroup: - // Nav - if section == 0 { - numberOfRowsInSection = feed!.navigation.count - } - // Groups - if section >= 1, section <= feed!.groups.count { - if feed!.groups[section - 1].navigation.count > 0 { - // Nav inside a group - numberOfRowsInSection = feed!.groups[section - 1].navigation.count - } else { - // No nav inside a group - numberOfRowsInSection = 1 - } - } - - case .MixedNavigationGroupPublication: - if section == 0 { - numberOfRowsInSection = feed!.navigation.count - } - if section >= 1, section <= feed!.groups.count { - if feed!.groups[section - 1].navigation.count > 0 { - numberOfRowsInSection = feed!.groups[section - 1].navigation.count - } else { - numberOfRowsInSection = 1 - } - } - if section == (feed!.groups.count + 1) { - numberOfRowsInSection = 1 - } - - default: - numberOfRowsInSection = 0 - } - - return numberOfRowsInSection - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - var heightForRowAt: CGFloat = 0.0 - - switch browsingState { - case .Publication: - heightForRowAt = tableView.bounds.height - - case .MixedGroup: - if feed!.groups[indexPath.section].navigation.count > 0 { - heightForRowAt = 44 - } else { - heightForRowAt = calculateRowHeightForGroup(feed!.groups[indexPath.section]) - } - - case .MixedNavigationPublication: - if indexPath.section == 0 { - heightForRowAt = 44 - } else { - heightForRowAt = tableView.bounds.height / 2 - } - - case .MixedNavigationGroup: - // Nav - if indexPath.section == 0 { - heightForRowAt = 44 - // Group - } else { - // Nav inside a group - if feed!.groups[indexPath.section - 1].navigation.count > 0 { - heightForRowAt = 44 - } else { - // No nav inside a group - heightForRowAt = calculateRowHeightForGroup(feed!.groups[indexPath.section - 1]) - } - } - - case .MixedNavigationGroupPublication: - if indexPath.section == 0 { - heightForRowAt = 44 - } else if indexPath.section >= 1, indexPath.section <= feed!.groups.count { - if feed!.groups[indexPath.section - 1].navigation.count > 0 { - heightForRowAt = 44 - } else { - heightForRowAt = calculateRowHeightForGroup(feed!.groups[indexPath.section - 1]) - } - } else { - let group = ReadiumShared.Group(title: feed!.metadata.title) - group.publications = feed!.publications - heightForRowAt = calculateRowHeightForGroup(group) - } - - default: - heightForRowAt = 44 - } - - return heightForRowAt - } - - fileprivate func calculateRowHeightForGroup(_ group: ReadiumShared.Group) -> CGFloat { - if group.navigation.count > 0 { - return tableView.bounds.height / 2 - - } else { - let idiom = { () -> UIUserInterfaceIdiom in - let tempIdion = UIDevice.current.userInterfaceIdiom - return (tempIdion != .pad) ? .phone : .pad // ignnore carplay and others - }() - - guard let deviceLayoutHeightForRow = layoutHeightForRow[idiom] else { return 44 } - guard let heightForRow = deviceLayoutHeightForRow[.current] else { return 44 } - - return heightForRow - } - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - var title: String? - - switch browsingState { - case .MixedGroup: - if section >= 0, section <= feed!.groups.count { - title = feed!.groups[section].metadata.title - } - - case .MixedNavigationGroup: - // Nav - if section == 0 { - title = NSLocalizedString("opds_browse_title", comment: "Title of the section displaying the feeds") - } - // Groups - if section >= 1, section <= feed!.groups.count { - title = feed!.groups[section - 1].metadata.title - } - - case .MixedNavigationGroupPublication: - if section == 0 { - title = NSLocalizedString("opds_browse_title", comment: "Title of the section displaying the feeds") - } - if section >= 1, section <= feed!.groups.count { - title = feed!.groups[section - 1].metadata.title - } - if section > feed!.groups.count { - title = feed!.metadata.title - } - - default: - title = nil - } - - return title - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - var cell: UITableViewCell? - - switch browsingState { - case .Navigation: - cell = buildNavigationCell(tableView: tableView, indexPath: indexPath) - - case .Publication: - cell = buildPublicationCell(tableView: tableView, indexPath: indexPath) - - case .MixedGroup: - cell = buildGroupCell(tableView: tableView, indexPath: indexPath) - - case .MixedNavigationPublication: - if indexPath.section == 0 { - cell = buildNavigationCell(tableView: tableView, indexPath: indexPath) - } else { - cell = buildPublicationCell(tableView: tableView, indexPath: indexPath) - } - - case .MixedNavigationGroup, .MixedNavigationGroupPublication: - if indexPath.section == 0 { - // Nav - cell = buildNavigationCell(tableView: tableView, indexPath: indexPath) - } else { - // Groups - cell = buildGroupCell(tableView: tableView, indexPath: indexPath) - } - - default: - cell = nil - } - - return cell! - } - - func buildNavigationCell(tableView: UITableView, indexPath: IndexPath) -> OPDSNavigationTableViewCell { - let castedCell = tableView.dequeueReusableCell(withIdentifier: "opdsNavigationCell", for: indexPath) as! OPDSNavigationTableViewCell - - var currentNavigation: [ReadiumShared.Link]? - - if let navigation = feed?.navigation, navigation.count > 0 { - currentNavigation = navigation - } else { - if let navigation = feed?.groups[indexPath.section].navigation, navigation.count > 0 { - currentNavigation = navigation - } - } - - if let currentNavigation = currentNavigation { - castedCell.title.text = currentNavigation[indexPath.row].title - if let count = currentNavigation[indexPath.row].properties.numberOfItems { - castedCell.count.text = "\(count)" - } else { - castedCell.count.text = "" - } - } - - return castedCell - } - - func buildPublicationCell(tableView: UITableView, indexPath: IndexPath) -> OPDSPublicationTableViewCell { - let castedCell = tableView.dequeueReusableCell(withIdentifier: "opdsPublicationCell", for: indexPath) as! OPDSPublicationTableViewCell - castedCell.feed = feed - castedCell.opdsRootTableViewController = self - return castedCell - } - - func buildGroupCell(tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { - if browsingState != .MixedGroup { - if indexPath.section > feed!.groups.count { - let group = ReadiumShared.Group(title: feed!.metadata.title) - group.publications = feed!.publications - return preparedGroupCell(group: group, indexPath: indexPath, offset: 0) - } else { - if feed!.groups[indexPath.section - 1].navigation.count > 0 { - return buildNavigationCell(tableView: tableView, indexPath: indexPath) - } else { - return preparedGroupCell(group: nil, indexPath: indexPath, offset: 1) - } - } - } else { - if feed!.groups[indexPath.section].navigation.count > 0 { - return buildNavigationCell(tableView: tableView, indexPath: indexPath) - } else { - return preparedGroupCell(group: nil, indexPath: indexPath, offset: 0) - } - } - } - - fileprivate func preparedGroupCell(group: ReadiumShared.Group?, indexPath: IndexPath, offset: Int) -> OPDSGroupTableViewCell { - let castedCell = tableView.dequeueReusableCell(withIdentifier: "opdsGroupCell", for: indexPath) as! OPDSGroupTableViewCell - castedCell.group = group != nil ? group : feed?.groups[indexPath.section - offset] - castedCell.opdsRootTableViewController = self - return castedCell - } - - // MARK: - Table view delegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - switch browsingState { - case .Navigation, .MixedNavigationPublication, .MixedNavigationGroup, .MixedNavigationGroupPublication: - var link: ReadiumShared.Link? - if indexPath.section == 0 { - link = feed!.navigation[indexPath.row] - } else if indexPath.section >= 1, indexPath.section <= feed!.groups.count, feed!.groups[indexPath.section - 1].navigation.count > 0 { - link = feed!.groups[indexPath.section - 1].navigation[indexPath.row] - } - - if let link = link { - pushOpdsRootViewController(href: link.href) - } - - default: - break - } - } - - override func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { - let header = view as! UITableViewHeaderFooterView - header.isAccessibilityElement = false - - header.textLabel?.font = UIFont.boldSystemFont(ofSize: 13) - header.textLabel?.accessibilityHint = NSLocalizedString("opds_feed_header_a11y_hint", comment: "Accessibility hint feed section header") - - var offset: Int - - if browsingState != .MixedGroup { - offset = section - 1 - } else { - offset = section - } - - if let feed = feed { - if let moreButton = view.subviews.last as? OPDSMoreButton { - if offset >= 0, offset < feed.groups.count { - moreButton.offset = offset - } else { - view.subviews.last?.removeFromSuperview() - } - return - } - - if offset >= 0, offset < feed.groups.count { - let links = feed.groups[offset].links - if links.count > 0 { - let buttonWidth: CGFloat = 70 - let moreButton = OPDSMoreButton(type: .system) - moreButton.frame = CGRect(x: header.frame.width - buttonWidth, y: 0, width: buttonWidth, height: header.frame.height) - - moreButton.setTitle(NSLocalizedString("opds_more_button", comment: "Button to expand a feed gallery"), for: .normal) - moreButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 11) - moreButton.setTitleColor(UIColor.darkGray, for: .normal) - - moreButton.offset = offset - moreButton.addTarget(self, action: #selector(moreAction), for: .touchUpInside) - - moreButton.isAccessibilityElement = true - moreButton.accessibilityLabel = NSLocalizedString("opds_more_button_a11y_label", comment: "Button to expand a feed gallery") - - view.addSubview(moreButton) - - moreButton.translatesAutoresizingMaskIntoConstraints = false - moreButton.widthAnchor.constraint(equalToConstant: buttonWidth).isActive = true - moreButton.heightAnchor.constraint(equalToConstant: header.frame.height).isActive = true - moreButton.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true - } - } - } - } - - // MARK: - Target action - - @objc func moreAction(sender: UIButton!) { - if let moreButton = sender as? OPDSMoreButton { - if let href = feed?.groups[moreButton.offset!].links[0].href { - pushOpdsRootViewController(href: href) - } - } - } -} - -// MARK: - UINavigationController delegate and tooling - -extension OPDSRootTableViewController: UINavigationControllerDelegate { - fileprivate func pushOpdsRootViewController(href: String) { - guard let url = URL(string: href) else { - return - } - - let viewController: OPDSRootTableViewController = factory.make(feedURL: url, indexPath: nil) - navigationController?.pushViewController(viewController, animated: true) - } -} - -// MARK: - Sublass of UIButton - -class OPDSMoreButton: UIButton { - var offset: Int? -}