diff --git a/TestApp/Sources/Views/About/About.swift b/TestApp/Sources/About/Views/About.swift similarity index 98% rename from TestApp/Sources/Views/About/About.swift rename to TestApp/Sources/About/Views/About.swift index a33a073f5..8a2d485e8 100644 --- a/TestApp/Sources/Views/About/About.swift +++ b/TestApp/Sources/About/Views/About.swift @@ -29,6 +29,7 @@ struct About: View { 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/Views/Bookshelf/AddBookSheet.swift b/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift similarity index 100% rename from TestApp/Sources/Views/Bookshelf/AddBookSheet.swift rename to TestApp/Sources/Bookshelf/Views/AddBookSheet.swift diff --git a/TestApp/Sources/Views/Bookshelf/Bookshelf.swift b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift similarity index 67% rename from TestApp/Sources/Views/Bookshelf/Bookshelf.swift rename to TestApp/Sources/Bookshelf/Views/Bookshelf.swift index 5dbb981fc..09fe26118 100644 --- a/TestApp/Sources/Views/Bookshelf/Bookshelf.swift +++ b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift @@ -8,23 +8,27 @@ import SwiftUI struct Bookshelf: View { - @ObservedObject var viewModel: BookshelfViewModel + 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 - if let books = viewModel.books { - let columns: [GridItem] = Array(repeating: .init(.adaptive(minimum: 170)), count: 2) - ScrollView { - LazyVGrid(columns: columns, spacing: 20) { - ForEach(books, id: \.self) { item in - BookCover(book: item) - } + 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) } } + .onReceive(bookRepository.all()) { + books = $0 + } } + } .navigationTitle("Bookshelf") .toolbar(content: toolbarContent) diff --git a/TestApp/Sources/Views/Catalogs/Catalogs/AddFeedSheet.swift b/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift similarity index 100% rename from TestApp/Sources/Views/Catalogs/Catalogs/AddFeedSheet.swift rename to TestApp/Sources/Catalogs/Views/AddFeedSheet.swift diff --git a/TestApp/Sources/Catalogs/Views/CatalogFeed.swift b/TestApp/Sources/Catalogs/Views/CatalogFeed.swift new file mode 100644 index 000000000..f13c83339 --- /dev/null +++ b/TestApp/Sources/Catalogs/Views/CatalogFeed.swift @@ -0,0 +1,102 @@ +// +// Copyright 2022 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 +import R2Shared +import ReadiumOPDS + +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 { $0.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 [R2Shared.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) { + self.parseData = try? await OPDSParser.parseURL(url: url) + } + } +} + +// FIXME this causes a Swift compiler error segmentation fault 11 + +//struct CatalogDetail_Previews: PreviewProvider { +// static var previews: some View { +// let catalog = Catalog(title: "Test", url: "https://www.test.com") +// let catalogDetail: (Catalog) -> CatalogDetail = { CatalogDetail(CatalogDetailViewModel(catalog: catalog)) } +// CatalogDetail(viewModel: CatalogDetailViewModel(catalog: catalog), catalogDetail: catalogDetail) +// } +//} + +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..553156c06 --- /dev/null +++ b/TestApp/Sources/Catalogs/Views/CatalogGroup.swift @@ -0,0 +1,63 @@ +// +// Copyright 2022 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 +import R2Shared + +struct CatalogGroup: View { + + var group: R2Shared.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 { $0.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/Views/Catalogs/Catalogs/Catalogs.swift b/TestApp/Sources/Catalogs/Views/CatalogList.swift similarity index 54% rename from TestApp/Sources/Views/Catalogs/Catalogs/Catalogs.swift rename to TestApp/Sources/Catalogs/Views/CatalogList.swift index b65f0a48d..9f22e1762 100644 --- a/TestApp/Sources/Views/Catalogs/Catalogs/Catalogs.swift +++ b/TestApp/Sources/Catalogs/Views/CatalogList.swift @@ -7,27 +7,36 @@ import SwiftUI import ReadiumOPDS -struct Catalogs: View { +struct CatalogList: View { - @ObservedObject var viewModel: CatalogsViewModel - let catalogDetail: (Catalog) -> CatalogDetail + 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 { - if let catalogs = viewModel.catalogs { - List() { - ForEach(catalogs, id: \.id) { catalog in - NavigationLink(destination: catalogDetail(catalog)) { - ListRowItem(title: catalog.title) - } + List() { + ForEach(catalogs, id: \.id) { catalog in + NavigationLink(destination: catalogFeed(catalog)) { + ListRowItem(title: catalog.title) } } - .listStyle(DefaultListStyle()) + .onDelete { offsets in + let catalogIds = offsets.map { catalogs[$0].id! } + Task { + try await deleteCatalogs(ids: catalogIds) + } + } + } + .onReceive(catalogRepository.all()) { + catalogs = $0 ?? [] } + .listStyle(DefaultListStyle()) + } .navigationTitle("Catalogs") .toolbar(content: toolbarContent) @@ -38,7 +47,7 @@ struct Catalogs: View { Task { do { _ = try await OPDSParser.parseURL(url: URL(string: url)!) - try await viewModel.addCatalog(catalog: Catalog(title: title, url: url)) + try await addCatalog(catalog: Catalog(title: title, url: url)) } catch { showingAlert = true } @@ -61,3 +70,15 @@ struct Catalogs: View { } } } + +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..b2776f2ab --- /dev/null +++ b/TestApp/Sources/Catalogs/Views/PublicationDetail.swift @@ -0,0 +1,54 @@ +// +// Copyright 2022 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 +import R2Shared + +struct PublicationDetail: View { + + @State var publication: Publication + + var body: some View { + let authors = publication.metadata.authors + .map { $0.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/R2Shared.swift b/TestApp/Sources/Common/Toolkit/Extensions/R2Shared.swift new file mode 100644 index 000000000..23d2bd6c6 --- /dev/null +++ b/TestApp/Sources/Common/Toolkit/Extensions/R2Shared.swift @@ -0,0 +1,10 @@ +// +// Copyright 2022 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 R2Shared + +extension R2Shared.Publication: Identifiable {} +extension R2Shared.Group : Identifiable {} diff --git a/TestApp/Sources/Container.swift b/TestApp/Sources/Container.swift index 1cc05b6a4..53d75f30f 100644 --- a/TestApp/Sources/Container.swift +++ b/TestApp/Sources/Container.swift @@ -5,6 +5,7 @@ // import Foundation +import R2Shared class Container { @@ -22,22 +23,29 @@ class Container { private lazy var bookRepository = BookRepository(db: db) func bookshelf() -> Bookshelf { - Bookshelf(viewModel: BookshelfViewModel(bookRepository: bookRepository)) + Bookshelf(bookRepository: bookRepository) } // Catalogs private lazy var catalogRepository = CatalogRepository(db: db) - func catalogs() -> Catalogs { - Catalogs( - viewModel: CatalogsViewModel(catalogRepository: catalogRepository), - catalogDetail: catalogDetail(with:) + func catalogs() -> CatalogList { + CatalogList( + catalogRepository: catalogRepository, + catalogFeed: catalogFeed(with:) ) } - func catalogDetail(with catalog: Catalog) -> CatalogDetail { - CatalogDetail(viewModel: CatalogDetailViewModel(catalog: catalog)) + 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 diff --git a/TestApp/Sources/Data/Catalog.swift b/TestApp/Sources/Data/Catalog.swift index 6bf6b2bf6..ff57758e4 100644 --- a/TestApp/Sources/Data/Catalog.swift +++ b/TestApp/Sources/Data/Catalog.swift @@ -49,4 +49,10 @@ final class CatalogRepository { 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/Views/BookCover.swift b/TestApp/Sources/Views/BookCover.swift index 81511e1fd..d4127b65f 100644 --- a/TestApp/Sources/Views/BookCover.swift +++ b/TestApp/Sources/Views/BookCover.swift @@ -7,23 +7,56 @@ import SwiftUI struct BookCover: View { - var book: Book + var title: String + var authors: String? + var url: URL? + var action: () -> Void = {} + var body: some View { - VStack(alignment: .leading, spacing: 5) { - if let url = book.cover { - AsyncImage( - url: url, - content: { $0 - .resizable() - .aspectRatio(contentMode: .fit) - }, - placeholder: { ProgressView() } - ) - } else { - Image(systemName: "book.closed") - } - Text(book.title) - Text(book.authors ?? "") + 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) } } } @@ -31,6 +64,6 @@ struct BookCover: View { 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(book: book) + BookCover(title: book.title, authors: book.authors) } } diff --git a/TestApp/Sources/Views/Bookshelf/BookshelfViewModel.swift b/TestApp/Sources/Views/Bookshelf/BookshelfViewModel.swift deleted file mode 100644 index e4a62b230..000000000 --- a/TestApp/Sources/Views/Bookshelf/BookshelfViewModel.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Copyright 2022 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 Combine -import Foundation - -final class BookshelfViewModel: ObservableObject { - - @Published var books: [Book]? - private var bookRepository: BookRepository - - init(bookRepository: BookRepository) { - self.bookRepository = bookRepository - } -} diff --git a/TestApp/Sources/Views/Button.swift b/TestApp/Sources/Views/Button.swift index 5dbad84c8..94c4812b5 100644 --- a/TestApp/Sources/Views/Button.swift +++ b/TestApp/Sources/Views/Button.swift @@ -10,6 +10,7 @@ enum ButtonKind { case add case cancel case save + case download } @ViewBuilder @@ -23,5 +24,9 @@ func Button(_ kind: ButtonKind, action: @escaping () -> Void) -> some View { 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/Catalogs/CatalogDetail/CatalogDetail.swift b/TestApp/Sources/Views/Catalogs/CatalogDetail/CatalogDetail.swift deleted file mode 100644 index e5c45a606..000000000 --- a/TestApp/Sources/Views/Catalogs/CatalogDetail/CatalogDetail.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Copyright 2022 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 CatalogDetail: View { - - @ObservedObject var viewModel: CatalogDetailViewModel - - var body: some View { - - VStack { - if let parseData = viewModel.parseData { - List(parseData.feed!.navigation, id: \.self) { link in - // NavigationLink(destination: CatalogDetail()) { - ListRowItem(title: link.title!) - // } - } - .listStyle(DefaultListStyle()) - } - } - .navigationTitle(viewModel.catalog.title) - .navigationBarTitleDisplayMode(.inline) - .onAppear { - Task { - await viewModel.parseFeed() - } - } - } -} - -struct CatalogDetail_Previews: PreviewProvider { - static var previews: some View { - let catalog = Catalog(title: "Test", url: "https://www.test.com") - CatalogDetail(viewModel: CatalogDetailViewModel(catalog: catalog)) - } -} diff --git a/TestApp/Sources/Views/Catalogs/CatalogDetail/CatalogDetailViewModel.swift b/TestApp/Sources/Views/Catalogs/CatalogDetail/CatalogDetailViewModel.swift deleted file mode 100644 index d2199babe..000000000 --- a/TestApp/Sources/Views/Catalogs/CatalogDetail/CatalogDetailViewModel.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Copyright 2022 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 SwiftUI - -final class CatalogDetailViewModel : ObservableObject { - - @Published var catalog: Catalog - @Published var parseData: ParseData? - - init(catalog: Catalog) { - self.catalog = catalog - } - - @MainActor func parseFeed() async { - if let url = URL(string: catalog.url) { - self.parseData = try? await OPDSParser.parseURL(url: url) - } - } -} diff --git a/TestApp/Sources/Views/Catalogs/Catalogs/CatalogsViewModel.swift b/TestApp/Sources/Views/Catalogs/Catalogs/CatalogsViewModel.swift deleted file mode 100644 index 52cb86bdf..000000000 --- a/TestApp/Sources/Views/Catalogs/Catalogs/CatalogsViewModel.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Copyright 2022 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 Combine -import Foundation - -final class CatalogsViewModel: ObservableObject { - - @Published var catalogs: [Catalog]? - private var catalogRepository: CatalogRepository - - init(catalogRepository: CatalogRepository) { - self.catalogRepository = catalogRepository - catalogRepository.all() - .assign(to: &$catalogs) - } - - func addCatalog(catalog: Catalog) async throws { - var savedCatalog = catalog - try? await catalogRepository.save(&savedCatalog) - } -}