From 0b6cb697bf41d772bfd2732c9654d160bb69172a Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Sat, 29 Jun 2024 14:59:32 -0500 Subject: [PATCH 01/16] Add SwiftUI based screens --- TestApp/Sources/About/Views/About.swift | 42 +++++++++ .../Bookshelf/Views/AddBookSheet.swift | 49 ++++++++++ .../Sources/Bookshelf/Views/Bookshelf.swift | 55 ++++++++++++ .../Sources/Catalogs/Views/AddFeedSheet.swift | 55 ++++++++++++ .../Sources/Catalogs/Views/CatalogFeed.swift | 90 +++++++++++++++++++ .../Sources/Catalogs/Views/CatalogGroup.swift | 62 +++++++++++++ .../Sources/Catalogs/Views/CatalogList.swift | 88 ++++++++++++++++++ .../Catalogs/Views/PublicationDetail.swift | 53 +++++++++++ .../Toolkit/Extensions/ReadiumShared.swift | 10 +++ TestApp/Sources/Container.swift | 54 +++++++++++ TestApp/Sources/Data/Catalog.swift | 58 ++++++++++++ TestApp/Sources/TestApp.swift | 32 +++++++ TestApp/Sources/Views/BookCover.swift | 69 ++++++++++++++ TestApp/Sources/Views/Button.swift | 32 +++++++ TestApp/Sources/Views/ListRowItem.swift | 24 +++++ 15 files changed, 773 insertions(+) create mode 100644 TestApp/Sources/About/Views/About.swift create mode 100644 TestApp/Sources/Bookshelf/Views/AddBookSheet.swift create mode 100644 TestApp/Sources/Bookshelf/Views/Bookshelf.swift create mode 100644 TestApp/Sources/Catalogs/Views/AddFeedSheet.swift create mode 100644 TestApp/Sources/Catalogs/Views/CatalogFeed.swift create mode 100644 TestApp/Sources/Catalogs/Views/CatalogGroup.swift create mode 100644 TestApp/Sources/Catalogs/Views/CatalogList.swift create mode 100644 TestApp/Sources/Catalogs/Views/PublicationDetail.swift create mode 100644 TestApp/Sources/Common/Toolkit/Extensions/ReadiumShared.swift create mode 100644 TestApp/Sources/Container.swift create mode 100644 TestApp/Sources/Data/Catalog.swift create mode 100644 TestApp/Sources/TestApp.swift create mode 100644 TestApp/Sources/Views/BookCover.swift create mode 100644 TestApp/Sources/Views/Button.swift create mode 100644 TestApp/Sources/Views/ListRowItem.swift diff --git a/TestApp/Sources/About/Views/About.swift b/TestApp/Sources/About/Views/About.swift new file mode 100644 index 000000000..228347b04 --- /dev/null +++ b/TestApp/Sources/About/Views/About.swift @@ -0,0 +1,42 @@ +// +// Copyright 2024 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 SwiftUI + +struct About: View { + var body: some View { + NavigationView { + VStack(alignment: .leading, spacing: 20) { + Text("Version") + .font(.title2) + HStack(spacing: 10) { + Text("Version").frame(width: 170.0, alignment: .leading) + Text("2.3.0") + } + HStack(spacing: 10) { + Text("Build").frame(width: 170.0, alignment: .leading) + Text("1") + } + Text("Copyright").font(.title2) + Link("© 2022 European Digital Reading Lab", + destination: URL(string: "https://www.edrlab.org/")!) + Link("[BSD-3 License]", + destination: URL(string: "https://opensource.org/licenses/BSD-3-Clause")!) + Text("Acknowledgements").font(.title2) + Text("R2 Reader wouldn't have been developed without the financial help of the French State.") + Image("rf") + } + .padding() + .navigationTitle("About") + } + } +} + +struct About_Previews: PreviewProvider { + static var previews: some View { + About() + } +} diff --git a/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift b/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift new file mode 100644 index 000000000..bb82bfc0c --- /dev/null +++ b/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift @@ -0,0 +1,49 @@ +// +// Copyright 2024 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 SwiftUI + +struct AddBookSheet: View { + // For iOS 15, we can use @Environment(\.dismiss) + @Binding var showingSheet: Bool + var action: (String) -> Void + + @State var url: String = "" + + var body: some View { + NavigationView { + Form { + TextField("URL", text: $url) + .keyboardType(.URL) + .autocapitalization(.none) + } + .navigationBarTitle("Add a Book") + .toolbar(content: toolbarContent) + } + } + + @ToolbarContentBuilder + private func toolbarContent() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + Button(.cancel) { + showingSheet = false + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button(.save) { + action(url) + showingSheet = false + } + .disabled(url.isEmpty) + } + } +} + +// struct AddBookSheet_Previews: PreviewProvider { +// static var previews: some View { +// AddBookSheet(showingSheet: true) +// } +// } diff --git a/TestApp/Sources/Bookshelf/Views/Bookshelf.swift b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift new file mode 100644 index 000000000..b6394ced5 --- /dev/null +++ b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift @@ -0,0 +1,55 @@ +// +// Copyright 2024 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 SwiftUI + +struct Bookshelf: View { + let bookRepository: BookRepository + + @State private var showingSheet = false + @State private var books: [Book] = [] + + var body: some View { + NavigationView { + VStack { + // TODO: figure out what the best column layout is for phones and tablets + let columns: [GridItem] = [GridItem(.adaptive(minimum: 150 + 8))] + ScrollView { + LazyVGrid(columns: columns, spacing: 20) { + ForEach(books, id: \.self) { book in + BookCover(title: book.title, authors: book.authors, url: book.cover?.url) + } + } + // TODO: handle error + .onReceive(bookRepository.all() + .replaceError(with: []) + ) { books in + self.books = books + } + } + } + .navigationTitle("Bookshelf") + .toolbar(content: toolbarContent) + } + .navigationViewStyle(.stack) + .sheet(isPresented: $showingSheet) { + AddBookSheet(showingSheet: $showingSheet) { _ in + // TODO: validate the URL and import the book + } + } + } +} + +extension Bookshelf { + @ToolbarContentBuilder + private func toolbarContent() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + Button(.add) { + showingSheet = true + } + } + } +} diff --git a/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift b/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift new file mode 100644 index 000000000..5d5fbbf8a --- /dev/null +++ b/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift @@ -0,0 +1,55 @@ +// +// Copyright 2024 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 SwiftUI + +struct AddFeedSheet: View { + typealias ActionCallback = ((title: String, url: String)) -> Void + + // For iOS 15, we can use @Environment(\.dismiss) + @Binding var showingSheet: Bool + var action: ActionCallback + + @State var title: String = "" + @State var url: String = "" + + var body: some View { + NavigationView { + Form { + Section { + TextField("Feed Title", text: $title) + TextField("URL", text: $url) + .keyboardType(.URL) + .autocapitalization(.none) + } + } + .navigationBarTitle("Add an OPDS Feed") + .toolbar(content: toolbarContent) + } + } + + @ToolbarContentBuilder + private func toolbarContent() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + Button(.cancel) { + showingSheet = false + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button(.save) { + action((title: title, url: url)) + showingSheet = false + } + .disabled(title.isEmpty || url.isEmpty) + } + } +} + +// struct AddFeedSheet_Previews: PreviewProvider { +// static var previews: some View { +// AddFeedSheet() +// } +// } diff --git a/TestApp/Sources/Catalogs/Views/CatalogFeed.swift b/TestApp/Sources/Catalogs/Views/CatalogFeed.swift new file mode 100644 index 000000000..2897d4f3c --- /dev/null +++ b/TestApp/Sources/Catalogs/Views/CatalogFeed.swift @@ -0,0 +1,90 @@ +// +// Copyright 2024 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 + +struct CatalogFeed: View { + var catalog: Catalog + @State private var parseData: ParseData? + + let catalogFeed: (Catalog) -> CatalogFeed + let publicationDetail: (Publication) -> PublicationDetail + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + if let feed = parseData?.feed { + if !feed.navigation.isEmpty { + ForEach(feed.navigation, id: \.self) { link in + let navigationLink = Catalog(title: link.title ?? "Catalog", url: link.href) + NavigationLink(destination: catalogFeed(navigationLink)) { + ListRowItem(title: link.title!) + } + } + Divider().frame(height: 50) + } + + // TODO: This probably needs its own file + if !feed.publications.isEmpty { + let columns: [GridItem] = [GridItem(.adaptive(minimum: 150 + 8))] + LazyVGrid(columns: columns) { + ForEach(feed.publications) { publication in + let authors = publication.metadata.authors + .map(\.name) + .joined(separator: ", ") + NavigationLink(destination: publicationDetail(publication)) { + BookCover( + title: publication.metadata.title ?? "", + authors: authors, + url: publication.images.first + .flatMap { URL(string: $0.href) } + ) + } + .buttonStyle(.plain) + } + } + Divider().frame(height: 50) + } + + if !feed.groups.isEmpty { + ForEach(feed.groups as [ReadiumShared.Group]) { group in + CatalogGroup(group: group, publicationDetail: publicationDetail, catalogFeed: catalogFeed) + .padding([.bottom], 25) + } + } + } + } + } + .padding() + .navigationTitle(catalog.title) + .navigationBarTitleDisplayMode(.inline) + .task { + if parseData == nil { + await parseFeed() + } + } + } +} + +extension CatalogFeed { + func parseFeed() async { + if let url = URL(string: catalog.url) { + OPDSParser.parseURL(url: url) { data, _ in + self.parseData = data + } + } + } +} + +struct CatalogDetail_Previews: PreviewProvider { + static var previews: some View { + let catalog = Catalog(title: "Test", url: "https://www.test.com") + CatalogFeed(catalog: catalog, catalogFeed: { _ in fatalError() }, + publicationDetail: { _ in fatalError() }) + } +} diff --git a/TestApp/Sources/Catalogs/Views/CatalogGroup.swift b/TestApp/Sources/Catalogs/Views/CatalogGroup.swift new file mode 100644 index 000000000..6008fab5f --- /dev/null +++ b/TestApp/Sources/Catalogs/Views/CatalogGroup.swift @@ -0,0 +1,62 @@ +// +// Copyright 2024 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 CatalogGroup: View { + var group: ReadiumShared.Group + let publicationDetail: (Publication) -> PublicationDetail + let catalogFeed: (Catalog) -> CatalogFeed + + var body: some View { + VStack(alignment: .leading) { + let rows = [GridItem(.flexible(), alignment: .top)] + HStack { + Text(group.metadata.title).font(.title3) + if !group.links.isEmpty { + let navigationLink = Catalog(title: group.links.first!.title ?? "Catalog", url: group.links.first!.href) + NavigationLink(destination: catalogFeed(navigationLink)) { + ListRowItem(title: "See All").frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + if !group.publications.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + LazyHGrid(rows: rows, spacing: 30) { + ForEach(group.publications) { publication in + let authors = publication.metadata.authors + .map(\.name) + .joined(separator: ", ") + NavigationLink(destination: publicationDetail(publication)) { + // FIXME: Ideally the title and author should not be truncated + BookCover( + title: publication.metadata.title ?? "", + authors: authors, + url: publication.images.first + .map { URL(string: $0.href)! } + ) + } + .buttonStyle(.plain) + } + } + } + } + ForEach(group.navigation, id: \.self) { navigation in + let navigationLink = Catalog(title: navigation.title ?? "Catalog", url: navigation.href) + NavigationLink(destination: catalogFeed(navigationLink)) { + ListRowItem(title: navigation.title!) + } + } + } + } +} + +// struct CatalogGroup_Previews: PreviewProvider { +// static var previews: some View { +// CatalogGroup() +// } +// } diff --git a/TestApp/Sources/Catalogs/Views/CatalogList.swift b/TestApp/Sources/Catalogs/Views/CatalogList.swift new file mode 100644 index 000000000..33c3cab1e --- /dev/null +++ b/TestApp/Sources/Catalogs/Views/CatalogList.swift @@ -0,0 +1,88 @@ +// +// Copyright 2024 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 SwiftUI + +struct CatalogList: View { + let catalogRepository: CatalogRepository + let catalogFeed: (Catalog) -> CatalogFeed + + @State private var showingSheet = false + @State private var showingAlert = false + @State private var catalogs: [Catalog] = [] + + var body: some View { + NavigationView { + VStack { + List { + ForEach(catalogs, id: \.id) { catalog in + NavigationLink(destination: catalogFeed(catalog)) { + ListRowItem(title: catalog.title) + } + } + .onDelete { offsets in + let catalogIds = offsets.map { catalogs[$0].id! } + Task { + try await deleteCatalogs(ids: catalogIds) + } + } + } + .onReceive(catalogRepository.all() + .replaceError(with: nil)) + { catalogsOrNil in + if let catalogs = catalogsOrNil { + self.catalogs = catalogs + } else { + print("Error fetching catalogs") + } + } + .listStyle(DefaultListStyle()) + } + .navigationTitle("Catalogs") + .toolbar(content: toolbarContent) + } + .navigationViewStyle(.stack) + .sheet(isPresented: $showingSheet) { + AddFeedSheet(showingSheet: $showingSheet) { title, url in + Task { + do { + OPDSParser.parseURL(url: URL(string: url)!) { _, _ in + } + try await addCatalog(catalog: Catalog(title: title, url: url)) + } catch { + showingAlert = true + } + } + } + } + .alert("Error", isPresented: $showingAlert, actions: { + Button("OK", role: .cancel, action: {}) + }, message: { + Text("Feed is not valid, please try again.") + }) + } + + @ToolbarContentBuilder + private func toolbarContent() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + Button(.add) { + showingSheet = true + } + } + } +} + +extension CatalogList { + func addCatalog(catalog: Catalog) async throws { + var savedCatalog = catalog + try? await catalogRepository.save(&savedCatalog) + } + + func deleteCatalogs(ids: [Catalog.Id]) async throws { + try? await catalogRepository.delete(ids: ids) + } +} diff --git a/TestApp/Sources/Catalogs/Views/PublicationDetail.swift b/TestApp/Sources/Catalogs/Views/PublicationDetail.swift new file mode 100644 index 000000000..0cabbd7d2 --- /dev/null +++ b/TestApp/Sources/Catalogs/Views/PublicationDetail.swift @@ -0,0 +1,53 @@ +// +// Copyright 2024 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 PublicationDetail: View { + @State var publication: Publication + + var body: some View { + let authors = publication.metadata.authors + .map(\.name) + .joined(separator: ", ") + ScrollView { + VStack { + AsyncImage( + url: publication.images.first + .map { URL(string: $0.href)! }, + content: { $0 + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 225, height: 330) + }, + placeholder: { ProgressView() } + ) + Text(publication.metadata.title ?? "").font(.largeTitle) + Text(authors).font(.title2) + Text(publication.metadata.description ?? "") + .padding([.top, .bottom], 20) + } + } + .padding() + .toolbar(content: toolbarContent) + } + + @ToolbarContentBuilder + private func toolbarContent() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + Button(.download) { + // TODO: download the publication + } + } + } +} + +// struct PublicationDetail_Previews: PreviewProvider { +// static var previews: some View { +// PublicationDetail() +// } +// } diff --git a/TestApp/Sources/Common/Toolkit/Extensions/ReadiumShared.swift b/TestApp/Sources/Common/Toolkit/Extensions/ReadiumShared.swift new file mode 100644 index 000000000..0ce46151c --- /dev/null +++ b/TestApp/Sources/Common/Toolkit/Extensions/ReadiumShared.swift @@ -0,0 +1,10 @@ +// +// Copyright 2024 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 + +extension ReadiumShared.Publication: Identifiable {} +extension ReadiumShared.Group: Identifiable {} diff --git a/TestApp/Sources/Container.swift b/TestApp/Sources/Container.swift new file mode 100644 index 000000000..7f5131051 --- /dev/null +++ b/TestApp/Sources/Container.swift @@ -0,0 +1,54 @@ +// +// Copyright 2024 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 ReadiumShared + +class Container { + private let db: Database + + init() throws { + db = try Database( + file: Paths.library.appendingPath("database.db", isDirectory: false).url, + migrations: [InitialMigration()] + ) + } + + // Bookshelf + + private lazy var bookRepository = BookRepository(db: db) + + func bookshelf() -> Bookshelf { + Bookshelf(bookRepository: bookRepository) + } + + // Catalogs + + private lazy var catalogRepository = CatalogRepository(db: db) + + func catalogs() -> CatalogList { + CatalogList( + catalogRepository: catalogRepository, + catalogFeed: catalogFeed(with:) + ) + } + + func catalogFeed(with catalog: Catalog) -> CatalogFeed { + CatalogFeed(catalog: catalog, + catalogFeed: catalogFeed(with:), + publicationDetail: publicationDetail(with:)) + } + + func publicationDetail(with publication: Publication) -> PublicationDetail { + PublicationDetail(publication: publication) + } + + // About + + func about() -> About { + About() + } +} diff --git a/TestApp/Sources/Data/Catalog.swift b/TestApp/Sources/Data/Catalog.swift new file mode 100644 index 000000000..c690dcc08 --- /dev/null +++ b/TestApp/Sources/Data/Catalog.swift @@ -0,0 +1,58 @@ +// +// Copyright 2024 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 GRDB + +/// Represents an OPDS catalog. +struct Catalog: Codable, Hashable, Identifiable { + struct Id: EntityId { let rawValue: Int64 } + + var id: Id? + var title: String + var url: String + var created: Date + + init(id: Id? = nil, title: String, url: String, created: Date = Date()) { + self.id = id + self.title = title + self.url = url + self.created = created + } +} + +extension Catalog: TableRecord, FetchableRecord, PersistableRecord { + enum Columns: String, ColumnExpression { + case id, title, url, created + } +} + +final class CatalogRepository { + private let db: Database + + init(db: Database) { + self.db = db + } + + func all() -> AnyPublisher<[Catalog]?, Error> { + db.observe { + try Catalog.order(Catalog.Columns.title).fetchAll($0) + } + } + + func save(_ catalog: inout Catalog) async throws { + catalog = try await db.write { [catalog] db in + try catalog.saved(db) + } + } + + func delete(ids: [Catalog.Id]) async throws { + try await db.write { db in + try Catalog.deleteAll(db, ids: ids) + } + } +} diff --git a/TestApp/Sources/TestApp.swift b/TestApp/Sources/TestApp.swift new file mode 100644 index 000000000..7fa047f7f --- /dev/null +++ b/TestApp/Sources/TestApp.swift @@ -0,0 +1,32 @@ +// +// Copyright 2024 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 GRDB +import SwiftUI + +@main +struct TestApp: App { + let container = try! Container() + + var body: some Scene { + WindowGroup { + TabView { + container.bookshelf() + .tabItem { + Label("Bookshelf", systemImage: "books.vertical.fill") + } + container.catalogs() + .tabItem { + Label("Catalogs", systemImage: "magazine.fill") + } + container.about() + .tabItem { + Label("About", systemImage: "info.circle.fill") + } + } + } + } +} diff --git a/TestApp/Sources/Views/BookCover.swift b/TestApp/Sources/Views/BookCover.swift new file mode 100644 index 000000000..11feb75de --- /dev/null +++ b/TestApp/Sources/Views/BookCover.swift @@ -0,0 +1,69 @@ +// +// Copyright 2024 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 SwiftUI + +struct BookCover: View { + var title: String + var authors: String? + var url: URL? + var action: () -> Void = {} + + var body: some View { + VStack { + let width: CGFloat = 150 + cover + .frame(width: width, height: 220, alignment: .bottom) + labels + .frame(width: width, alignment: .topLeading) + } + } + + @ViewBuilder + private var cover: some View { + if url != nil { + AsyncImage( + url: url, + content: { $0 + .resizable() + .aspectRatio(contentMode: .fit) + .shadow(radius: 2) + }, + placeholder: { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + ) + } else { + Image(systemName: "book.closed") + .resizable() + .aspectRatio(contentMode: .fit) + } + } + + @ViewBuilder + private var labels: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + .lineLimit(1) + + // Hack to reserve space for two lines of text. + // See https://sarunw.com/posts/how-to-force-two-lines-of-text-in-swiftui/ + Text((authors ?? "") + "\n") + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } +} + +struct BookCover_Previews: PreviewProvider { + static var previews: some View { + let book = Book(title: "Test Title", authors: "Test Author", type: "application/epub+zip", path: "/test/path/") + BookCover(title: book.title, authors: book.authors) + } +} diff --git a/TestApp/Sources/Views/Button.swift b/TestApp/Sources/Views/Button.swift new file mode 100644 index 000000000..ac69fb3a3 --- /dev/null +++ b/TestApp/Sources/Views/Button.swift @@ -0,0 +1,32 @@ +// +// Copyright 2024 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 SwiftUI + +enum ButtonKind { + case add + case cancel + case save + case download +} + +@ViewBuilder +func Button(_ kind: ButtonKind, action: @escaping () -> Void) -> some View { + switch kind { + case .add: + Button(action: action) { + Label("Add", systemImage: "plus") + } + case .cancel: + Button("Cancel", action: action) + case .save: + Button("Save", action: action) + case .download: + Button(action: action) { + Label("Download", systemImage: "icloud.and.arrow.down") + } + } +} diff --git a/TestApp/Sources/Views/ListRowItem.swift b/TestApp/Sources/Views/ListRowItem.swift new file mode 100644 index 000000000..ed70ba974 --- /dev/null +++ b/TestApp/Sources/Views/ListRowItem.swift @@ -0,0 +1,24 @@ +// +// Copyright 2024 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 SwiftUI + +struct ListRowItem: View { + var action: () -> Void = {} + var title: String + + var body: some View { + Text(title) + .font(.title3) + .padding(.vertical, 8) + } +} + +struct ListRowItem_Previews: PreviewProvider { + static var previews: some View { + ListRowItem(title: "Test") + } +} From acdd82de3d59728ef9e9d27675c00ad9d06355e9 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Sat, 29 Jun 2024 15:00:32 -0500 Subject: [PATCH 02/16] Add Hashable and Identifiable protocols to Book --- TestApp/Sources/Data/Book.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TestApp/Sources/Data/Book.swift b/TestApp/Sources/Data/Book.swift index 1360677aa..ba9314d9f 100644 --- a/TestApp/Sources/Data/Book.swift +++ b/TestApp/Sources/Data/Book.swift @@ -9,7 +9,7 @@ import Foundation import GRDB import ReadiumShared -struct Book: Codable { +struct Book: Codable, Hashable, Identifiable { struct Id: EntityId { let rawValue: Int64 } let id: Id? From 0f8e5d285f49758f3fedd01a4e51e550edc1ba6f Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Sat, 29 Jun 2024 15:01:07 -0500 Subject: [PATCH 03/16] Redo database to support migrations --- TestApp/Sources/App/AppModule.swift | 2 +- TestApp/Sources/Data/Database.swift | 73 ++++++++----------- .../Sources/Data/Migrations/01 Initial.swift | 73 +++++++++++++++++++ 3 files changed, 105 insertions(+), 43 deletions(-) create mode 100644 TestApp/Sources/Data/Migrations/01 Initial.swift diff --git a/TestApp/Sources/App/AppModule.swift b/TestApp/Sources/App/AppModule.swift index 84467aa43..d65835abc 100644 --- a/TestApp/Sources/App/AppModule.swift +++ b/TestApp/Sources/App/AppModule.swift @@ -30,7 +30,7 @@ final class AppModule { let httpClient = DefaultHTTPClient() let file = Paths.library.appendingPath("database.db", isDirectory: false) - let db = try Database(file: file.url) + let db = try Database(file: file.url, migrations: [InitialMigration()]) print("Created database at \(file.path)") let books = BookRepository(db: db) diff --git a/TestApp/Sources/Data/Database.swift b/TestApp/Sources/Data/Database.swift index 6a7bca42d..36db6dd7f 100644 --- a/TestApp/Sources/Data/Database.swift +++ b/TestApp/Sources/Data/Database.swift @@ -8,56 +8,45 @@ import Combine import Foundation import GRDB import ReadiumShared +import SwiftUI + +/// Database migration to be performed when updating the app. +protocol DatabaseMigration { + /// Schema version for this migration. + var version: Int { get } + + /// Applies the migration. + func run(on db: GRDB.Database) throws +} final class Database { - convenience init(file: URL) throws { - try self.init(writer: DatabaseQueue(path: file.path)) + convenience init(file: URL, migrations: [DatabaseMigration]) throws { + try self.init(writer: DatabaseQueue(path: file.path), migrations: migrations) } private let writer: DatabaseWriter - private init(writer: DatabaseWriter = DatabaseQueue()) throws { + private init(writer: DatabaseWriter = DatabaseQueue(), migrations: [DatabaseMigration]) throws { self.writer = writer - - var migrator = DatabaseMigrator() - migrator.registerMigration("initial") { db in - try db.create(table: "book") { t in - t.autoIncrementedPrimaryKey("id") - t.column("identifier", .text) - t.column("title", .text).notNull() - t.column("authors", .text) - t.column("type", .text).notNull() - t.column("path", .text).notNull() - t.column("coverPath", .text) - t.column("locator", .text) - t.column("progression", .integer).notNull().defaults(to: 0) - t.column("created", .datetime).notNull() - t.column("preferencesJSON", .text) - } - - try db.create(table: "bookmark") { t in - t.autoIncrementedPrimaryKey("id") - t.column("bookId", .integer).references("book", onDelete: .cascade).notNull() - t.column("locator", .text) - t.column("progression", .double).notNull() - t.column("created", .datetime).notNull() - } - - try db.create(table: "highlight") { t in - t.autoIncrementedPrimaryKey("id") - t.column("bookId", .integer).references("book", onDelete: .cascade).notNull() - t.column("locator", .text) - t.column("progression", .double).notNull() - t.column("color", .integer).notNull() - t.column("created", .datetime).notNull() - } - - // create an index to make sorting by progression faster - try db.create(index: "index_highlight_progression", on: "highlight", columns: ["bookId", "progression"], ifNotExists: true) - try db.create(index: "index_bookmark_progression", on: "bookmark", columns: ["bookId", "progression"], ifNotExists: true) + + try run(migrations) + } + + /// Runs the database migrations on `Database` initialization. + private func run(_ migrations: [DatabaseMigration]) throws { + try writer.write { db in + let currentVersion = try Int64.fetchOne(db, sql: "PRAGMA user_version") ?? 0 + + try migrations + .filter { $0.version > currentVersion } + .sorted { $0.version < $1.version } + .forEach { try run($0, on: db) } } - - try migrator.migrate(writer) + } + + private func run(_ migration: DatabaseMigration, on db: GRDB.Database) throws { + try migration.run(on: db) + try db.execute(sql: "PRAGMA user_version = \(migration.version)") } func read(_ query: @escaping (GRDB.Database) throws -> T) async throws -> T { diff --git a/TestApp/Sources/Data/Migrations/01 Initial.swift b/TestApp/Sources/Data/Migrations/01 Initial.swift new file mode 100644 index 000000000..1b4c72427 --- /dev/null +++ b/TestApp/Sources/Data/Migrations/01 Initial.swift @@ -0,0 +1,73 @@ +// +// Copyright 2024 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 GRDB + +/// This database migration will create the SQL schema and insert some initial data. +struct InitialMigration: DatabaseMigration { + let version = 1 + + func run(on db: GRDB.Database) throws { + try createSchema(on: db) + try bootstrapData(on: db) + } + + private func createSchema(on db: GRDB.Database) throws { + try db.create(table: "book", ifNotExists: true) { t in + t.autoIncrementedPrimaryKey("id") + t.column("identifier", .text) + t.column("title", .text).notNull() + t.column("authors", .text) + t.column("type", .text).notNull() + t.column("path", .text).notNull() + t.column("coverPath", .text) + t.column("locator", .text) + t.column("progression", .integer).notNull().defaults(to: 0) + t.column("created", .datetime).notNull() + t.column("preferencesJSON", .text) + } + + try db.create(table: "bookmark", ifNotExists: true) { t in + t.autoIncrementedPrimaryKey("id") + t.column("bookId", .integer).references("book", onDelete: .cascade).notNull() + t.column("locator", .text) + t.column("progression", .double).notNull() + t.column("created", .datetime).notNull() + } + + try db.create(table: "highlight", ifNotExists: true) { t in + t.autoIncrementedPrimaryKey("id") + t.column("bookId", .integer).references("book", onDelete: .cascade).notNull() + t.column("locator", .text) + t.column("progression", .double).notNull() + t.column("color", .integer).notNull() + t.column("created", .datetime).notNull() + } + + // create an index to make sorting by progression faster + try db.create(index: "index_highlight_progression", on: "highlight", columns: ["bookId", "progression"], ifNotExists: true) + try db.create(index: "index_bookmark_progression", on: "bookmark", columns: ["bookId", "progression"], ifNotExists: true) + + try db.create(table: "catalog", ifNotExists: true) { t in + t.autoIncrementedPrimaryKey("id") + t.column("title", .text) + t.column("url", .text).notNull() + t.column("created", .datetime).notNull() + } + } + + private func bootstrapData(on db: GRDB.Database) throws { + let catalogs = [ + Catalog(title: "OPDS 2.0 Test Catalog", url: "https://test.opds.io/2.0/home.json"), + Catalog(title: "Open Textbooks Catalog", url: "http://open.minitex.org/textbooks"), + ] + + for catalog in catalogs { + try catalog.save(db) + } + } +} From 665b55129d02d83459c1de500bee916d027d602a Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Sat, 29 Jun 2024 15:01:37 -0500 Subject: [PATCH 04/16] Don't run the storyboard based app --- TestApp/Sources/AppDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index a799e347f..1e6e152f7 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -8,7 +8,7 @@ import Combine import ReadiumShared import UIKit -@UIApplicationMain +//@UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? From f8ab403ba7365eadbbd985b6b9ac72e3d2daa0e5 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Sat, 29 Jun 2024 15:24:45 -0500 Subject: [PATCH 05/16] Go back to storyboard running of testapp --- TestApp/Sources/AppDelegate.swift | 2 +- TestApp/Sources/TestApp.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index 1e6e152f7..a799e347f 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -8,7 +8,7 @@ import Combine import ReadiumShared import UIKit -//@UIApplicationMain +@UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? diff --git a/TestApp/Sources/TestApp.swift b/TestApp/Sources/TestApp.swift index 7fa047f7f..03961fa13 100644 --- a/TestApp/Sources/TestApp.swift +++ b/TestApp/Sources/TestApp.swift @@ -7,7 +7,7 @@ import GRDB import SwiftUI -@main +//@main struct TestApp: App { let container = try! Container() From 12d2ef99fb0e5ab12133881dfa0014b27200ddb0 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Sat, 29 Jun 2024 15:58:22 -0500 Subject: [PATCH 06/16] Fix lint issues --- TestApp/Sources/Data/Database.swift | 6 +++--- TestApp/Sources/TestApp.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/TestApp/Sources/Data/Database.swift b/TestApp/Sources/Data/Database.swift index 36db6dd7f..39f7b2ac3 100644 --- a/TestApp/Sources/Data/Database.swift +++ b/TestApp/Sources/Data/Database.swift @@ -28,10 +28,10 @@ final class Database { private init(writer: DatabaseWriter = DatabaseQueue(), migrations: [DatabaseMigration]) throws { self.writer = writer - + try run(migrations) } - + /// Runs the database migrations on `Database` initialization. private func run(_ migrations: [DatabaseMigration]) throws { try writer.write { db in @@ -43,7 +43,7 @@ final class Database { .forEach { try run($0, on: db) } } } - + private func run(_ migration: DatabaseMigration, on db: GRDB.Database) throws { try migration.run(on: db) try db.execute(sql: "PRAGMA user_version = \(migration.version)") diff --git a/TestApp/Sources/TestApp.swift b/TestApp/Sources/TestApp.swift index 03961fa13..c0311493e 100644 --- a/TestApp/Sources/TestApp.swift +++ b/TestApp/Sources/TestApp.swift @@ -7,7 +7,7 @@ import GRDB import SwiftUI -//@main +// @main struct TestApp: App { let container = try! Container() From b4b82e2bdb5a0fcb268113c9a59e44ba287703d6 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Mon, 1 Jul 2024 19:29:39 -0500 Subject: [PATCH 07/16] Update TestApp to iOS 16 --- TestApp/Integrations/Carthage/project+lcp.yml | 4 ++-- TestApp/Integrations/Carthage/project.yml | 2 +- TestApp/Integrations/CocoaPods/Podfile | 4 ++-- TestApp/Integrations/CocoaPods/Podfile+lcp | 4 ++-- TestApp/Integrations/CocoaPods/project+lcp.yml | 2 +- TestApp/Integrations/CocoaPods/project.yml | 2 +- TestApp/Integrations/Local/project+lcp.yml | 2 +- TestApp/Integrations/Local/project.yml | 4 ++-- TestApp/Integrations/SPM/project+lcp.yml | 2 +- TestApp/Integrations/SPM/project.yml | 4 ++-- TestApp/README.md | 2 +- 11 files changed, 16 insertions(+), 16 deletions(-) diff --git a/TestApp/Integrations/Carthage/project+lcp.yml b/TestApp/Integrations/Carthage/project+lcp.yml index ef9eac672..f72e78abf 100644 --- a/TestApp/Integrations/Carthage/project+lcp.yml +++ b/TestApp/Integrations/Carthage/project+lcp.yml @@ -15,8 +15,8 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" - sources: + deploymentTarget: "16.0" + sources: - path: Sources excludes: - Resources/Fonts diff --git a/TestApp/Integrations/Carthage/project.yml b/TestApp/Integrations/Carthage/project.yml index 24a543164..0e33f1e38 100644 --- a/TestApp/Integrations/Carthage/project.yml +++ b/TestApp/Integrations/Carthage/project.yml @@ -15,7 +15,7 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" + deploymentTarget: "16.0" sources: - path: Sources excludes: diff --git a/TestApp/Integrations/CocoaPods/Podfile b/TestApp/Integrations/CocoaPods/Podfile index 6d02b2e2f..ccb1dc0ab 100644 --- a/TestApp/Integrations/CocoaPods/Podfile +++ b/TestApp/Integrations/CocoaPods/Podfile @@ -1,4 +1,4 @@ -platform :ios, '14.0' +platform :ios, '16.0' target 'TestApp' do # Comment the next line if you don't want to use dynamic frameworks @@ -23,7 +23,7 @@ end post_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' config.build_settings['ENABLE_BITCODE'] = 'NO' end end diff --git a/TestApp/Integrations/CocoaPods/Podfile+lcp b/TestApp/Integrations/CocoaPods/Podfile+lcp index 31186eded..fe80b872c 100644 --- a/TestApp/Integrations/CocoaPods/Podfile+lcp +++ b/TestApp/Integrations/CocoaPods/Podfile+lcp @@ -1,4 +1,4 @@ -platform :ios, '14.0' +platform :ios, '16.0' target 'TestApp' do # Comment the next line if you don't want to use dynamic frameworks @@ -26,7 +26,7 @@ end post_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' config.build_settings['ENABLE_BITCODE'] = 'NO' end end diff --git a/TestApp/Integrations/CocoaPods/project+lcp.yml b/TestApp/Integrations/CocoaPods/project+lcp.yml index 741934ed2..42c682ac8 100644 --- a/TestApp/Integrations/CocoaPods/project+lcp.yml +++ b/TestApp/Integrations/CocoaPods/project+lcp.yml @@ -5,7 +5,7 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" + deploymentTarget: "16.0" sources: - path: Sources excludes: diff --git a/TestApp/Integrations/CocoaPods/project.yml b/TestApp/Integrations/CocoaPods/project.yml index 578dd1ed9..86ef1d095 100644 --- a/TestApp/Integrations/CocoaPods/project.yml +++ b/TestApp/Integrations/CocoaPods/project.yml @@ -5,7 +5,7 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" + deploymentTarget: "16.0" sources: - path: Sources excludes: diff --git a/TestApp/Integrations/Local/project+lcp.yml b/TestApp/Integrations/Local/project+lcp.yml index ba2c0558d..cd70cab50 100644 --- a/TestApp/Integrations/Local/project+lcp.yml +++ b/TestApp/Integrations/Local/project+lcp.yml @@ -31,7 +31,7 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" + deploymentTarget: "16.0" sources: - path: Sources excludes: diff --git a/TestApp/Integrations/Local/project.yml b/TestApp/Integrations/Local/project.yml index 8ef863399..50ed51bdc 100644 --- a/TestApp/Integrations/Local/project.yml +++ b/TestApp/Integrations/Local/project.yml @@ -29,8 +29,8 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" - sources: + deploymentTarget: "16.0" + sources: - path: Sources excludes: - Resources/Fonts diff --git a/TestApp/Integrations/SPM/project+lcp.yml b/TestApp/Integrations/SPM/project+lcp.yml index 6a6e7d29a..a0c92db70 100644 --- a/TestApp/Integrations/SPM/project+lcp.yml +++ b/TestApp/Integrations/SPM/project+lcp.yml @@ -23,7 +23,7 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" + deploymentTarget: "16.0" sources: - path: Sources excludes: diff --git a/TestApp/Integrations/SPM/project.yml b/TestApp/Integrations/SPM/project.yml index d7dff76e1..dfe71fd83 100644 --- a/TestApp/Integrations/SPM/project.yml +++ b/TestApp/Integrations/SPM/project.yml @@ -21,8 +21,8 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" - sources: + deploymentTarget: "16.0" + sources: - path: Sources excludes: - Resources/Fonts diff --git a/TestApp/README.md b/TestApp/README.md index 4970e3d36..76d36ee89 100644 --- a/TestApp/README.md +++ b/TestApp/README.md @@ -2,7 +2,7 @@ This sample application demonstrates how to integrate the Readium Swift toolkit in your own reading app. Stable versions are [published on TestFlight](https://testflight.apple.com/join/lYEMEfBr). -:warning: The Readium toolkit itself supports down to iOS 11, but the Test App requires iOS 14 and Xcode 13.2. +:warning: The Readium toolkit itself supports down to iOS 13, but the Test App requires iOS 16 and Xcode 13.2. ## Features From 9b04b78123c907462310d6c164e404f9470a9080 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Mon, 1 Jul 2024 20:16:10 -0500 Subject: [PATCH 08/16] Switch to NavigationStack --- TestApp/Sources/About/Views/About.swift | 2 +- TestApp/Sources/Bookshelf/Views/AddBookSheet.swift | 4 ++-- TestApp/Sources/Bookshelf/Views/Bookshelf.swift | 3 +-- TestApp/Sources/Catalogs/Views/AddFeedSheet.swift | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/TestApp/Sources/About/Views/About.swift b/TestApp/Sources/About/Views/About.swift index 228347b04..4b135d773 100644 --- a/TestApp/Sources/About/Views/About.swift +++ b/TestApp/Sources/About/Views/About.swift @@ -8,7 +8,7 @@ import SwiftUI struct About: View { var body: some View { - NavigationView { + NavigationStack { VStack(alignment: .leading, spacing: 20) { Text("Version") .font(.title2) diff --git a/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift b/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift index bb82bfc0c..383f23443 100644 --- a/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift +++ b/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift @@ -14,13 +14,13 @@ struct AddBookSheet: View { @State var url: String = "" var body: some View { - NavigationView { + NavigationStack { Form { TextField("URL", text: $url) .keyboardType(.URL) .autocapitalization(.none) } - .navigationBarTitle("Add a Book") + .navigationTitle("Add a Book") .toolbar(content: toolbarContent) } } diff --git a/TestApp/Sources/Bookshelf/Views/Bookshelf.swift b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift index b6394ced5..113c1a6ae 100644 --- a/TestApp/Sources/Bookshelf/Views/Bookshelf.swift +++ b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift @@ -13,7 +13,7 @@ struct Bookshelf: View { @State private var books: [Book] = [] var body: some View { - NavigationView { + NavigationStack { VStack { // TODO: figure out what the best column layout is for phones and tablets let columns: [GridItem] = [GridItem(.adaptive(minimum: 150 + 8))] @@ -34,7 +34,6 @@ struct Bookshelf: View { .navigationTitle("Bookshelf") .toolbar(content: toolbarContent) } - .navigationViewStyle(.stack) .sheet(isPresented: $showingSheet) { AddBookSheet(showingSheet: $showingSheet) { _ in // TODO: validate the URL and import the book diff --git a/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift b/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift index 5d5fbbf8a..790633fb5 100644 --- a/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift +++ b/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift @@ -17,7 +17,7 @@ struct AddFeedSheet: View { @State var url: String = "" var body: some View { - NavigationView { + NavigationStack { Form { Section { TextField("Feed Title", text: $title) From b95416f9769ec9153ac51a5c96a0b93bd87f117a Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Mon, 1 Jul 2024 20:20:10 -0500 Subject: [PATCH 09/16] Simplify navigation links and OPDS publications --- .../Sources/Catalogs/Views/CatalogFeed.swift | 12 +++---- .../Sources/Catalogs/Views/CatalogGroup.swift | 10 +++--- .../Sources/Catalogs/Views/CatalogList.swift | 35 ++++++++++++------- .../Catalogs/Views/PublicationDetail.swift | 14 ++++---- TestApp/Sources/Common/Publication.swift | 16 +++++++++ TestApp/Sources/Container.swift | 11 +++--- 6 files changed, 59 insertions(+), 39 deletions(-) diff --git a/TestApp/Sources/Catalogs/Views/CatalogFeed.swift b/TestApp/Sources/Catalogs/Views/CatalogFeed.swift index 2897d4f3c..5cec6f39b 100644 --- a/TestApp/Sources/Catalogs/Views/CatalogFeed.swift +++ b/TestApp/Sources/Catalogs/Views/CatalogFeed.swift @@ -12,9 +12,6 @@ struct CatalogFeed: View { var catalog: Catalog @State private var parseData: ParseData? - let catalogFeed: (Catalog) -> CatalogFeed - let publicationDetail: (Publication) -> PublicationDetail - var body: some View { ScrollView { VStack(alignment: .leading) { @@ -22,7 +19,7 @@ struct CatalogFeed: View { if !feed.navigation.isEmpty { ForEach(feed.navigation, id: \.self) { link in let navigationLink = Catalog(title: link.title ?? "Catalog", url: link.href) - NavigationLink(destination: catalogFeed(navigationLink)) { + NavigationLink(value: navigationLink) { ListRowItem(title: link.title!) } } @@ -37,7 +34,7 @@ struct CatalogFeed: View { let authors = publication.metadata.authors .map(\.name) .joined(separator: ", ") - NavigationLink(destination: publicationDetail(publication)) { + NavigationLink(value: OPDSPublication(from: publication)) { BookCover( title: publication.metadata.title ?? "", authors: authors, @@ -53,7 +50,7 @@ struct CatalogFeed: View { if !feed.groups.isEmpty { ForEach(feed.groups as [ReadiumShared.Group]) { group in - CatalogGroup(group: group, publicationDetail: publicationDetail, catalogFeed: catalogFeed) + CatalogGroup(group: group) .padding([.bottom], 25) } } @@ -84,7 +81,6 @@ extension CatalogFeed { struct CatalogDetail_Previews: PreviewProvider { static var previews: some View { let catalog = Catalog(title: "Test", url: "https://www.test.com") - CatalogFeed(catalog: catalog, catalogFeed: { _ in fatalError() }, - publicationDetail: { _ in fatalError() }) + CatalogFeed(catalog: catalog) } } diff --git a/TestApp/Sources/Catalogs/Views/CatalogGroup.swift b/TestApp/Sources/Catalogs/Views/CatalogGroup.swift index 6008fab5f..abf11a7ad 100644 --- a/TestApp/Sources/Catalogs/Views/CatalogGroup.swift +++ b/TestApp/Sources/Catalogs/Views/CatalogGroup.swift @@ -9,8 +9,6 @@ import SwiftUI struct CatalogGroup: View { var group: ReadiumShared.Group - let publicationDetail: (Publication) -> PublicationDetail - let catalogFeed: (Catalog) -> CatalogFeed var body: some View { VStack(alignment: .leading) { @@ -18,8 +16,8 @@ struct CatalogGroup: View { HStack { Text(group.metadata.title).font(.title3) if !group.links.isEmpty { - let navigationLink = Catalog(title: group.links.first!.title ?? "Catalog", url: group.links.first!.href) - NavigationLink(destination: catalogFeed(navigationLink)) { + let navigationLink = Catalog(title: group.metadata.title, url: group.links.first!.href) + NavigationLink(value: navigationLink) { ListRowItem(title: "See All").frame(maxWidth: .infinity, alignment: .trailing) } } @@ -31,7 +29,7 @@ struct CatalogGroup: View { let authors = publication.metadata.authors .map(\.name) .joined(separator: ", ") - NavigationLink(destination: publicationDetail(publication)) { + NavigationLink(value: OPDSPublication(from: publication)) { // FIXME: Ideally the title and author should not be truncated BookCover( title: publication.metadata.title ?? "", @@ -47,7 +45,7 @@ struct CatalogGroup: View { } ForEach(group.navigation, id: \.self) { navigation in let navigationLink = Catalog(title: navigation.title ?? "Catalog", url: navigation.href) - NavigationLink(destination: catalogFeed(navigationLink)) { + NavigationLink(value: navigationLink) { ListRowItem(title: navigation.title!) } } diff --git a/TestApp/Sources/Catalogs/Views/CatalogList.swift b/TestApp/Sources/Catalogs/Views/CatalogList.swift index 33c3cab1e..bf0e3cf77 100644 --- a/TestApp/Sources/Catalogs/Views/CatalogList.swift +++ b/TestApp/Sources/Catalogs/Views/CatalogList.swift @@ -10,17 +10,18 @@ import SwiftUI struct CatalogList: View { let catalogRepository: CatalogRepository let catalogFeed: (Catalog) -> CatalogFeed + let publicationDetail: (OPDSPublication) -> PublicationDetail @State private var showingSheet = false @State private var showingAlert = false @State private var catalogs: [Catalog] = [] var body: some View { - NavigationView { + NavigationStack { VStack { List { ForEach(catalogs, id: \.id) { catalog in - NavigationLink(destination: catalogFeed(catalog)) { + NavigationLink(value: catalog) { ListRowItem(title: catalog.title) } } @@ -43,19 +44,18 @@ struct CatalogList: View { .listStyle(DefaultListStyle()) } .navigationTitle("Catalogs") + .navigationDestination(for: Catalog.self) { catalog in + catalogFeed(catalog) + } + .navigationDestination(for: OPDSPublication.self) { opdsPublication in + publicationDetail(opdsPublication) + } .toolbar(content: toolbarContent) } - .navigationViewStyle(.stack) .sheet(isPresented: $showingSheet) { AddFeedSheet(showingSheet: $showingSheet) { title, url in Task { - do { - OPDSParser.parseURL(url: URL(string: url)!) { _, _ in - } - try await addCatalog(catalog: Catalog(title: title, url: url)) - } catch { - showingAlert = true - } + try await addCatalog(title: title, url: url) } } } @@ -77,9 +77,18 @@ struct CatalogList: View { } extension CatalogList { - func addCatalog(catalog: Catalog) async throws { - var savedCatalog = catalog - try? await catalogRepository.save(&savedCatalog) + func addCatalog(title: String, url: String) async throws { + do { + guard let catalogURL = URL(string: url) else { + showingAlert = true + return + } + OPDSParser.parseURL(url: catalogURL) { _, _ in } + var savedCatalog = Catalog(title: title, url: url) + try await catalogRepository.save(&savedCatalog) + } catch { + showingAlert = true + } } func deleteCatalogs(ids: [Catalog.Id]) async throws { diff --git a/TestApp/Sources/Catalogs/Views/PublicationDetail.swift b/TestApp/Sources/Catalogs/Views/PublicationDetail.swift index 0cabbd7d2..3a842e9ea 100644 --- a/TestApp/Sources/Catalogs/Views/PublicationDetail.swift +++ b/TestApp/Sources/Catalogs/Views/PublicationDetail.swift @@ -8,16 +8,16 @@ import ReadiumShared import SwiftUI struct PublicationDetail: View { - @State var publication: Publication + @State var opdsPublication: OPDSPublication var body: some View { - let authors = publication.metadata.authors + let authors = opdsPublication.authors .map(\.name) .joined(separator: ", ") ScrollView { VStack { AsyncImage( - url: publication.images.first + url: opdsPublication.images.first .map { URL(string: $0.href)! }, content: { $0 .resizable() @@ -26,10 +26,12 @@ struct PublicationDetail: View { }, placeholder: { ProgressView() } ) - Text(publication.metadata.title ?? "").font(.largeTitle) - Text(authors).font(.title2) - Text(publication.metadata.description ?? "") + Text(opdsPublication.title ?? "").font(.title) + Text(authors).font(.title3) + .padding([.top], 5) + Text(opdsPublication.description ?? "") .padding([.top, .bottom], 20) + .frame(alignment: .leading) } } .padding() diff --git a/TestApp/Sources/Common/Publication.swift b/TestApp/Sources/Common/Publication.swift index 58bd30770..6cededf02 100644 --- a/TestApp/Sources/Common/Publication.swift +++ b/TestApp/Sources/Common/Publication.swift @@ -17,3 +17,19 @@ extension Publication { } } } + +struct OPDSPublication: Hashable { + let title: String? + let authors: [Contributor] + let images: [Link] + let description: String? + let baseURL: HTTPURL? + + init(from publication: Publication) { + title = publication.metadata.title + authors = publication.metadata.authors + images = publication.images + description = publication.metadata.description + baseURL = publication.baseURL + } +} diff --git a/TestApp/Sources/Container.swift b/TestApp/Sources/Container.swift index 7f5131051..905fdc83b 100644 --- a/TestApp/Sources/Container.swift +++ b/TestApp/Sources/Container.swift @@ -32,18 +32,17 @@ class Container { func catalogs() -> CatalogList { CatalogList( catalogRepository: catalogRepository, - catalogFeed: catalogFeed(with:) + catalogFeed: catalogFeed(with:), + publicationDetail: publicationDetail(with:) ) } func catalogFeed(with catalog: Catalog) -> CatalogFeed { - CatalogFeed(catalog: catalog, - catalogFeed: catalogFeed(with:), - publicationDetail: publicationDetail(with:)) + CatalogFeed(catalog: catalog) } - func publicationDetail(with publication: Publication) -> PublicationDetail { - PublicationDetail(publication: publication) + func publicationDetail(with opdsPublication: OPDSPublication) -> PublicationDetail { + PublicationDetail(opdsPublication: opdsPublication) } // About From db1da2fcdc97174a726aea32002aff580a1d6206 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Mon, 1 Jul 2024 20:42:13 -0500 Subject: [PATCH 10/16] Use Environment dismiss for sheets --- TestApp/Sources/Bookshelf/Views/AddBookSheet.swift | 7 +++---- TestApp/Sources/Bookshelf/Views/Bookshelf.swift | 7 ++++--- TestApp/Sources/Catalogs/Views/AddFeedSheet.swift | 7 +++---- TestApp/Sources/Catalogs/Views/CatalogList.swift | 6 +++--- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift b/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift index 383f23443..78e239223 100644 --- a/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift +++ b/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift @@ -7,8 +7,7 @@ import SwiftUI struct AddBookSheet: View { - // For iOS 15, we can use @Environment(\.dismiss) - @Binding var showingSheet: Bool + @Environment(\.dismiss) private var dismiss var action: (String) -> Void @State var url: String = "" @@ -29,13 +28,13 @@ struct AddBookSheet: View { private func toolbarContent() -> some ToolbarContent { ToolbarItem(placement: .navigationBarLeading) { Button(.cancel) { - showingSheet = false + dismiss() } } ToolbarItem(placement: .navigationBarTrailing) { Button(.save) { action(url) - showingSheet = false + dismiss() } .disabled(url.isEmpty) } diff --git a/TestApp/Sources/Bookshelf/Views/Bookshelf.swift b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift index 113c1a6ae..7cf7279b4 100644 --- a/TestApp/Sources/Bookshelf/Views/Bookshelf.swift +++ b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift @@ -35,8 +35,9 @@ struct Bookshelf: View { .toolbar(content: toolbarContent) } .sheet(isPresented: $showingSheet) { - AddBookSheet(showingSheet: $showingSheet) { _ in + AddBookSheet { url in // TODO: validate the URL and import the book + print(url) } } } @@ -46,9 +47,9 @@ extension Bookshelf { @ToolbarContentBuilder private func toolbarContent() -> some ToolbarContent { ToolbarItem(placement: .navigationBarTrailing) { - Button(.add) { + Button(.add, action: { showingSheet = true - } + }) } } } diff --git a/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift b/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift index 790633fb5..1b2418a76 100644 --- a/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift +++ b/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift @@ -9,8 +9,7 @@ import SwiftUI struct AddFeedSheet: View { typealias ActionCallback = ((title: String, url: String)) -> Void - // For iOS 15, we can use @Environment(\.dismiss) - @Binding var showingSheet: Bool + @Environment(\.dismiss) private var dismiss var action: ActionCallback @State var title: String = "" @@ -35,13 +34,13 @@ struct AddFeedSheet: View { private func toolbarContent() -> some ToolbarContent { ToolbarItem(placement: .navigationBarLeading) { Button(.cancel) { - showingSheet = false + dismiss() } } ToolbarItem(placement: .navigationBarTrailing) { Button(.save) { action((title: title, url: url)) - showingSheet = false + dismiss() } .disabled(title.isEmpty || url.isEmpty) } diff --git a/TestApp/Sources/Catalogs/Views/CatalogList.swift b/TestApp/Sources/Catalogs/Views/CatalogList.swift index bf0e3cf77..18b2f010d 100644 --- a/TestApp/Sources/Catalogs/Views/CatalogList.swift +++ b/TestApp/Sources/Catalogs/Views/CatalogList.swift @@ -53,7 +53,7 @@ struct CatalogList: View { .toolbar(content: toolbarContent) } .sheet(isPresented: $showingSheet) { - AddFeedSheet(showingSheet: $showingSheet) { title, url in + AddFeedSheet { title, url in Task { try await addCatalog(title: title, url: url) } @@ -69,9 +69,9 @@ struct CatalogList: View { @ToolbarContentBuilder private func toolbarContent() -> some ToolbarContent { ToolbarItem(placement: .navigationBarTrailing) { - Button(.add) { + Button(.add, action: { showingSheet = true - } + }) } } } From 310cbb2411dd244019e65341d1e8688f60899559 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Mon, 1 Jul 2024 20:59:19 -0500 Subject: [PATCH 11/16] Fix lint issue --- TestApp/Sources/Common/Publication.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TestApp/Sources/Common/Publication.swift b/TestApp/Sources/Common/Publication.swift index 6cededf02..3a202cfb0 100644 --- a/TestApp/Sources/Common/Publication.swift +++ b/TestApp/Sources/Common/Publication.swift @@ -24,7 +24,7 @@ struct OPDSPublication: Hashable { let images: [Link] let description: String? let baseURL: HTTPURL? - + init(from publication: Publication) { title = publication.metadata.title authors = publication.metadata.authors From da57a56f79accd685220390dcfa7817aaea6bebf Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Wed, 3 Jul 2024 21:34:43 -0500 Subject: [PATCH 12/16] Add comments --- TestApp/Sources/Bookshelf/Views/Bookshelf.swift | 1 - TestApp/Sources/Catalogs/Views/AddFeedSheet.swift | 1 + TestApp/Sources/Catalogs/Views/CatalogFeed.swift | 2 ++ TestApp/Sources/Catalogs/Views/CatalogList.swift | 6 ++++++ TestApp/Sources/Catalogs/Views/PublicationDetail.swift | 1 + TestApp/Sources/Data/Catalog.swift | 3 +++ TestApp/Sources/TestApp.swift | 1 + 7 files changed, 14 insertions(+), 1 deletion(-) diff --git a/TestApp/Sources/Bookshelf/Views/Bookshelf.swift b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift index 7cf7279b4..fa2e9b82a 100644 --- a/TestApp/Sources/Bookshelf/Views/Bookshelf.swift +++ b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift @@ -37,7 +37,6 @@ struct Bookshelf: View { .sheet(isPresented: $showingSheet) { AddBookSheet { url in // TODO: validate the URL and import the book - print(url) } } } diff --git a/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift b/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift index 1b2418a76..282a3db33 100644 --- a/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift +++ b/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift @@ -6,6 +6,7 @@ import SwiftUI +/// Sheet to add a new OPDS catalog via a URL struct AddFeedSheet: View { typealias ActionCallback = ((title: String, url: String)) -> Void diff --git a/TestApp/Sources/Catalogs/Views/CatalogFeed.swift b/TestApp/Sources/Catalogs/Views/CatalogFeed.swift index 5cec6f39b..4532145d7 100644 --- a/TestApp/Sources/Catalogs/Views/CatalogFeed.swift +++ b/TestApp/Sources/Catalogs/Views/CatalogFeed.swift @@ -8,6 +8,7 @@ import ReadiumOPDS import ReadiumShared import SwiftUI +/// Screen of an actual catalog feed, second to x number in the stack since it can keep going to another catalog struct CatalogFeed: View { var catalog: Catalog @State private var parseData: ParseData? @@ -19,6 +20,7 @@ struct CatalogFeed: View { if !feed.navigation.isEmpty { ForEach(feed.navigation, id: \.self) { link in let navigationLink = Catalog(title: link.title ?? "Catalog", url: link.href) + /// We don't need to define the navigationDestination for this again because it will use the one in CatalogList NavigationLink(value: navigationLink) { ListRowItem(title: link.title!) } diff --git a/TestApp/Sources/Catalogs/Views/CatalogList.swift b/TestApp/Sources/Catalogs/Views/CatalogList.swift index 18b2f010d..3ef32e73e 100644 --- a/TestApp/Sources/Catalogs/Views/CatalogList.swift +++ b/TestApp/Sources/Catalogs/Views/CatalogList.swift @@ -7,6 +7,7 @@ import ReadiumOPDS import SwiftUI +/// Screen of list of catalog feeds, first in the stack struct CatalogList: View { let catalogRepository: CatalogRepository let catalogFeed: (Catalog) -> CatalogFeed @@ -21,6 +22,8 @@ struct CatalogList: View { VStack { List { ForEach(catalogs, id: \.id) { catalog in + /// Use the `value` argument for navigationDestination to use. + /// In this case, it is a catalog of type `Catalog`. See below navigationDestination comment. NavigationLink(value: catalog) { ListRowItem(title: catalog.title) } @@ -44,6 +47,9 @@ struct CatalogList: View { .listStyle(DefaultListStyle()) } .navigationTitle("Catalogs") + /// We define the different destinations here, which are applicable to everywhere in the stack. + /// Use the `for` argument to pass the type of data. This should match what is being passed in NavigationLink. + /// In the first case below, it is of type `Catalog`, the same as NavigationLink above. .navigationDestination(for: Catalog.self) { catalog in catalogFeed(catalog) } diff --git a/TestApp/Sources/Catalogs/Views/PublicationDetail.swift b/TestApp/Sources/Catalogs/Views/PublicationDetail.swift index 3a842e9ea..2bc932eaf 100644 --- a/TestApp/Sources/Catalogs/Views/PublicationDetail.swift +++ b/TestApp/Sources/Catalogs/Views/PublicationDetail.swift @@ -7,6 +7,7 @@ import ReadiumShared import SwiftUI +/// Screen of the publication detail, last in the stack struct PublicationDetail: View { @State var opdsPublication: OPDSPublication diff --git a/TestApp/Sources/Data/Catalog.swift b/TestApp/Sources/Data/Catalog.swift index c690dcc08..6f192b3e5 100644 --- a/TestApp/Sources/Data/Catalog.swift +++ b/TestApp/Sources/Data/Catalog.swift @@ -38,18 +38,21 @@ final class CatalogRepository { self.db = db } + /// Get all saved OPDS catalogs func all() -> AnyPublisher<[Catalog]?, Error> { db.observe { try Catalog.order(Catalog.Columns.title).fetchAll($0) } } + /// Save an OPDS catalog func save(_ catalog: inout Catalog) async throws { catalog = try await db.write { [catalog] db in try catalog.saved(db) } } + /// Delete an OPDS catalog func delete(ids: [Catalog.Id]) async throws { try await db.write { db in try Catalog.deleteAll(db, ids: ids) diff --git a/TestApp/Sources/TestApp.swift b/TestApp/Sources/TestApp.swift index c0311493e..aff4bca12 100644 --- a/TestApp/Sources/TestApp.swift +++ b/TestApp/Sources/TestApp.swift @@ -8,6 +8,7 @@ import GRDB import SwiftUI // @main +/// The main function and serves as the app's entry struct TestApp: App { let container = try! Container() From 14e72fe3552adc30105c8cfebc77c69aab5cb5a9 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Mon, 22 Jul 2024 21:53:54 -0500 Subject: [PATCH 13/16] Minor changes --- TestApp/Sources/About/Views/About.swift | 42 +++++++++---------- .../Sources/Bookshelf/Views/Bookshelf.swift | 2 +- TestApp/Sources/Views/BookCover.swift | 12 ++++-- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/TestApp/Sources/About/Views/About.swift b/TestApp/Sources/About/Views/About.swift index 4b135d773..bbdb43c2f 100644 --- a/TestApp/Sources/About/Views/About.swift +++ b/TestApp/Sources/About/Views/About.swift @@ -8,30 +8,28 @@ import SwiftUI struct About: View { var body: some View { - NavigationStack { - VStack(alignment: .leading, spacing: 20) { - Text("Version") - .font(.title2) - HStack(spacing: 10) { - Text("Version").frame(width: 170.0, alignment: .leading) - Text("2.3.0") - } - HStack(spacing: 10) { - Text("Build").frame(width: 170.0, alignment: .leading) - Text("1") - } - Text("Copyright").font(.title2) - Link("© 2022 European Digital Reading Lab", - destination: URL(string: "https://www.edrlab.org/")!) - Link("[BSD-3 License]", - destination: URL(string: "https://opensource.org/licenses/BSD-3-Clause")!) - Text("Acknowledgements").font(.title2) - Text("R2 Reader wouldn't have been developed without the financial help of the French State.") - Image("rf") + VStack(alignment: .leading, spacing: 20) { + Text("Version") + .font(.title2) + HStack(spacing: 10) { + Text("Version").frame(width: 170.0, alignment: .leading) + Text("2.3.0") } - .padding() - .navigationTitle("About") + HStack(spacing: 10) { + Text("Build").frame(width: 170.0, alignment: .leading) + Text("1") + } + Text("Copyright").font(.title2) + Link("© 2022 European Digital Reading Lab", + destination: URL(string: "https://www.edrlab.org/")!) + Link("[BSD-3 License]", + destination: URL(string: "https://opensource.org/licenses/BSD-3-Clause")!) + Text("Acknowledgements").font(.title2) + Text("R2 Reader wouldn't have been developed without the financial help of the French State.") + Image("rf") } + .padding() + .navigationTitle("About") } } diff --git a/TestApp/Sources/Bookshelf/Views/Bookshelf.swift b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift index fa2e9b82a..100495a28 100644 --- a/TestApp/Sources/Bookshelf/Views/Bookshelf.swift +++ b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift @@ -16,7 +16,7 @@ struct Bookshelf: View { NavigationStack { VStack { // TODO: figure out what the best column layout is for phones and tablets - let columns: [GridItem] = [GridItem(.adaptive(minimum: 150 + 8))] + let columns: [GridItem] = [GridItem(.adaptive(minimum: Constant.bookCoverWidth + Constant.adaptiveGridDelta))] ScrollView { LazyVGrid(columns: columns, spacing: 20) { ForEach(books, id: \.self) { book in diff --git a/TestApp/Sources/Views/BookCover.swift b/TestApp/Sources/Views/BookCover.swift index 11feb75de..f2e70cc42 100644 --- a/TestApp/Sources/Views/BookCover.swift +++ b/TestApp/Sources/Views/BookCover.swift @@ -14,11 +14,10 @@ struct BookCover: View { var body: some View { VStack { - let width: CGFloat = 150 cover - .frame(width: width, height: 220, alignment: .bottom) + .frame(width: Constant.bookCoverWidth, height: Constant.bookCoverHeight, alignment: .bottom) labels - .frame(width: width, alignment: .topLeading) + .frame(width: Constant.bookCoverWidth, alignment: .topLeading) } } @@ -37,6 +36,7 @@ struct BookCover: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } ) + .onTapGesture(perform: action) } else { Image(systemName: "book.closed") .resizable() @@ -67,3 +67,9 @@ struct BookCover_Previews: PreviewProvider { BookCover(title: book.title, authors: book.authors) } } + +enum Constant { + static let bookCoverWidth: Double = 130 + static let bookCoverHeight: Double = 200 + static let adaptiveGridDelta: Double = 8 +} From c37ddf93c1ce0d3216468fcaf46fefe8205c701b Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Wed, 24 Jul 2024 21:38:49 -0500 Subject: [PATCH 14/16] Basic reader view started --- .../Sources/Bookshelf/Views/Bookshelf.swift | 8 ++++- TestApp/Sources/Reader/Views/Reader.swift | 31 +++++++++++++++++++ TestApp/Sources/Views/BookCover.swift | 2 -- 3 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 TestApp/Sources/Reader/Views/Reader.swift diff --git a/TestApp/Sources/Bookshelf/Views/Bookshelf.swift b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift index 100495a28..be15ba524 100644 --- a/TestApp/Sources/Bookshelf/Views/Bookshelf.swift +++ b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift @@ -20,7 +20,10 @@ struct Bookshelf: View { ScrollView { LazyVGrid(columns: columns, spacing: 20) { ForEach(books, id: \.self) { book in - BookCover(title: book.title, authors: book.authors, url: book.cover?.url) + NavigationLink(value: book) { + BookCover(title: book.title, authors: book.authors, url: book.cover?.url) + } + .buttonStyle(.plain) } } // TODO: handle error @@ -32,6 +35,9 @@ struct Bookshelf: View { } } .navigationTitle("Bookshelf") + .navigationDestination(for: Book.self) { book in + Reader(book: book) + } .toolbar(content: toolbarContent) } .sheet(isPresented: $showingSheet) { diff --git a/TestApp/Sources/Reader/Views/Reader.swift b/TestApp/Sources/Reader/Views/Reader.swift new file mode 100644 index 000000000..faf8baccc --- /dev/null +++ b/TestApp/Sources/Reader/Views/Reader.swift @@ -0,0 +1,31 @@ +// +// Copyright 2024 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 SwiftUI + +struct Reader: View { + @State private var isFullScreen = true + + var book: Book + + var body: some View { + VStack { + Text("Reader") + .font(.title2) + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .toolbar(.hidden, for: .tabBar) + .toolbar(isFullScreen ? .hidden : .visible, for: .navigationBar) + .statusBar(hidden: isFullScreen) + .onTapGesture { + withAnimation { + isFullScreen.toggle() + } + } + .edgesIgnoringSafeArea(.all) + } +} diff --git a/TestApp/Sources/Views/BookCover.swift b/TestApp/Sources/Views/BookCover.swift index f2e70cc42..a74f0dc62 100644 --- a/TestApp/Sources/Views/BookCover.swift +++ b/TestApp/Sources/Views/BookCover.swift @@ -10,7 +10,6 @@ struct BookCover: View { var title: String var authors: String? var url: URL? - var action: () -> Void = {} var body: some View { VStack { @@ -36,7 +35,6 @@ struct BookCover: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } ) - .onTapGesture(perform: action) } else { Image(systemName: "book.closed") .resizable() From 733aa733099bc6f04485fa9c4c94489d14c47801 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Sun, 28 Jul 2024 20:40:27 -0500 Subject: [PATCH 15/16] Add in more reader code Co-Authored-By: gatamar <1910505+gatamar@users.noreply.github.com> --- .../Views/NewReaderViewController.swift | 20 ++++ .../Sources/Reader/Views/ReaderService.swift | 98 +++++++++++++++++++ .../Reader/Views/ReaderViewModel.swift | 69 +++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 TestApp/Sources/Reader/Views/NewReaderViewController.swift create mode 100644 TestApp/Sources/Reader/Views/ReaderService.swift create mode 100644 TestApp/Sources/Reader/Views/ReaderViewModel.swift diff --git a/TestApp/Sources/Reader/Views/NewReaderViewController.swift b/TestApp/Sources/Reader/Views/NewReaderViewController.swift new file mode 100644 index 000000000..1a42cb3dd --- /dev/null +++ b/TestApp/Sources/Reader/Views/NewReaderViewController.swift @@ -0,0 +1,20 @@ +// +// Copyright 2024 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 SwiftUI + +struct NewReaderViewController: UIViewControllerRepresentable { + let makeReaderVCFunc: () -> UIViewController + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + + } + + func makeUIViewController(context: Context) -> UIViewController { + return makeReaderVCFunc() + } +} diff --git a/TestApp/Sources/Reader/Views/ReaderService.swift b/TestApp/Sources/Reader/Views/ReaderService.swift new file mode 100644 index 000000000..c2de3f129 --- /dev/null +++ b/TestApp/Sources/Reader/Views/ReaderService.swift @@ -0,0 +1,98 @@ +// +// Copyright 2024 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 ReadiumNavigator +import ReadiumShared +import ReadiumStreamer +import UIKit + +typealias ReaderViewControllerType = UIViewController & Navigator + +class ReaderService { + let bookmarks: BookmarkRepository + let highlights: HighlightRepository + let makeReaderVCFunc: (Publication, Book, NavigatorDelegate) -> ReaderViewControllerType + let drmLibraryServices: [DRMLibraryService] + let streamer: Streamer + let httpClient: HTTPClient + + init(bookmarks: BookmarkRepository, + highlights: HighlightRepository, + makeReaderVCFunc: @escaping (Publication, Book, NavigatorDelegate) -> ReaderViewControllerType, + drmLibraryServices: [DRMLibraryService], + streamer: Streamer, + httpClient: HTTPClient + ) { + self.bookmarks = bookmarks + self.highlights = highlights + self.makeReaderVCFunc = makeReaderVCFunc + self.drmLibraryServices = drmLibraryServices + self.streamer = streamer + self.httpClient = httpClient + } + + func openBook(_ book: Book, sender: UIViewController) async throws -> Publication { + let (pub, _) = try await openPublication(at: book.url(), allowUserInteraction: true, sender: sender) + try checkIsReadable(publication: pub) + return pub + } + + /// Opens the Readium 2 Publication at the given `url`. + private func openPublication(at url: FileURL, allowUserInteraction: Bool, sender: UIViewController?) async throws -> (Publication, MediaType) { + let asset = FileAsset(file: url) + guard let mediaType = asset.mediaType() else { + throw LibraryError.openFailed(Publication.OpeningError.unsupportedFormat) + } + + return try await withCheckedThrowingContinuation { cont in + streamer.open(asset: asset, allowUserInteraction: allowUserInteraction, sender: sender) { result in + switch result { + case let .success(publication): + cont.resume(returning: (publication, mediaType)) + case let .failure(error): + cont.resume(throwing: LibraryError.openFailed(error)) + case .cancelled: + cont.resume(throwing: LibraryError.cancelled) + } + } + } + } + + /// Checks if the publication is not still locked by a DRM. + private func checkIsReadable(publication: Publication) throws { + guard !publication.isRestricted else { + if let error = publication.protectionError { + throw LibraryError.openFailed(error) + } else { + throw LibraryError.cancelled + } + } + } +} + +private extension Book { + func url() throws -> FileURL { + guard let url = AnyURL(string: path) else { + throw LibraryError.bookNotFound + } + + switch url { + case let .absolute(url): + guard let url = url.fileURL else { + throw LibraryError.bookNotFound + } + return url + + case let .relative(relativeURL): + // Path relative to Documents/. + guard let url = Paths.documents.resolve(relativeURL) else { + throw LibraryError.bookNotFound + } + return url + } + } +} diff --git a/TestApp/Sources/Reader/Views/ReaderViewModel.swift b/TestApp/Sources/Reader/Views/ReaderViewModel.swift new file mode 100644 index 000000000..582e09bc1 --- /dev/null +++ b/TestApp/Sources/Reader/Views/ReaderViewModel.swift @@ -0,0 +1,69 @@ +// +// Copyright 2024 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 ReadiumNavigator +import ReadiumShared +import WebKit + +class ReaderViewModel: ObservableObject { + let book: Book + let publication: Publication + let readerService: ReaderService + + @Published var positionLabelText: String = "" + @Published var navigator: Navigator! + + private var bookId: Book.Id { + book.id! + } + + init(book: Book, publication: Publication, readerService: ReaderService) { + self.book = book + self.publication = publication + self.readerService = readerService + } + + func makeReaderVCFunc() -> UIViewController { + let result = readerService.makeReaderVCFunc(publication, book, self) + self.navigator = result + // TODO: become a delegate of a specific Format implementation + return result + } +} + +extension ReaderViewModel: PDFNavigatorDelegate, EPUBNavigatorDelegate, CBZNavigatorDelegate {} + +extension ReaderViewModel: NavigatorDelegate { + func navigator(_ navigator: any ReadiumNavigator.Navigator, didFailToLoadResourceAt href: ReadiumShared.RelativeURL, withError error: ReadiumShared.ResourceError) { + + } + + func navigator(_ navigator: Navigator, presentError error: NavigatorError) { + + } + + func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { + positionLabelText = { + if let position = locator.locations.position { + return "\(position) / \(publication.positions.count)" + } else if let progression = locator.locations.totalProgression { + return "\(progression)%" + } else { + return "" + } + }() + } +} + +extension ReaderViewModel: VisualNavigatorDelegate { + func navigator(_ navigator: VisualNavigator, didTapAt point: CGPoint) { + // clear a current search highlight + if let decorator = self.navigator as? DecorableNavigator { + decorator.apply(decorations: [], in: "search") + } + } +} From 350059f34420e69b086e6190219eed4517e41850 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Tue, 30 Jul 2024 22:03:31 -0500 Subject: [PATCH 16/16] Reader updates --- .../Sources/Bookshelf/Views/Bookshelf.swift | 4 +- TestApp/Sources/Container.swift | 72 ++++++++++++++++++- .../Views/NewReaderViewController.swift | 6 +- TestApp/Sources/Reader/Views/Reader.swift | 26 +++---- .../Sources/Reader/Views/ReaderService.swift | 54 ++++++++++++-- .../Reader/Views/ReaderViewModel.swift | 17 ++--- 6 files changed, 143 insertions(+), 36 deletions(-) diff --git a/TestApp/Sources/Bookshelf/Views/Bookshelf.swift b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift index be15ba524..f535749fd 100644 --- a/TestApp/Sources/Bookshelf/Views/Bookshelf.swift +++ b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift @@ -4,10 +4,12 @@ // available in the top-level LICENSE file of the project. // +import ReadiumShared import SwiftUI struct Bookshelf: View { let bookRepository: BookRepository + let reader: (Book) -> Reader @State private var showingSheet = false @State private var books: [Book] = [] @@ -36,7 +38,7 @@ struct Bookshelf: View { } .navigationTitle("Bookshelf") .navigationDestination(for: Book.self) { book in - Reader(book: book) + reader(book) } .toolbar(content: toolbarContent) } diff --git a/TestApp/Sources/Container.swift b/TestApp/Sources/Container.swift index 905fdc83b..3a3fa3c11 100644 --- a/TestApp/Sources/Container.swift +++ b/TestApp/Sources/Container.swift @@ -5,7 +5,11 @@ // import Foundation +import ReadiumAdapterGCDWebServer +import ReadiumNavigator import ReadiumShared +import ReadiumStreamer +import UIKit class Container { private let db: Database @@ -22,7 +26,32 @@ class Container { private lazy var bookRepository = BookRepository(db: db) func bookshelf() -> Bookshelf { - Bookshelf(bookRepository: bookRepository) + Bookshelf(bookRepository: bookRepository, reader: reader(with:)) + } + + // Reader + + lazy var readerService: ReaderService = { + var drmLibraryServices = [DRMLibraryService]() + #if LCP + drmLibraryServices.append(LCPLibraryService()) + #endif + + return ReaderService( + bookmarks: BookmarkRepository(db: db), + highlights: HighlightRepository(db: db), + makeReaderVCFunc: makeReaderVCFunc, + drmLibraryServices: drmLibraryServices, + streamer: Streamer( + contentProtections: drmLibraryServices.compactMap(\.contentProtection) + ), + httpClient: DefaultHTTPClient() + ) + }() + + func reader(with book: Book) -> Reader { + let viewModel = ReaderViewModel(book: book, readerService: readerService) + return Reader(viewModel: viewModel) } // Catalogs @@ -51,3 +80,44 @@ class Container { About() } } + +extension Container { + //TODO I don't know if this is the best spot for this code. I duplicated it in ReaderService where it might be better served. + func makeReaderVCFunc(for publication: Publication, book: Book, delegate: NavigatorDelegate) -> ReaderViewControllerType { + let locator = book.locator + let httpServer = GCDHTTPServer.shared + + do { + if publication.conforms(to: .pdf) { + let navigator = try PDFNavigatorViewController(publication: publication, initialLocation: locator, httpServer: httpServer) + navigator.delegate = delegate as? PDFNavigatorDelegate + return navigator + } + + if publication.conforms(to: .epub) || publication.readingOrder.allAreHTML { + guard publication.metadata.identifier != nil else { + fatalError("ReaderError.epubNotValid") + } + + let navigator = try EPUBNavigatorViewController(publication: publication, initialLocation: locator, httpServer: httpServer) + navigator.delegate = delegate as? EPUBNavigatorDelegate + return navigator + } + + if publication.conforms(to: .divina) { + let navigator = try CBZNavigatorViewController(publication: publication, initialLocation: locator, httpServer: httpServer) + navigator.delegate = delegate as? CBZNavigatorDelegate + return navigator + } + } catch { + fatalError("Failed: \(error)") + } + return StubNavigatorViewController() + } + + private class StubNavigatorViewController: UIViewController, Navigator { + var publication: ReadiumShared.Publication + + var currentLocation: Locator? + } +} diff --git a/TestApp/Sources/Reader/Views/NewReaderViewController.swift b/TestApp/Sources/Reader/Views/NewReaderViewController.swift index 1a42cb3dd..f618a058b 100644 --- a/TestApp/Sources/Reader/Views/NewReaderViewController.swift +++ b/TestApp/Sources/Reader/Views/NewReaderViewController.swift @@ -10,11 +10,9 @@ import SwiftUI struct NewReaderViewController: UIViewControllerRepresentable { let makeReaderVCFunc: () -> UIViewController - func updateUIViewController(_ uiViewController: UIViewController, context: Context) { - - } + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} func makeUIViewController(context: Context) -> UIViewController { - return makeReaderVCFunc() + makeReaderVCFunc() } } diff --git a/TestApp/Sources/Reader/Views/Reader.swift b/TestApp/Sources/Reader/Views/Reader.swift index faf8baccc..ae359ee95 100644 --- a/TestApp/Sources/Reader/Views/Reader.swift +++ b/TestApp/Sources/Reader/Views/Reader.swift @@ -9,23 +9,19 @@ import SwiftUI struct Reader: View { @State private var isFullScreen = true - - var book: Book + @ObservedObject var viewModel: ReaderViewModel var body: some View { - VStack { - Text("Reader") - .font(.title2) - } - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) - .toolbar(.hidden, for: .tabBar) - .toolbar(isFullScreen ? .hidden : .visible, for: .navigationBar) - .statusBar(hidden: isFullScreen) - .onTapGesture { - withAnimation { - isFullScreen.toggle() + NewReaderViewController(makeReaderVCFunc: viewModel.makeReaderVCFunc) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .toolbar(.hidden, for: .tabBar) + .toolbar(isFullScreen ? .hidden : .visible, for: .navigationBar) + .statusBar(hidden: isFullScreen) + .onTapGesture { + withAnimation { + isFullScreen.toggle() + } } - } - .edgesIgnoringSafeArea(.all) + .edgesIgnoringSafeArea(.all) } } diff --git a/TestApp/Sources/Reader/Views/ReaderService.swift b/TestApp/Sources/Reader/Views/ReaderService.swift index c2de3f129..a8669dc25 100644 --- a/TestApp/Sources/Reader/Views/ReaderService.swift +++ b/TestApp/Sources/Reader/Views/ReaderService.swift @@ -5,12 +5,13 @@ // import Foundation +import ReadiumAdapterGCDWebServer import ReadiumNavigator import ReadiumShared import ReadiumStreamer import UIKit -typealias ReaderViewControllerType = UIViewController & Navigator +typealias ReaderViewControllerType = Navigator & UIViewController class ReaderService { let bookmarks: BookmarkRepository @@ -19,14 +20,14 @@ class ReaderService { let drmLibraryServices: [DRMLibraryService] let streamer: Streamer let httpClient: HTTPClient - + init(bookmarks: BookmarkRepository, highlights: HighlightRepository, makeReaderVCFunc: @escaping (Publication, Book, NavigatorDelegate) -> ReaderViewControllerType, drmLibraryServices: [DRMLibraryService], streamer: Streamer, - httpClient: HTTPClient - ) { + httpClient: HTTPClient) + { self.bookmarks = bookmarks self.highlights = highlights self.makeReaderVCFunc = makeReaderVCFunc @@ -34,7 +35,7 @@ class ReaderService { self.streamer = streamer self.httpClient = httpClient } - + func openBook(_ book: Book, sender: UIViewController) async throws -> Publication { let (pub, _) = try await openPublication(at: book.url(), allowUserInteraction: true, sender: sender) try checkIsReadable(publication: pub) @@ -72,6 +73,49 @@ class ReaderService { } } } + + func makeReaderVCFunc(for publication: Publication, book: Book, delegate: NavigatorDelegate) -> ReaderViewControllerType { + let locator = book.locator + let httpServer = GCDHTTPServer.shared + + do { + if publication.conforms(to: .pdf) { + let navigator = try PDFNavigatorViewController(publication: publication, initialLocation: locator, httpServer: httpServer) + navigator.delegate = delegate as? PDFNavigatorDelegate + return navigator + } + + if publication.conforms(to: .epub) || publication.readingOrder.allAreHTML { + guard publication.metadata.identifier != nil else { + fatalError("ReaderError.epubNotValid") + } + + let navigator = try EPUBNavigatorViewController(publication: publication, initialLocation: locator, httpServer: httpServer) + navigator.delegate = delegate as? EPUBNavigatorDelegate + return navigator + } + + if publication.conforms(to: .divina) { + let navigator = try CBZNavigatorViewController(publication: publication, initialLocation: locator, httpServer: httpServer) + navigator.delegate = delegate as? CBZNavigatorDelegate + return navigator + } + } catch { + fatalError("Failed: \(error)") + } + return StubNavigatorViewController(coder: NSCoder())! + } + + private class StubNavigatorViewController: UIViewController, Navigator { + var publication: ReadiumShared.Publication + + var currentLocation: Locator? + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } } private extension Book { diff --git a/TestApp/Sources/Reader/Views/ReaderViewModel.swift b/TestApp/Sources/Reader/Views/ReaderViewModel.swift index 582e09bc1..fa6afc06a 100644 --- a/TestApp/Sources/Reader/Views/ReaderViewModel.swift +++ b/TestApp/Sources/Reader/Views/ReaderViewModel.swift @@ -11,7 +11,8 @@ import WebKit class ReaderViewModel: ObservableObject { let book: Book - let publication: Publication + // TODO: do we need publication in the VM? + let publication: Publication? let readerService: ReaderService @Published var positionLabelText: String = "" @@ -21,15 +22,15 @@ class ReaderViewModel: ObservableObject { book.id! } - init(book: Book, publication: Publication, readerService: ReaderService) { + init(book: Book, readerService: ReaderService) { self.book = book - self.publication = publication self.readerService = readerService } func makeReaderVCFunc() -> UIViewController { + // Where best to get the publication from the book via openBook, which is async. Here? let result = readerService.makeReaderVCFunc(publication, book, self) - self.navigator = result + navigator = result // TODO: become a delegate of a specific Format implementation return result } @@ -38,13 +39,9 @@ class ReaderViewModel: ObservableObject { extension ReaderViewModel: PDFNavigatorDelegate, EPUBNavigatorDelegate, CBZNavigatorDelegate {} extension ReaderViewModel: NavigatorDelegate { - func navigator(_ navigator: any ReadiumNavigator.Navigator, didFailToLoadResourceAt href: ReadiumShared.RelativeURL, withError error: ReadiumShared.ResourceError) { + func navigator(_ navigator: any ReadiumNavigator.Navigator, didFailToLoadResourceAt href: ReadiumShared.RelativeURL, withError error: ReadiumShared.ResourceError) {} - } - - func navigator(_ navigator: Navigator, presentError error: NavigatorError) { - - } + func navigator(_ navigator: Navigator, presentError error: NavigatorError) {} func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { positionLabelText = {