diff --git a/Views/BuildingBlocks/ArticleActions.swift b/Views/BuildingBlocks/ArticleActions.swift new file mode 100644 index 00000000..e220cb99 --- /dev/null +++ b/Views/BuildingBlocks/ArticleActions.swift @@ -0,0 +1,36 @@ +// This file is part of Kiwix for iOS & macOS. +// +// Kiwix is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// any later version. +// +// Kiwix is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kiwix; If not, see https://www.gnu.org/licenses/. + +import SwiftUI + +struct ArticleActions: View { + + let zimFileID: UUID + + var body: some View { + AsyncButton { + guard let url = await ZimFileService.shared.getMainPageURL(zimFileID: zimFileID) else { return } + NotificationCenter.openURL(url, inNewTab: true) + } label: { + Label(LocalString.library_zim_file_context_main_page_label, systemImage: "house") + } + AsyncButton { + guard let url = await ZimFileService.shared.getRandomPageURL(zimFileID: zimFileID) else { return } + NotificationCenter.openURL(url, inNewTab: true) + } label: { + Label(LocalString.library_zim_file_context_random_label, systemImage: "die.face.5") + } + } +} diff --git a/Views/BuildingBlocks/CopyPasteMenu.swift b/Views/BuildingBlocks/CopyPasteMenu.swift new file mode 100644 index 00000000..fe5cc061 --- /dev/null +++ b/Views/BuildingBlocks/CopyPasteMenu.swift @@ -0,0 +1,35 @@ +// This file is part of Kiwix for iOS & macOS. +// +// Kiwix is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// any later version. +// +// Kiwix is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kiwix; If not, see https://www.gnu.org/licenses/. + +import SwiftUI +import UniformTypeIdentifiers + +struct CopyPasteMenu: View { + + let downloadURL: URL + + var body: some View { + Button { + #if os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(downloadURL.absoluteString, forType: .string) + #elseif os(iOS) + UIPasteboard.general.setValue(downloadURL.absoluteString, forPasteboardType: UTType.url.identifier) + #endif + } label: { + Label(LocalString.library_zim_file_context_copy_url, systemImage: "doc.on.doc") + } + } +} diff --git a/Views/Library/Library.swift b/Views/Library/Library.swift index 15098b8a..99e52acb 100644 --- a/Views/Library/Library.swift +++ b/Views/Library/Library.swift @@ -112,70 +112,47 @@ struct LibraryZimFileDetailSidePanel: ViewModifier { /// On macOS, converts the modified view to a Button that modifies the currently selected zim file /// On iOS, converts the modified view to a NavigationLink that goes to the zim file detail. -struct LibraryZimFileContext: ViewModifier { +struct LibraryZimFileContext: View { @EnvironmentObject private var viewModel: LibraryViewModel - @EnvironmentObject private var navigation: NavigationViewModel - - let zimFile: ZimFile - let dismiss: (() -> Void)? // iOS only - - init(zimFile: ZimFile, dismiss: (() -> Void)?) { + + private let content: Content + private let zimFile: ZimFile + /// iOS only + private let dismiss: (() -> Void)? + + init( + @ViewBuilder content: () -> Content, + zimFile: ZimFile, + dismiss: (() -> Void)? = nil + ) { + self.content = content() self.zimFile = zimFile self.dismiss = dismiss } - - func body(content: Content) -> some View { + + var body: some View { Group { - #if os(macOS) +#if os(macOS) Button { viewModel.selectedZimFile = zimFile } label: { content }.buttonStyle(.plain) - #elseif os(iOS) +#elseif os(iOS) NavigationLink { ZimFileDetail(zimFile: zimFile, dismissParent: dismiss) } label: { content } - #endif +#endif }.contextMenu { if zimFile.fileURLBookmark != nil, !zimFile.isMissing { - Section { articleActions } + Section { ArticleActions(zimFileID: zimFile.fileID) } } - Section { supplementaryActions } - } - } - - @ViewBuilder - var articleActions: some View { - AsyncButton { - guard let url = await ZimFileService.shared.getMainPageURL(zimFileID: zimFile.fileID) else { return } - NotificationCenter.openURL(url, inNewTab: true) - } label: { - Label(LocalString.library_zim_file_context_main_page_label, systemImage: "house") - } - AsyncButton { - guard let url = await ZimFileService.shared.getRandomPageURL(zimFileID: zimFile.fileID) else { return } - NotificationCenter.openURL(url, inNewTab: true) - } label: { - Label(LocalString.library_zim_file_context_random_label, systemImage: "die.face.5") - } - } - - @ViewBuilder - var supplementaryActions: some View { - if let downloadURL = zimFile.downloadURL { - Button { - #if os(macOS) - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(downloadURL.absoluteString, forType: .string) - #elseif os(iOS) - UIPasteboard.general.setValue(downloadURL.absoluteString, forPasteboardType: UTType.url.identifier) - #endif - } label: { - Label(LocalString.library_zim_file_context_copy_url, systemImage: "doc.on.doc") + if let downloadURL = zimFile.downloadURL { + Section { CopyPasteMenu(downloadURL: downloadURL) } } } } + } diff --git a/Views/Library/ZimFilesCategories.swift b/Views/Library/ZimFilesCategories.swift index 25d716bd..4479fe6c 100644 --- a/Views/Library/ZimFilesCategories.swift +++ b/Views/Library/ZimFilesCategories.swift @@ -139,14 +139,18 @@ private struct CategoryGrid: View { ForEach(sections) { section in if sections.count <= 1 { ForEach(section) { zimFile in - ZimFileCell(zimFile, prominent: .size) - .modifier(LibraryZimFileContext(zimFile: zimFile, dismiss: dismiss)) + LibraryZimFileContext( + content: { ZimFileCell(zimFile, prominent: .size) }, + zimFile: zimFile, + dismiss: dismiss) } } else { Section { ForEach(section) { zimFile in - ZimFileCell(zimFile, prominent: .size) - .modifier(LibraryZimFileContext(zimFile: zimFile, dismiss: dismiss)) + LibraryZimFileContext( + content: { ZimFileCell(zimFile, prominent: .size) }, + zimFile: zimFile, + dismiss: dismiss) } } header: { SectionHeader( @@ -244,8 +248,10 @@ private struct CategoryList: View { } } else { List(zimFiles, id: \.self, selection: $viewModel.selectedZimFile) { zimFile in - ZimFileRow(zimFile) - .modifier(LibraryZimFileContext(zimFile: zimFile, dismiss: dismiss)) + LibraryZimFileContext( + content: { ZimFileRow(zimFile) }, + zimFile: zimFile, + dismiss: dismiss) } #if os(macOS) .listStyle(.inset) diff --git a/Views/Library/ZimFilesDownloads.swift b/Views/Library/ZimFilesDownloads.swift index 0e28a270..906cad83 100644 --- a/Views/Library/ZimFilesDownloads.swift +++ b/Views/Library/ZimFilesDownloads.swift @@ -35,10 +35,11 @@ struct ZimFilesDownloads: View { alignment: .leading, spacing: 12 ) { - ForEach(downloadTasks) { downloadTask in - if let zimFile = downloadTask.zimFile { - DownloadTaskCell(zimFile).modifier(LibraryZimFileContext(zimFile: zimFile, dismiss: dismiss)) - } + ForEach(downloadTasks.compactMap(\.zimFile)) { zimFile in + LibraryZimFileContext( + content: { DownloadTaskCell(zimFile) }, + zimFile: zimFile, + dismiss: dismiss) } } .modifier(GridCommon()) diff --git a/Views/Library/ZimFilesNew.swift b/Views/Library/ZimFilesNew.swift index 276fc68b..5b7baf0d 100644 --- a/Views/Library/ZimFilesNew.swift +++ b/Views/Library/ZimFilesNew.swift @@ -14,26 +14,93 @@ // along with Kiwix; If not, see https://www.gnu.org/licenses/. import SwiftUI - import Defaults +private final class ViewModel: ObservableObject { + + @Published private(set) var zimFiles: [ZimFile] = [] + + private var languageCodes = Set() + private var searchText: String = "" + + private let sortDescriptors = [ + NSSortDescriptor(keyPath: \ZimFile.created, ascending: false), + NSSortDescriptor(keyPath: \ZimFile.name, ascending: true), + NSSortDescriptor(keyPath: \ZimFile.size, ascending: false) + ] + + func update(languageCodes: Set) { + guard languageCodes != self.languageCodes else { return } + self.languageCodes = languageCodes + Task { + await update() + } + } + + func update(searchText: String) { + guard searchText != self.searchText else { return } + self.searchText = searchText + Task { + await update() + } + } + + func update() async { + let searchText = self.searchText + let languageCodes = self.languageCodes + let newZimFiles: [ZimFile] = await withCheckedContinuation { continuation in + Database.shared.performBackgroundTask { context in + let predicate: NSPredicate = Self.buildPredicate( + searchText: searchText, + languageCodes: languageCodes + ) + if let results = try? context.fetch( + ZimFile.fetchRequest( + predicate: predicate, + sortDescriptors: self.sortDescriptors + ) + ) { + continuation.resume(returning: results) + } else { + continuation.resume(returning: []) + } + } + } + await MainActor.run { + withAnimation(.easeInOut) { + self.zimFiles = newZimFiles + } + } + } + + private static func buildPredicate(searchText: String, languageCodes: Set) -> NSPredicate { + var predicates = [ + NSPredicate(format: "languageCode IN %@", languageCodes), + NSPredicate(format: "requiresServiceWorkers == false") + ] + if let aMonthAgo = Calendar.current.date(byAdding: .month, value: -3, to: Date()) { + predicates.append(NSPredicate(format: "created > %@", aMonthAgo as CVarArg)) + } + if !searchText.isEmpty { + predicates.append( + NSCompoundPredicate(orPredicateWithSubpredicates: [ + NSPredicate(format: "name CONTAINS[cd] %@", searchText), + NSPredicate(format: "fileDescription CONTAINS[cd] %@", searchText) + ]) + ) + } + return NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + } + +} + /// A grid of zim files that are newly available. struct ZimFilesNew: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @EnvironmentObject var viewModel: LibraryViewModel + @EnvironmentObject var library: LibraryViewModel @Default(.libraryLanguageCodes) private var languageCodes - @FetchRequest( - sortDescriptors: [ - NSSortDescriptor(keyPath: \ZimFile.created, ascending: false), - NSSortDescriptor(keyPath: \ZimFile.name, ascending: true), - NSSortDescriptor(keyPath: \ZimFile.size, ascending: false) - ], - animation: .easeInOut - ) private var zimFiles: FetchedResults + @StateObject private var viewModel = ViewModel() @State private var searchText = "" - private var filterPredicate: NSPredicate { - ZimFilesNew.buildPredicate(searchText: searchText) - } let dismiss: (() -> Void)? // iOS only var body: some View { @@ -42,9 +109,14 @@ struct ZimFilesNew: View { alignment: .leading, spacing: 12 ) { - ForEach(zimFiles.filter { filterPredicate.evaluate(with: $0) }) { zimFile in - ZimFileCell(zimFile, prominent: .name) - .modifier(LibraryZimFileContext(zimFile: zimFile, dismiss: dismiss)) + ForEach(viewModel.zimFiles, id: \.fileID) { zimFile in + LibraryZimFileContext( + content: { + ZimFileCell(zimFile, prominent: .name) + }, + zimFile: zimFile, + dismiss: dismiss) + .transition(AnyTransition.opacity) } } .modifier(GridCommon()) @@ -52,11 +124,19 @@ struct ZimFilesNew: View { .navigationTitle(NavigationItem.new.name) .searchable(text: $searchText) .onAppear { - viewModel.start(isUserInitiated: false) + viewModel.update(searchText: searchText) + viewModel.update(languageCodes: languageCodes) + library.start(isUserInitiated: false) + } + .onChange(of: searchText) { newSearchText in + viewModel.update(searchText: newSearchText) + } + .onChange(of: languageCodes) { newLanguageCodes in + viewModel.update(languageCodes: newLanguageCodes) } .overlay { - if zimFiles.isEmpty { - switch viewModel.state { + if viewModel.zimFiles.isEmpty { + switch library.state { case .inProgress: Message(text: LocalString.zim_file_catalog_fetching_message) case .error: @@ -80,14 +160,14 @@ struct ZimFilesNew: View { } #endif ToolbarItem { - if viewModel.state == .inProgress { + if library.state == .inProgress { ProgressView() #if os(macOS) .scaleEffect(0.5) #endif } else { Button { - viewModel.start(isUserInitiated: true) + library.start(isUserInitiated: true) } label: { Label(LocalString.zim_file_new_button_refresh, systemImage: "arrow.triangle.2.circlepath.circle") @@ -96,25 +176,6 @@ struct ZimFilesNew: View { } } } - - private static func buildPredicate(searchText: String) -> NSPredicate { - var predicates = [ - NSPredicate(format: "languageCode IN %@", Defaults[.libraryLanguageCodes]), - NSPredicate(format: "requiresServiceWorkers == false") - ] - if let aMonthAgo = Calendar.current.date(byAdding: .month, value: -3, to: Date()) { - predicates.append(NSPredicate(format: "created > %@", aMonthAgo as CVarArg)) - } - if !searchText.isEmpty { - predicates.append( - NSCompoundPredicate(orPredicateWithSubpredicates: [ - NSPredicate(format: "name CONTAINS[cd] %@", searchText), - NSPredicate(format: "fileDescription CONTAINS[cd] %@", searchText) - ]) - ) - } - return NSCompoundPredicate(andPredicateWithSubpredicates: predicates) - } } @available(macOS 13.0, iOS 16.0, *) diff --git a/Views/Library/ZimFilesOpened.swift b/Views/Library/ZimFilesOpened.swift index c9812333..51c7c06c 100644 --- a/Views/Library/ZimFilesOpened.swift +++ b/Views/Library/ZimFilesOpened.swift @@ -35,8 +35,10 @@ struct ZimFilesOpened: View { spacing: 12 ) { ForEach(zimFiles) { zimFile in - ZimFileCell(zimFile, prominent: .name).modifier(LibraryZimFileContext(zimFile: zimFile, - dismiss: self.dismiss)) + LibraryZimFileContext( + content: { ZimFileCell(zimFile, prominent: .name) }, + zimFile: zimFile, + dismiss: dismiss) } } .modifier(GridCommon(edges: .all))