diff --git a/App/App_iOS.swift b/App/App_iOS.swift index 325cf5aa0..6754ee8cf 100644 --- a/App/App_iOS.swift +++ b/App/App_iOS.swift @@ -12,6 +12,9 @@ import UserNotifications #if os(iOS) @main struct Kiwix: App { + @Environment(\.scenePhase) private var scenePhase + @StateObject private var library = LibraryViewModel() + @StateObject private var navigation = NavigationViewModel() @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate private let fileMonitor: DirectoryMonitor @@ -29,7 +32,24 @@ struct Kiwix: App { var body: some Scene { WindowGroup { - RootView().environment(\.managedObjectContext, Database.viewContext) + RootView() + .ignoresSafeArea() + .environment(\.managedObjectContext, Database.viewContext) + .environmentObject(library) + .environmentObject(navigation) + .modifier(AlertHandler()) + .modifier(OpenFileHandler()) + .onChange(of: scenePhase) { newValue in + guard newValue == .inactive else { return } + try? Database.viewContext.save() + } + .onOpenURL { url in + if url.isFileURL { + NotificationCenter.openFiles([url], context: .file) + } else if url.scheme == "kiwix" { + NotificationCenter.openURL(url) + } + } } .commands { CommandGroup(replacing: .undoRedo) { @@ -55,77 +75,26 @@ struct Kiwix: App { withCompletionHandler completionHandler: @escaping () -> Void) { if let zimFileID = UUID(uuidString: response.notification.request.identifier), let mainPageURL = ZimFileService.shared.getMainPageURL(zimFileID: zimFileID) { - UIApplication.shared.open(mainPageURL) + NotificationCenter.openURL(mainPageURL, inNewTab: true) } completionHandler() } + + /// Purge some cached browser view models when receiving memory warning + func applicationDidReceiveMemoryWarning(_ application: UIApplication) { + BrowserViewModel.purgeCache() + } } } -struct RootView: View { - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @Environment(\.scenePhase) private var scenePhase - @StateObject private var library = LibraryViewModel() - @StateObject private var navigation = NavigationViewModel() +private struct RootView: UIViewControllerRepresentable { + @EnvironmentObject private var navigation: NavigationViewModel - private let primaryItems: [NavigationItem] = [.bookmarks, .settings] - private let libraryItems: [NavigationItem] = [.opened, .categories, .downloads, .new] - private let openURL = NotificationCenter.default.publisher(for: .openURL) + func makeUIViewController(context: Context) -> SplitViewController { + SplitViewController(navigationViewModel: navigation) + } - var body: some View { - Group { - if #available(iOS 16.0, *) { - if horizontalSizeClass == .regular { - RegularView() - } else { - ContainerView { - CompactView() - } - .ignoresSafeArea() - .onAppear() { - navigation.navigateToMostRecentTab() - } - } - } else { - ContainerView { - LegacyView() - }.ignoresSafeArea() - } - } - .focusedSceneValue(\.navigationItem, $navigation.currentItem) - .environmentObject(library) - .environmentObject(navigation) - .modifier(AlertHandler()) - .modifier(ExternalLinkHandler()) - .modifier(OpenFileHandler()) - .onChange(of: scenePhase) { newScenePhase in - guard newScenePhase == .inactive else { return } - WebViewCache.shared.persistStates() - } - .onOpenURL { url in - if url.isFileURL { - NotificationCenter.openFiles([url], context: .file) - } else if url.scheme == "kiwix" { - NotificationCenter.openURL(url) - } - } - .onReceive(openURL) { notification in - guard let url = notification.userInfo?["url"] as? URL else { return } - let inNewTab = notification.userInfo?["inNewTab"] as? Bool ?? false - if #available(iOS 16.0, *) { - if inNewTab { - let tabID = navigation.createTab() - WebViewCache.shared.getWebView(tabID: tabID).load(URLRequest(url: url)) - } else if case let .tab(tabID) = navigation.currentItem { - WebViewCache.shared.getWebView(tabID: tabID).load(URLRequest(url: url)) - } else { - let tabID = navigation.createTab() - WebViewCache.shared.getWebView(tabID: tabID).load(URLRequest(url: url)) - } - } else { - WebViewCache.shared.webView.load(URLRequest(url: url)) - } - } + func updateUIViewController(_ controller: SplitViewController, context: Context) { } } #endif diff --git a/App/App_macOS.swift b/App/App_macOS.swift index a85f06902..ce6efe2cf 100644 --- a/App/App_macOS.swift +++ b/App/App_macOS.swift @@ -79,11 +79,13 @@ struct Kiwix: App { } struct RootView: View { + @Environment(\.controlActiveState) var controlActiveState + @StateObject private var browser = BrowserViewModel() @StateObject private var navigation = NavigationViewModel() private let primaryItems: [NavigationItem] = [.reading, .bookmarks] private let libraryItems: [NavigationItem] = [.opened, .categories, .downloads, .new] - private let openURL = NotificationCenter.default.publisher(for: Notification.Name.openURL) + private let openURL = NotificationCenter.default.publisher(for: .openURL) var body: some View { NavigationView { @@ -108,7 +110,7 @@ struct RootView: View { } switch navigation.currentItem { case .reading: - ReadingView() + BrowserTab().environmentObject(browser) case .bookmarks: Bookmarks() case .opened: @@ -127,7 +129,6 @@ struct RootView: View { .focusedSceneValue(\.navigationItem, $navigation.currentItem) .environmentObject(navigation) .modifier(AlertHandler()) - .modifier(ExternalLinkHandler()) .modifier(OpenFileHandler()) .onOpenURL { url in if url.isFileURL { @@ -137,8 +138,8 @@ struct RootView: View { } } .onReceive(openURL) { notification in - guard let url = notification.userInfo?["url"] as? URL else { return } - WebViewCache.shared.webView.load(URLRequest(url: url)) + guard controlActiveState == .key, let url = notification.userInfo?["url"] as? URL else { return } + browser.load(url: url) navigation.currentItem = .reading } } diff --git a/App/CompactView.swift b/App/CompactView.swift deleted file mode 100644 index cc2185f69..000000000 --- a/App/CompactView.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// CompactView.swift -// Kiwix -// -// Created by Chris Li on 8/16/23. -// Copyright © 2023 Chris Li. All rights reserved. -// - -import SwiftUI - -#if os(iOS) -@available(iOS 16.0, *) -struct CompactView: View { - @EnvironmentObject private var navigation: NavigationViewModel - @EnvironmentObject private var search: SearchViewModel - @StateObject private var browser = BrowserViewModel() - - var body: some View { - Group { - if search.isSearching { - SearchResults() - } else if case let .tab(tabID) = navigation.currentItem, browser.url != nil { - WebView(tabID: tabID).ignoresSafeArea().id(tabID) - } else { - Welcome() - } - } - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - if search.isSearching { - Button("Cancel") { - search.isSearching = false - } - } - } - ToolbarItemGroup(placement: .bottomBar) { - if !search.isSearching { - NavigationButtons() - Spacer() - OutlineButton() - Spacer() - BookmarkButton() - Spacer() - RandomArticleButton() - Spacer() - TabsManagerButton() - } - } - } - .environmentObject(browser) - .focusedSceneValue(\.browserViewModel, browser) - .focusedSceneValue(\.canGoBack, browser.canGoBack) - .focusedSceneValue(\.canGoForward, browser.canGoForward) - .onAppear { - guard case let .tab(tabID) = navigation.currentItem else { return } - browser.configure(tabID: tabID) - } - .onChange(of: navigation.currentItem) { navigationItem in - guard case let .tab(tabID) = navigation.currentItem else { return } - browser.configure(tabID: tabID) - } - } -} -#endif diff --git a/App/CompactViewController.swift b/App/CompactViewController.swift new file mode 100644 index 000000000..9cb0a84e8 --- /dev/null +++ b/App/CompactViewController.swift @@ -0,0 +1,142 @@ +// +// CompactViewController.swift +// Kiwix +// +// Created by Chris Li on 9/4/23. +// Copyright © 2023 Chris Li. All rights reserved. +// + +#if os(iOS) +import Combine +import SwiftUI +import UIKit + +class CompactViewController: UIHostingController, UISearchControllerDelegate, UISearchResultsUpdating { + private let searchViewModel: SearchViewModel + private let searchController: UISearchController + private var searchTextObserver: AnyCancellable? + private var openURLObserver: NSObjectProtocol? + + init() { + searchViewModel = SearchViewModel() + let searchResult = SearchResults().environmentObject(searchViewModel) + searchController = UISearchController(searchResultsController: UIHostingController(rootView: searchResult)) + super.init(rootView: AnyView(CompactView())) + searchController.searchResultsUpdater = self + } + + @MainActor required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + definesPresentationContext = true + navigationController?.isToolbarHidden = false + navigationController?.toolbar.scrollEdgeAppearance = { + let apperance = UIToolbarAppearance() + apperance.configureWithDefaultBackground() + return apperance + }() + navigationItem.scrollEdgeAppearance = { + let apperance = UINavigationBarAppearance() + apperance.configureWithDefaultBackground() + return apperance + }() + navigationItem.titleView = searchController.searchBar + searchController.automaticallyShowsCancelButton = false + searchController.delegate = self + searchController.hidesNavigationBarDuringPresentation = false + searchController.showsSearchResultsController = true + + searchTextObserver = searchViewModel.$searchText.sink { [weak self] searchText in + guard self?.searchController.searchBar.text != searchText else { return } + self?.searchController.searchBar.text = searchText + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + openURLObserver = NotificationCenter.default.addObserver( + forName: .openURL, object: nil, queue: nil + ) { [weak self] _ in + self?.searchController.isActive = false + self?.navigationItem.setRightBarButton(nil, animated: true) + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + NotificationCenter.default.removeObserver(self) + } + + func willPresentSearchController(_ searchController: UISearchController) { + navigationController?.setToolbarHidden(true, animated: true) + navigationItem.setRightBarButton( + UIBarButtonItem(systemItem: .cancel, primaryAction: UIAction { [unowned self] _ in + searchController.isActive = false + navigationItem.setRightBarButton(nil, animated: true) + }), animated: true + ) + } + + func willDismissSearchController(_ searchController: UISearchController) { + navigationController?.setToolbarHidden(false, animated: true) + searchViewModel.searchText = "" + } + + func updateSearchResults(for searchController: UISearchController) { + searchViewModel.searchText = searchController.searchBar.text ?? "" + } +} + +private struct CompactView: View { + @EnvironmentObject private var navigation: NavigationViewModel + + var body: some View { + if case let .tab(tabID) = navigation.currentItem { + Content().id(tabID).toolbar { + ToolbarItemGroup(placement: .bottomBar) { + HStack { + NavigationButtons() + Spacer() + OutlineButton() + Spacer() + BookmarkButton() + Spacer() + ArticleShortcutButtons(displayMode: .randomArticle) + Spacer() + TabsManagerButton() + } + } + } + .environmentObject(BrowserViewModel.getCached(tabID: tabID)) + } + } +} + +private struct Content: View { + @EnvironmentObject private var browser: BrowserViewModel + + var body: some View { + Group { + if browser.url == nil { + Welcome() + } else { + WebView().ignoresSafeArea() + } + } + .focusedSceneValue(\.browserViewModel, browser) + .focusedSceneValue(\.canGoBack, browser.canGoBack) + .focusedSceneValue(\.canGoForward, browser.canGoForward) + .modifier(ExternalLinkHandler()) + .onAppear { + browser.updateLastOpened() + } + .onDisappear { + browser.persistState() + } + } +} +#endif diff --git a/App/ContainerView.swift b/App/ContainerView.swift deleted file mode 100644 index 07d58e4de..000000000 --- a/App/ContainerView.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// ContainerView.swift -// Kiwix -// -// Created by Chris Li on 8/16/23. -// Copyright © 2023 Chris Li. All rights reserved. -// - -import SwiftUI - -#if os(iOS) -struct ContainerView: UIViewControllerRepresentable { - @StateObject private var search = SearchViewModel() - - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - func makeUIViewController(context: Context) -> UINavigationController { - let controller = UIHostingController(rootView: self.content.environmentObject(search)) - controller.navigationItem.scrollEdgeAppearance = { - let apperance = UINavigationBarAppearance() - apperance.configureWithDefaultBackground() - return apperance - }() - controller.navigationItem.titleView = context.coordinator.searchBar - let navigation = UINavigationController(rootViewController: controller) - navigation.isToolbarHidden = false - navigation.toolbar.scrollEdgeAppearance = { - let apperance = UIToolbarAppearance() - apperance.configureWithDefaultBackground() - return apperance - }() - return navigation - } - - func updateUIViewController(_ navigationController: UINavigationController, context: Context) { - if search.isSearching { - DispatchQueue.main.async { - context.coordinator.searchBar.text = search.searchText - } - } else { - DispatchQueue.main.async { - context.coordinator.searchBar.resignFirstResponder() - } - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(view: self) - } - - class Coordinator: NSObject, UISearchBarDelegate { - let view: ContainerView - let searchBar = UISearchBar() - - init(view: ContainerView) { - self.view = view - searchBar.autocorrectionType = .no - searchBar.autocapitalizationType = .none - searchBar.placeholder = "Search" - searchBar.searchBarStyle = .minimal - super.init() - searchBar.delegate = self - } - - func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { - view.search.isSearching = true - } - - func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { - searchBar.text = "" - view.search.isSearching = false - view.search.searchText = "" - } - - func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - guard view.search.searchText != searchText else { return } - view.search.searchText = searchText - } - } -} -#endif diff --git a/App/LegacyView.swift b/App/LegacyView.swift deleted file mode 100644 index d7d2bb80e..000000000 --- a/App/LegacyView.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// LegacyView.swift -// Kiwix -// -// Created by Chris Li on 8/3/23. -// Copyright © 2023 Chris Li. All rights reserved. -// - -import SwiftUI - -#if os(iOS) -struct LegacyView: View { - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @EnvironmentObject private var navigation: NavigationViewModel - @EnvironmentObject private var search: SearchViewModel - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \ZimFile.size, ascending: false)], - predicate: ZimFile.openedPredicate - ) private var zimFiles: FetchedResults - @State private var presentedSheet: PresentedSheet? - @StateObject private var browser = BrowserViewModel() - - enum PresentedSheet: String, Identifiable { - var id: String { rawValue } - case library, settings - } - - var body: some View { - Group { - if search.isSearching { - SearchResults() - } else if browser.url == nil { - Welcome() - } else { - WebView(tabID: nil).ignoresSafeArea() - } - } - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - if horizontalSizeClass == .regular, !search.isSearching { - NavigationButtons() - OutlineButton() - BookmarkButton() - } - } - ToolbarItemGroup(placement: .navigationBarTrailing) { - if horizontalSizeClass == .regular, !search.isSearching { - RandomArticleButton() - MainArticleButton() - Button { - presentedSheet = .library - } label: { - Label("Library", systemImage: "folder") - } - Button { - presentedSheet = .settings - } label: { - Label("Settings", systemImage: "gear") - } - } else if search.isSearching { - Button("Cancel") { - search.isSearching = false - } - } - } - ToolbarItemGroup(placement: .bottomBar) { - if horizontalSizeClass == .compact, !search.isSearching { - NavigationButtons() - Spacer() - OutlineButton() - Spacer() - BookmarkButton() - Spacer() - RandomArticleButton() - Spacer() - Menu { - Section { - ForEach(zimFiles) { zimFile in - Button { - guard let url = ZimFileService.shared.getMainPageURL(zimFileID: zimFile.id) else { return } - browser.load(url: url) - } label: { - Label(zimFile.name, systemImage: "house") - } - } - } - Button { - presentedSheet = .library - } label: { - Label("Library", systemImage: "folder") - } - Button { - presentedSheet = .settings - } label: { - Label("Settings", systemImage: "gear") - } - } label: { - Label("More Actions", systemImage: "ellipsis.circle") - } - } - } - } - .environmentObject(browser) - .focusedSceneValue(\.browserViewModel, browser) - .focusedSceneValue(\.canGoBack, browser.canGoBack) - .focusedSceneValue(\.canGoForward, browser.canGoForward) - .onAppear { - browser.configure(tabID: nil) - } - .onChange(of: browser.url) { _ in - search.isSearching = false - presentedSheet = nil - } - .sheet(item: $presentedSheet) { presentedSheet in - switch presentedSheet { - case .library: - Library() - case .settings: - SheetContent { Settings() } - } - } - } -} -#endif diff --git a/App/ReadingView.swift b/App/ReadingView.swift deleted file mode 100644 index ccb50941e..000000000 --- a/App/ReadingView.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// ReadingView.swift -// Kiwix -// -// Created by Chris Li on 8/13/23. -// Copyright © 2023 Chris Li. All rights reserved. -// - -import SwiftUI - -#if os(macOS) -struct ReadingView: View { - @EnvironmentObject private var navigation: NavigationViewModel - @StateObject private var browser = BrowserViewModel() - @StateObject private var search = SearchViewModel() - - var body: some View { - Content().toolbar { - ToolbarItemGroup(placement: .navigation) { NavigationButtons() } - ToolbarItemGroup(placement: .primaryAction) { - OutlineButton() - BookmarkButton() - RandomArticleButton() - MainArticleButton() - } - } - .environmentObject(browser) - .environmentObject(search) - .focusedSceneValue(\.browserViewModel, browser) - .focusedSceneValue(\.canGoBack, browser.canGoBack) - .focusedSceneValue(\.canGoForward, browser.canGoForward) - .searchable(text: $search.searchText, placement: .toolbar) - .navigationTitle(browser.articleTitle.isEmpty ? "Kiwix" : browser.articleTitle) - .navigationSubtitle(browser.zimFileName) - .onAppear { - browser.configure(tabID: nil) - } - } - - struct Content: View { - @Environment(\.isSearching) private var isSearching - @EnvironmentObject private var browser: BrowserViewModel - - var body: some View { - Group { - if browser.url == nil { - Welcome() - } else { - WebView().ignoresSafeArea() - } - }.overlay { - if isSearching { - GeometryReader { proxy in - SearchResults().environment(\.horizontalSizeClass, proxy.size.width > 700 ? .regular : .compact) - } - } - } - } - } -} -#endif diff --git a/App/RegularView.swift b/App/RegularView.swift deleted file mode 100644 index 2e8e770eb..000000000 --- a/App/RegularView.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// RegularView.swift -// Kiwix -// -// Created by Chris Li on 7/1/23. -// Copyright © 2023 Chris Li. All rights reserved. -// - -import SwiftUI - -#if os(iOS) -@available(iOS 16.0, *) -struct RegularView: View { - @EnvironmentObject private var navigation: NavigationViewModel - - private let primaryItems: [NavigationItem] = [.bookmarks, .settings] - private let libraryItems: [NavigationItem] = [.opened, .categories, .downloads, .new] - - var body: some View { - NavigationSplitView { - List(selection: $navigation.currentItem) { - ForEach(primaryItems, id: \.self) { navigationItem in - Label(navigationItem.name, systemImage: navigationItem.icon) - } - Section("Tabs") { - TabsSectionContent() - } - Section("Library") { - ForEach(libraryItems, id: \.self) { navigationItem in - Label(navigationItem.name, systemImage: navigationItem.icon) - } - } - } - .navigationTitle("Kiwix") - .toolbar { NewTabButton() } - } detail: { - NavigationStack { content.navigationBarTitleDisplayMode(.inline).toolbarRole(.browser) } - } - } - - @ViewBuilder - @available(iOS 16.0, *) - private var content: some View { - switch navigation.currentItem { - case .bookmarks: - Bookmarks() - case .settings: - Settings() - case .tab: - RegularTab() - case .opened: - ZimFilesOpened() - case .categories: - ZimFilesCategories() - case .downloads: - ZimFilesDownloads() - case .new: - ZimFilesNew() - default: - EmptyView() - } - } -} - -@available(iOS 16.0, *) -private struct RegularTab: View { - @EnvironmentObject private var navigation: NavigationViewModel - @StateObject private var browser = BrowserViewModel() - @StateObject private var search = SearchViewModel() - - var body: some View { - Content().toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { NavigationButtons() } - ToolbarItemGroup(placement: .primaryAction) { - OutlineButton() - BookmarkButton() - RandomArticleButton() - MainArticleButton() - } - } - .environmentObject(browser) - .environmentObject(search) - .focusedSceneValue(\.browserViewModel, browser) - .focusedSceneValue(\.canGoBack, browser.canGoBack) - .focusedSceneValue(\.canGoForward, browser.canGoForward) - .searchable(text: $search.searchText, placement: .toolbar) - .navigationBarTitle(browser.articleTitle) // avoid _UIModernBarButton related constraint error - .navigationBarTitleDisplayMode(.inline) - .toolbarRole(.browser) - .toolbarBackground(.visible, for: .navigationBar) - .modifier(ExternalLinkHandler()) - .onAppear { - guard case let .tab(tabID) = navigation.currentItem else { return } - browser.configure(tabID: tabID) - } - .onChange(of: navigation.currentItem) { navigationItem in - guard case let .tab(tabID) = navigation.currentItem else { return } - browser.configure(tabID: tabID) - } - } - - struct Content: View { - @Environment(\.isSearching) private var isSearching - @EnvironmentObject private var browser: BrowserViewModel - @EnvironmentObject private var navigation: NavigationViewModel - - var body: some View { - Group { - if case let .tab(tabID) = navigation.currentItem, browser.url != nil { - WebView(tabID: tabID).ignoresSafeArea().id(tabID) - } else { - Welcome() - } - }.overlay { - if isSearching { - GeometryReader { proxy in - SearchResults().environment(\.horizontalSizeClass, proxy.size.width > 700 ? .regular : .compact) - } - } - } - } - } -} -#endif diff --git a/App/SidebarViewController.swift b/App/SidebarViewController.swift new file mode 100644 index 000000000..836850f3a --- /dev/null +++ b/App/SidebarViewController.swift @@ -0,0 +1,213 @@ +// +// SidebarViewController.swift +// Kiwix +// +// Created by Chris Li on 9/4/23. +// Copyright © 2023 Chris Li. All rights reserved. +// + +#if os(iOS) +import CoreData +import SwiftUI +import UIKit + +class SidebarViewController: UICollectionViewController, NSFetchedResultsControllerDelegate { + private lazy var dataSource = { + let cellRegistration = UICollectionView.CellRegistration { + [unowned self] cell, indexPath, item in + configureCell(cell: cell, indexPath: indexPath, item: item) + } + let headerRegistration = UICollectionView.SupplementaryRegistration( + elementKind: UICollectionView.elementKindSectionHeader + ) { [unowned self] headerView, elementKind, indexPath in + configureHeader(headerView: headerView, elementKind: elementKind, indexPath: indexPath) + } + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { + collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) + } + dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in + collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath) + } + return dataSource + }() + private let fetchedResultController = NSFetchedResultsController( + fetchRequest: Tab.fetchRequest(sortDescriptors: [NSSortDescriptor(key: "created", ascending: false)]), + managedObjectContext: Database.viewContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + enum Section: String, CaseIterable { + case primary + case tabs + case library + case settings + } + + init() { + super.init(collectionViewLayout: UICollectionViewLayout()) + collectionView.collectionViewLayout = UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in + var config = UICollectionLayoutListConfiguration(appearance: .sidebar) + config.headerMode = .supplementary + config.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in + configureSwipeAction(indexPath: indexPath) + } + let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: layoutEnvironment) + return section + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateSelection() { + guard let splitViewController = splitViewController as? SplitViewController, + let currentItem = splitViewController.navigationViewModel.currentItem, + let indexPath = dataSource.indexPath(for: currentItem), + collectionView.indexPathsForSelectedItems?.first != indexPath else { return } + collectionView.selectItem(at: indexPath, animated: true, scrollPosition: []) + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + fetchedResultController.delegate = self + + // configure view + navigationItem.title = "Kiwix" + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "plus.square"), + primaryAction: UIAction { [unowned self] _ in + guard let splitViewController = splitViewController as? SplitViewController else { return } + splitViewController.navigationViewModel.createTab() + }, + menu: UIMenu(children: [ + UIAction( + title: "Close This Tab", + image: UIImage(systemName: "xmark.square"), + attributes: .destructive + ) { [unowned self] _ in + guard let splitViewController = splitViewController as? SplitViewController, + case let .tab(tabID) = splitViewController.navigationViewModel.currentItem else { return } + splitViewController.navigationViewModel.deleteTab(tabID: tabID) + }, + UIAction( + title: "Close All Tabs", + image: UIImage(systemName: "xmark.square.fill"), + attributes: .destructive + ) { [unowned self] _ in + guard let splitViewController = splitViewController as? SplitViewController else { return } + splitViewController.navigationViewModel.deleteAllTabs() + } + ]) + ) + + // apply initial snapshot + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections(Section.allCases) + snapshot.appendItems([.bookmarks], toSection: .primary) + snapshot.appendItems([.opened, .categories, .downloads, .new], toSection: .library) + snapshot.appendItems([.settings], toSection: .settings) + dataSource.apply(snapshot, animatingDifferences: false) + try? fetchedResultController.performFetch() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.navigationBar.prefersLargeTitles = true + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + updateSelection() + } + + // MARK: - Delegations + + func controller(_ controller: NSFetchedResultsController, + didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + let tabs = snapshot.itemIdentifiers + .compactMap { $0 as? NSManagedObjectID } + .map { NavigationItem.tab(objectID: $0) } + var snapshot = NSDiffableDataSourceSectionSnapshot() + snapshot.append(tabs) + dataSource.apply(snapshot, to: .tabs, animatingDifferences: dataSource.snapshot(for: .tabs).items.count > 0) { + // [iOS 15] when a tab is selected, reload it to refresh title and icon + guard #unavailable(iOS 16), + let indexPath = self.collectionView.indexPathsForSelectedItems?.first, + let item = self.dataSource.itemIdentifier(for: indexPath), + case .tab = item else { return } + var snapshot = self.dataSource.snapshot() + snapshot.reconfigureItems([item]) + self.dataSource.apply(snapshot, animatingDifferences: true) + } + } + + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let splitViewController = splitViewController as? SplitViewController, + let navigationItem = dataSource.itemIdentifier(for: indexPath) else { return } + splitViewController.navigationViewModel.currentItem = navigationItem + if splitViewController.displayMode == .oneOverSecondary { + splitViewController.hide(.primary) + } + } + + override func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool { + return false + } + + // MARK: - Collection View Configuration + + private func configureCell(cell: UICollectionViewListCell, indexPath: IndexPath, item: NavigationItem) { + if case let .tab(objectID) = item, let tab = try? Database.viewContext.existingObject(with: objectID) as? Tab { + if #available(iOS 16.0, *) { + cell.contentConfiguration = UIHostingConfiguration { + TabLabel(tab: tab) + } + } else { + var config = cell.defaultContentConfiguration() + config.text = tab.title ?? item.name + config.textProperties.numberOfLines = 1 + config.image = UIImage(systemName: item.icon) + cell.contentConfiguration = config + } + } else { + var config = cell.defaultContentConfiguration() + config.text = item.name + config.image = UIImage(systemName: item.icon) + cell.contentConfiguration = config + } + + } + + private func configureHeader(headerView: UICollectionViewListCell, elementKind: String, indexPath: IndexPath) { + let section = Section.allCases[indexPath.section] + switch section { + case .tabs: + var config = UIListContentConfiguration.sidebarHeader() + config.text = "Tabs" + headerView.contentConfiguration = config + case .library: + var config = UIListContentConfiguration.sidebarHeader() + config.text = "Library" + headerView.contentConfiguration = config + default: + headerView.contentConfiguration = nil + } + } + + private func configureSwipeAction(indexPath: IndexPath) -> UISwipeActionsConfiguration? { + guard let splitViewController = splitViewController as? SplitViewController, + let item = dataSource.itemIdentifier(for: indexPath), + case let .tab(tabID) = item else { return nil } + let action = UIContextualAction(style: .destructive, title: "Close") { _, _, _ in + splitViewController.navigationViewModel.deleteTab(tabID: tabID) + } + action.image = UIImage(systemName: "xmark") + return UISwipeActionsConfiguration(actions: [action]) + } +} +#endif diff --git a/App/SplitViewController.swift b/App/SplitViewController.swift new file mode 100644 index 000000000..1e8300320 --- /dev/null +++ b/App/SplitViewController.swift @@ -0,0 +1,136 @@ +// +// SplitViewController.swift +// Kiwix +// +// Created by Chris Li on 9/4/23. +// Copyright © 2023 Chris Li. All rights reserved. +// + +#if os(iOS) +import Combine +import SwiftUI +import UIKit + +class SplitViewController: UISplitViewController { + let navigationViewModel: NavigationViewModel + private var navigationItemObserver: AnyCancellable? + private var openURLObserver: NSObjectProtocol? + private var toggleSidebarObserver: NSObjectProtocol? + + init(navigationViewModel: NavigationViewModel) { + self.navigationViewModel = navigationViewModel + super.init(style: .doubleColumn) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + if #available(iOS 16.0, *) {} else { + presentsWithGesture = false + } + + // setup controllers + setViewController(UINavigationController(rootViewController: CompactViewController()), for: .compact) + setViewController(SidebarViewController(), for: .primary) + setSecondaryController() + + // observers + navigationItemObserver = navigationViewModel.$currentItem + .receive(on: DispatchQueue.main) // needed to postpones sink after navigationViewModel.currentItem updates + .dropFirst() + .sink { [weak self] _ in + if let sidebarViewController = self?.viewController(for: .primary) as? SidebarViewController { + sidebarViewController.updateSelection() + } + if self?.traitCollection.horizontalSizeClass == .regular { + self?.setSecondaryController() + } + } + openURLObserver = NotificationCenter.default.addObserver( + forName: .openURL, object: nil, queue: nil + ) { [weak self] notification in + guard let url = notification.userInfo?["url"] as? URL else { return } + let inNewTab = notification.userInfo?["inNewTab"] as? Bool ?? false + if !inNewTab, case let .tab(tabID) = self?.navigationViewModel.currentItem { + BrowserViewModel.getCached(tabID: tabID).load(url: url) + } else { + guard let tabID = self?.navigationViewModel.createTab() else { return } + BrowserViewModel.getCached(tabID: tabID).load(url: url) + } + } + toggleSidebarObserver = NotificationCenter.default.addObserver( + forName: .toggleSidebar, object: nil, queue: nil + ) { [weak self] _ in + if #available(iOS 16.0, *) {} else { + if self?.displayMode == .secondaryOnly { + self?.show(.primary) + } else { + self?.hide(.primary) + } + } + } + } + + /// Dismiss any controller that is already presented when horizontal size class is about to change + override func willTransition(to newCollection: UITraitCollection, + with coordinator: UIViewControllerTransitionCoordinator) { + super.willTransition(to: newCollection, with: coordinator) + guard newCollection.horizontalSizeClass != traitCollection.horizontalSizeClass else { return } + presentedViewController?.dismiss(animated: false) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass else { return } + if traitCollection.horizontalSizeClass == .compact { + navigationViewModel.navigateToMostRecentTab() + } else { + setSecondaryController() + } + } + + private func setSecondaryController() { + switch navigationViewModel.currentItem { + case .bookmarks: + let controller = UIHostingController(rootView: Bookmarks()) + setViewController(UINavigationController(rootViewController: controller), for: .secondary) + case .tab(let tabID): + let view = BrowserTab().environmentObject(BrowserViewModel.getCached(tabID: tabID)) + let controller = UIHostingController(rootView: view) + controller.navigationItem.scrollEdgeAppearance = { + let apperance = UINavigationBarAppearance() + apperance.configureWithDefaultBackground() + return apperance + }() + setViewController(UINavigationController(rootViewController: controller), for: .secondary) + case .opened: + let controller = UIHostingController(rootView: ZimFilesOpened()) + setViewController(UINavigationController(rootViewController: controller), for: .secondary) + case .categories: + let controller = UIHostingController(rootView: ZimFilesCategories()) + setViewController(UINavigationController(rootViewController: controller), for: .secondary) + case .downloads: + let controller = UIHostingController(rootView: ZimFilesDownloads()) + setViewController(UINavigationController(rootViewController: controller), for: .secondary) + case .new: + let controller = UIHostingController(rootView: ZimFilesNew()) + setViewController(UINavigationController(rootViewController: controller), for: .secondary) + case .settings: + let controller = UIHostingController(rootView: Settings()) + setViewController(UINavigationController(rootViewController: controller), for: .secondary) + default: + let controller = UIHostingController(rootView: Text("Not yet implemented")) + setViewController(UINavigationController(rootViewController: controller), for: .secondary) + } + } +} +#endif diff --git a/CHANGELOG.md b/CHANGELOG.md index 8943eb4dd..5b81dac49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## 3.2 - Browse with multiple tabs on iOS and iPadOS -- On iPadOS, the app use sidebar to facilitate navigation between different tabs and functionalities +- On iPadOS, table of contents is now displayed as popover instead of menu - Dropped support for iOS 14 & iPadOS 14 ## 3.1.1 diff --git a/Kiwix.xcodeproj/project.pbxproj b/Kiwix.xcodeproj/project.pbxproj index d224eb185..36f20b6e0 100644 --- a/Kiwix.xcodeproj/project.pbxproj +++ b/Kiwix.xcodeproj/project.pbxproj @@ -8,20 +8,15 @@ /* Begin PBXBuildFile section */ 97008ABD2974A5BF0076E60C /* OPDSParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97008ABC2974A5BF0076E60C /* OPDSParserTests.swift */; }; - 970258B528FE418A00B68E84 /* BookmarkOperations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970258B328FE418A00B68E84 /* BookmarkOperations.swift */; }; 9709C0982A8E4C5700E4564C /* Commands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9709C0972A8E4C5700E4564C /* Commands.swift */; }; 97121EBE28849F0000371AEB /* ZimFileMissingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97121EBC28849F0000371AEB /* ZimFileMissingIndicator.swift */; }; 97176AD22A4FBD710093E3B0 /* BrowserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97176AD12A4FBD710093E3B0 /* BrowserViewModel.swift */; }; - 97176AD42A506B3B0093E3B0 /* RegularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97176AD32A506B3B0093E3B0 /* RegularView.swift */; }; 9721BBB72841C16D005C910D /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97FB4B0A27B819A90055F86E /* Message.swift */; }; 9721BBBB28427A93005C910D /* Bookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9721BBB928427A93005C910D /* Bookmarks.swift */; }; 9724FC3028D5F5BE001B7DD2 /* BookmarkContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9724FC2E28D5F5BE001B7DD2 /* BookmarkContextMenu.swift */; }; 972727AA2A89122F00BCAF75 /* App_macOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 972727A92A89122F00BCAF75 /* App_macOS.swift */; }; - 972727AC2A891B9000BCAF75 /* ReadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 972727AB2A891B9000BCAF75 /* ReadingView.swift */; }; 972727AE2A897FAA00BCAF75 /* GridSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 972727AD2A897FAA00BCAF75 /* GridSection.swift */; }; 972727B12A898B9700BCAF75 /* NavigationButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 972727B02A898B9700BCAF75 /* NavigationButtons.swift */; }; - 972727B32A898BBF00BCAF75 /* RandomArticleButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 972727B22A898BBF00BCAF75 /* RandomArticleButton.swift */; }; - 972727B52A898BD600BCAF75 /* MainArticleButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 972727B42A898BD600BCAF75 /* MainArticleButton.swift */; }; 972727B72A898BF900BCAF75 /* OutlineButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 972727B62A898BF900BCAF75 /* OutlineButton.swift */; }; 972727B92A898C1A00BCAF75 /* BookmarkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 972727B82A898C1A00BCAF75 /* BookmarkButton.swift */; }; 972727BB2A89930600BCAF75 /* ExternalLinkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 972727BA2A89930600BCAF75 /* ExternalLinkHandler.swift */; }; @@ -36,6 +31,8 @@ 972DE4BC2814A5BE004FD9B9 /* OPDSParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9779A5A32456793500F6F6FF /* OPDSParser.swift */; }; 972DE4BD2814A5BE004FD9B9 /* OPDSParser.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9779A5A52456793500F6F6FF /* OPDSParser.mm */; }; 972DE4C42814AAAE004FD9B9 /* Library.swift in Sources */ = {isa = PBXBuildFile; fileRef = 972DE4C12814A961004FD9B9 /* Library.swift */; }; + 973206532AA25D07003A1A8F /* ViewModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 973206522AA25D07003A1A8F /* ViewModifiers.swift */; }; + 973206552AA28FF7003A1A8F /* TabsManagerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 973206542AA28FF7003A1A8F /* TabsManagerButton.swift */; }; 97341C6E2852248500BC273E /* DownloadTaskCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97341C6C2852248500BC273E /* DownloadTaskCell.swift */; }; 973A0DE4281D80E300B41E71 /* ZimFileDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 973A0DE2281D80E300B41E71 /* ZimFileDetail.swift */; }; 973A0DE7281DC8F400B41E71 /* DownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 973A0DE5281DC8F400B41E71 /* DownloadService.swift */; }; @@ -48,8 +45,6 @@ 9744068728CE263800916BD4 /* DirectoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9779A5CF2456796A00F6F6FF /* DirectoryMonitor.swift */; }; 9744068A28CF65AE00916BD4 /* LibraryOperations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9744068828CF65AE00916BD4 /* LibraryOperations.swift */; }; 9745AB5A28E9257400067FF6 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9745AB5828E9257400067FF6 /* Settings.swift */; }; - 9745AB5D28E9277900067FF6 /* ReadingSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9745AB5B28E9277900067FF6 /* ReadingSettings.swift */; }; - 9745AB6028E9C72900067FF6 /* LibrarySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9745AB5E28E9C72900067FF6 /* LibrarySettings.swift */; }; 97486D06284A36790096E4DD /* ArticleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97486D05284A36790096E4DD /* ArticleCell.swift */; }; 97486D08284A42B90096E4DD /* SearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97486D07284A42B90096E4DD /* SearchResultRow.swift */; }; 97486D0B284B96690096E4DD /* CellBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97486D09284B96690096E4DD /* CellBackground.swift */; }; @@ -61,13 +56,14 @@ 975088B328763B6900273181 /* SheetContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 975088B228763B6900273181 /* SheetContent.swift */; }; 975088BB287BBDFE00273181 /* LanguageSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 975088B9287BBDFE00273181 /* LanguageSelector.swift */; }; 975088BE287DA10800273181 /* LibraryLastRefreshTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 975088BC287DA10800273181 /* LibraryLastRefreshTime.swift */; }; + 975346C42AC30CFD000DECB6 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 975346C32AC30CFD000DECB6 /* OrderedCollections */; }; 9753D949285B55F100A626CC /* DefaultKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9753D947285B55F100A626CC /* DefaultKeys.swift */; }; 9753D94B285B56C900A626CC /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 9753D94A285B56C900A626CC /* Defaults */; }; 97659CAC28DF817D002E6CE4 /* SearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976A65B22659489F009A97C6 /* SearchResult.swift */; }; 97659CAD28DF817D002E6CE4 /* SearchResult.m in Sources */ = {isa = PBXBuildFile; fileRef = 97B3998C2467561900BC6F5B /* SearchResult.m */; }; 97677B572A8FA80000F523AB /* FileImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97677B562A8FA80000F523AB /* FileImport.swift */; }; 9767E7862A72A21300C5082D /* App_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9767E7852A72A21300C5082D /* App_iOS.swift */; }; - 9767E78C2A75420400C5082D /* TabsManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9767E78B2A75420400C5082D /* TabsManagement.swift */; }; + 9767E78C2A75420400C5082D /* TabLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9767E78B2A75420400C5082D /* TabLabel.swift */; }; 9767E7902A756CEE00C5082D /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9767E78F2A756CEE00C5082D /* NavigationViewModel.swift */; }; 976BAEBA284903EA0049404F /* Fuzi in Frameworks */ = {isa = PBXBuildFile; productRef = 976BAEB9284903EA0049404F /* Fuzi */; }; 976BAEBC2849056B0049404F /* SearchOperation.mm in Sources */ = {isa = PBXBuildFile; fileRef = 97B3998924673FE000BC6F5B /* SearchOperation.mm */; }; @@ -77,7 +73,12 @@ 976D90DC281586B400CC7D29 /* ZimFileCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 973A2FD72780024600BD4320 /* ZimFileCell.swift */; }; 976D90E428159BFA00CC7D29 /* Favicon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97B0737527825E3D007C7DF3 /* Favicon.swift */; }; 976D90E92815D63A00CC7D29 /* ZimFilesNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976D90E72815D63A00CC7D29 /* ZimFilesNew.swift */; }; + 976F5EC62A97909100938490 /* BrowserTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976F5EC52A97909100938490 /* BrowserTab.swift */; }; 977B1B65289801DE001D07C4 /* Map.swift in Sources */ = {isa = PBXBuildFile; fileRef = 977B1B63289801DE001D07C4 /* Map.swift */; }; + 977DD4462AA570450001D6CC /* ArticleShortcutButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 977DD4452AA570450001D6CC /* ArticleShortcutButtons.swift */; }; + 977DD4492AA617680001D6CC /* SplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 977DD4482AA617680001D6CC /* SplitViewController.swift */; }; + 977DD44B2AA617A40001D6CC /* SidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 977DD44A2AA617A40001D6CC /* SidebarViewController.swift */; }; + 977DD44D2AA617DA0001D6CC /* CompactViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 977DD44C2AA617DA0001D6CC /* CompactViewController.swift */; }; 97909270286BF0BB002B7AA5 /* Formatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9790926E286BF0BB002B7AA5 /* Formatter.swift */; }; 9790CA5A28A05EBB00D39FC6 /* ZimFilesCategories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9790CA5828A05EBB00D39FC6 /* ZimFilesCategories.swift */; }; 979D3A7C284159BF00E396B8 /* injection.js in Resources */ = {isa = PBXBuildFile; fileRef = 9735B88B279D9A74005F0D1A /* injection.js */; }; @@ -86,9 +87,6 @@ 97B4489C210FBC2C0004B056 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97B4489A210FBC2C0004B056 /* Main.storyboard */; }; 97B4489E210FBC2E0004B056 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97B4489D210FBC2E0004B056 /* Assets.xcassets */; }; 97B448A1210FBC2E0004B056 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97B4489F210FBC2E0004B056 /* LaunchScreen.storyboard */; }; - 97B605A92A8DA32100317489 /* ContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97B605A82A8DA32100317489 /* ContainerView.swift */; }; - 97B605AC2A8DA35700317489 /* CompactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97B605AB2A8DA35700317489 /* CompactView.swift */; }; - 97B605AD2A8DA3D500317489 /* ReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978A730F27B99F25008101B4 /* ReaderViewModel.swift */; }; 97B706E82974599500562392 /* libkiwix.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97B706E72974599500562392 /* libkiwix.xcframework */; }; 97BD6C9D2886E06B004A8532 /* HTMLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BD6C9B2886E06B004A8532 /* HTMLParser.swift */; }; 97DA90D82975B0C100738365 /* LibraryRefreshViewModelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97DA90D72975B0C100738365 /* LibraryRefreshViewModelTest.swift */; }; @@ -98,7 +96,6 @@ 97DE2BA3283A8E5C00C63D9B /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97DE2BA1283A8E5C00C63D9B /* LibraryViewModel.swift */; }; 97DE2BA6283A944100C63D9B /* GridCommon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97DE2BA4283A944100C63D9B /* GridCommon.swift */; }; 97DE2BAD283B133700C63D9B /* wikipedia_dark.css in Resources */ = {isa = PBXBuildFile; fileRef = 970885D0271339A300C5795C /* wikipedia_dark.css */; }; - 97E8033E2A7BD80F00DD251D /* LegacyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97E8033D2A7BD80F00DD251D /* LegacyView.swift */; }; 97F3333028AFC1A2007FF53C /* SearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97F3332E28AFC1A2007FF53C /* SearchResults.swift */; }; 97FB4ECE28B4E221003FB524 /* SwiftUIBackports in Frameworks */ = {isa = PBXBuildFile; productRef = 97FB4ECD28B4E221003FB524 /* SwiftUIBackports */; }; /* End PBXBuildFile section */ @@ -147,16 +144,12 @@ 970EC3AB23BCF31F008DCA27 /* ParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserTests.swift; sourceTree = ""; }; 97121EBC28849F0000371AEB /* ZimFileMissingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZimFileMissingIndicator.swift; sourceTree = ""; }; 97176AD12A4FBD710093E3B0 /* BrowserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserViewModel.swift; sourceTree = ""; }; - 97176AD32A506B3B0093E3B0 /* RegularView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegularView.swift; sourceTree = ""; }; 9721BBB928427A93005C910D /* Bookmarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmarks.swift; sourceTree = ""; }; 9724FC2E28D5F5BE001B7DD2 /* BookmarkContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkContextMenu.swift; sourceTree = ""; }; 97255D8B273608C3002B995B /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; 972727A92A89122F00BCAF75 /* App_macOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App_macOS.swift; sourceTree = ""; }; - 972727AB2A891B9000BCAF75 /* ReadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingView.swift; sourceTree = ""; }; 972727AD2A897FAA00BCAF75 /* GridSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridSection.swift; sourceTree = ""; }; 972727B02A898B9700BCAF75 /* NavigationButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationButtons.swift; sourceTree = ""; }; - 972727B22A898BBF00BCAF75 /* RandomArticleButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomArticleButton.swift; sourceTree = ""; }; - 972727B42A898BD600BCAF75 /* MainArticleButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainArticleButton.swift; sourceTree = ""; }; 972727B62A898BF900BCAF75 /* OutlineButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutlineButton.swift; sourceTree = ""; }; 972727B82A898C1A00BCAF75 /* BookmarkButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkButton.swift; sourceTree = ""; }; 972727BA2A89930600BCAF75 /* ExternalLinkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalLinkHandler.swift; sourceTree = ""; }; @@ -165,6 +158,8 @@ 972DE4722814A116004FD9B9 /* Entities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Entities.swift; sourceTree = ""; }; 972DE4A62814A3D8004FD9B9 /* Kiwix.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Kiwix.app; sourceTree = BUILT_PRODUCTS_DIR; }; 972DE4C12814A961004FD9B9 /* Library.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Library.swift; sourceTree = ""; }; + 973206522AA25D07003A1A8F /* ViewModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModifiers.swift; sourceTree = ""; }; + 973206542AA28FF7003A1A8F /* TabsManagerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsManagerButton.swift; sourceTree = ""; }; 97341C6C2852248500BC273E /* DownloadTaskCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadTaskCell.swift; sourceTree = ""; }; 9735B88B279D9A74005F0D1A /* injection.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = injection.js; sourceTree = ""; }; 9735D0B92775363900C7D495 /* DataModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DataModel.xcdatamodel; sourceTree = ""; }; @@ -180,8 +175,6 @@ 973A2FD72780024600BD4320 /* ZimFileCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZimFileCell.swift; sourceTree = ""; }; 9744068828CF65AE00916BD4 /* LibraryOperations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryOperations.swift; sourceTree = ""; }; 9745AB5828E9257400067FF6 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; - 9745AB5B28E9277900067FF6 /* ReadingSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingSettings.swift; sourceTree = ""; }; - 9745AB5E28E9C72900067FF6 /* LibrarySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySettings.swift; sourceTree = ""; }; 97486D05284A36790096E4DD /* ArticleCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleCell.swift; sourceTree = ""; }; 97486D07284A42B90096E4DD /* SearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultRow.swift; sourceTree = ""; }; 97486D09284B96690096E4DD /* CellBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellBackground.swift; sourceTree = ""; }; @@ -198,10 +191,11 @@ 975FDB061F608F4100A10E8C /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; 97677B562A8FA80000F523AB /* FileImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileImport.swift; sourceTree = ""; }; 9767E7852A72A21300C5082D /* App_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App_iOS.swift; sourceTree = ""; }; - 9767E78B2A75420400C5082D /* TabsManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsManagement.swift; sourceTree = ""; }; + 9767E78B2A75420400C5082D /* TabLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabLabel.swift; sourceTree = ""; }; 9767E78F2A756CEE00C5082D /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = ""; }; 976A65B22659489F009A97C6 /* SearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResult.swift; sourceTree = ""; }; 976D90E72815D63A00CC7D29 /* ZimFilesNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZimFilesNew.swift; sourceTree = ""; }; + 976F5EC52A97909100938490 /* BrowserTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserTab.swift; sourceTree = ""; }; 9779A5A32456793500F6F6FF /* OPDSParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OPDSParser.swift; sourceTree = ""; }; 9779A5A42456793500F6F6FF /* OPDSParser.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OPDSParser.h; sourceTree = ""; }; 9779A5A52456793500F6F6FF /* OPDSParser.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = OPDSParser.mm; sourceTree = ""; }; @@ -212,7 +206,10 @@ 9779A73A2456796B00F6F6FF /* WebKitHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebKitHandler.swift; sourceTree = ""; }; 9779A7E224567A5A00F6F6FF /* Log.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; 977B1B63289801DE001D07C4 /* Map.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Map.swift; sourceTree = ""; }; - 978A730F27B99F25008101B4 /* ReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderViewModel.swift; sourceTree = ""; }; + 977DD4452AA570450001D6CC /* ArticleShortcutButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleShortcutButtons.swift; sourceTree = ""; }; + 977DD4482AA617680001D6CC /* SplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitViewController.swift; sourceTree = ""; }; + 977DD44A2AA617A40001D6CC /* SidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarViewController.swift; sourceTree = ""; }; + 977DD44C2AA617DA0001D6CC /* CompactViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactViewController.swift; sourceTree = ""; }; 9790926E286BF0BB002B7AA5 /* Formatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatter.swift; sourceTree = ""; }; 9790CA5828A05EBB00D39FC6 /* ZimFilesCategories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZimFilesCategories.swift; sourceTree = ""; }; 97B0737527825E3D007C7DF3 /* Favicon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Favicon.swift; sourceTree = ""; }; @@ -228,8 +225,6 @@ 97B4489D210FBC2E0004B056 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97B448A0210FBC2E0004B056 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97B448A2210FBC2E0004B056 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 97B605A82A8DA32100317489 /* ContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerView.swift; sourceTree = ""; }; - 97B605AB2A8DA35700317489 /* CompactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactView.swift; sourceTree = ""; }; 97B706E72974599500562392 /* libkiwix.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = libkiwix.xcframework; sourceTree = ""; }; 97B707042974637200562392 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 97BD6C9B2886E06B004A8532 /* HTMLParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLParser.swift; sourceTree = ""; }; @@ -238,7 +233,6 @@ 97DA90D72975B0C100738365 /* LibraryRefreshViewModelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryRefreshViewModelTest.swift; sourceTree = ""; }; 97DE2BA1283A8E5C00C63D9B /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = ""; }; 97DE2BA4283A944100C63D9B /* GridCommon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridCommon.swift; sourceTree = ""; }; - 97E8033D2A7BD80F00DD251D /* LegacyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyView.swift; sourceTree = ""; }; 97E94B1D271EF250005B0295 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97E94B22271EF250005B0295 /* Kiwix.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Kiwix.entitlements; sourceTree = ""; }; 97F3332E28AFC1A2007FF53C /* SearchResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResults.swift; sourceTree = ""; }; @@ -260,6 +254,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 975346C42AC30CFD000DECB6 /* OrderedCollections in Frameworks */, 97FB4ECE28B4E221003FB524 /* SwiftUIBackports in Frameworks */, 9753D94B285B56C900A626CC /* Defaults in Frameworks */, 97B706E82974599500562392 /* libkiwix.xcframework in Frameworks */, @@ -341,8 +336,8 @@ 972727B02A898B9700BCAF75 /* NavigationButtons.swift */, 972727B62A898BF900BCAF75 /* OutlineButton.swift */, 972727B82A898C1A00BCAF75 /* BookmarkButton.swift */, - 972727B42A898BD600BCAF75 /* MainArticleButton.swift */, - 972727B22A898BBF00BCAF75 /* RandomArticleButton.swift */, + 977DD4452AA570450001D6CC /* ArticleShortcutButtons.swift */, + 973206542AA28FF7003A1A8F /* TabsManagerButton.swift */, ); path = Buttons; sourceTree = ""; @@ -382,6 +377,7 @@ 972727BA2A89930600BCAF75 /* ExternalLinkHandler.swift */, 97677B562A8FA80000F523AB /* FileImport.swift */, 97DE2BA4283A944100C63D9B /* GridCommon.swift */, + 973206522AA25D07003A1A8F /* ViewModifiers.swift */, ); path = ViewModifiers; sourceTree = ""; @@ -390,8 +386,6 @@ isa = PBXGroup; children = ( 9745AB5828E9257400067FF6 /* Settings.swift */, - 9745AB5B28E9277900067FF6 /* ReadingSettings.swift */, - 9745AB5E28E9C72900067FF6 /* LibrarySettings.swift */, 975088B9287BBDFE00273181 /* LanguageSelector.swift */, 974C339E2853D4AC00DF6F4C /* About.swift */, ); @@ -421,6 +415,8 @@ 972727BC2A8A518D00BCAF75 /* SafariView.swift */, 97486D07284A42B90096E4DD /* SearchResultRow.swift */, 975088B228763B6900273181 /* SheetContent.swift */, + 9767E78B2A75420400C5082D /* TabLabel.swift */, + 97255D8B273608C3002B995B /* WebView.swift */, 973A2FD72780024600BD4320 /* ZimFileCell.swift */, 97121EBC28849F0000371AEB /* ZimFileMissingIndicator.swift */, 973A0DEF282E981200B41E71 /* ZimFileRow.swift */, @@ -476,12 +472,9 @@ children = ( 972727A92A89122F00BCAF75 /* App_macOS.swift */, 9767E7852A72A21300C5082D /* App_iOS.swift */, - 972727AB2A891B9000BCAF75 /* ReadingView.swift */, - 97176AD32A506B3B0093E3B0 /* RegularView.swift */, - 97B605A82A8DA32100317489 /* ContainerView.swift */, - 97B605AB2A8DA35700317489 /* CompactView.swift */, - 97E8033D2A7BD80F00DD251D /* LegacyView.swift */, - 9767E78B2A75420400C5082D /* TabsManagement.swift */, + 977DD4482AA617680001D6CC /* SplitViewController.swift */, + 977DD44A2AA617A40001D6CC /* SidebarViewController.swift */, + 977DD44C2AA617DA0001D6CC /* CompactViewController.swift */, ); path = App; sourceTree = ""; @@ -528,26 +521,18 @@ name = SearchResult; sourceTree = ""; }; - 97DED67D275278EB008D2C56 /* Browser */ = { - isa = PBXGroup; - children = ( - 97F3332E28AFC1A2007FF53C /* SearchResults.swift */, - 97255D8B273608C3002B995B /* WebView.swift */, - 97486D0C284C0EBC0096E4DD /* Welcome.swift */, - ); - path = Browser; - sourceTree = ""; - }; 97E4E69C28B8FCC70012227D /* Views */ = { isa = PBXGroup; children = ( 972727AF2A898B7400BCAF75 /* Buttons */, 973A0DF2282FED6F00B41E71 /* ViewModifiers */, 975088BF287EEE2900273181 /* BuildingBlocks */, - 97DED67D275278EB008D2C56 /* Browser */, 97A092782A54D088009C039E /* Library */, 9745AB5628E9235100067FF6 /* Settings */, 9721BBB928427A93005C910D /* Bookmarks.swift */, + 976F5EC52A97909100938490 /* BrowserTab.swift */, + 97F3332E28AFC1A2007FF53C /* SearchResults.swift */, + 97486D0C284C0EBC0096E4DD /* Welcome.swift */, 9709C0972A8E4C5700E4564C /* Commands.swift */, ); path = Views; @@ -557,7 +542,6 @@ isa = PBXGroup; children = ( 9735D0B72775362000C7D495 /* Model */, - 97FB4B0C27B81B530055F86E /* ViewModels */, 977B1B63289801DE001D07C4 /* Map.swift */, 974C33A7285589E900DF6F4C /* Patches.swift */, ); @@ -577,14 +561,6 @@ path = Support; sourceTree = ""; }; - 97FB4B0C27B81B530055F86E /* ViewModels */ = { - isa = PBXGroup; - children = ( - 978A730F27B99F25008101B4 /* ReaderViewModel.swift */, - ); - path = ViewModels; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -624,6 +600,7 @@ 976BAEB9284903EA0049404F /* Fuzi */, 9753D94A285B56C900A626CC /* Defaults */, 97FB4ECD28B4E221003FB524 /* SwiftUIBackports */, + 975346C32AC30CFD000DECB6 /* OrderedCollections */, ); productName = iOS_SwiftUI; productReference = 972DE4A62814A3D8004FD9B9 /* Kiwix.app */; @@ -685,6 +662,7 @@ 9744657E26F7981500072DC2 /* XCRemoteSwiftPackageReference "Fuzi" */, 97654724273214530070163D /* XCRemoteSwiftPackageReference "Defaults" */, 97FB4ECC28B4E221003FB524 /* XCRemoteSwiftPackageReference "SwiftUIBackports" */, + 975346C22AC30CFD000DECB6 /* XCRemoteSwiftPackageReference "swift-collections" */, ); productRefGroup = 97A2AB7F1C1B80FF00052E74; projectDirPath = ""; @@ -742,8 +720,6 @@ buildActionMask = 2147483647; files = ( 972DE4BB2814A5A4004FD9B9 /* Errors.swift in Sources */, - 97E8033E2A7BD80F00DD251D /* LegacyView.swift in Sources */, - 97B605A92A8DA32100317489 /* ContainerView.swift in Sources */, 9790CA5A28A05EBB00D39FC6 /* ZimFilesCategories.swift in Sources */, 97486D08284A42B90096E4DD /* SearchResultRow.swift in Sources */, 9753D949285B55F100A626CC /* DefaultKeys.swift in Sources */, @@ -753,12 +729,15 @@ 976BAEBC2849056B0049404F /* SearchOperation.mm in Sources */, 97909270286BF0BB002B7AA5 /* Formatter.swift in Sources */, 9745AB5A28E9257400067FF6 /* Settings.swift in Sources */, + 973206532AA25D07003A1A8F /* ViewModifiers.swift in Sources */, 97DE2B9C2839D39100C63D9B /* WebView.swift in Sources */, 973A0DEB281DDBB600B41E71 /* ZimFilesDownloads.swift in Sources */, 975088BE287DA10800273181 /* LibraryLastRefreshTime.swift in Sources */, + 977DD44B2AA617A40001D6CC /* SidebarViewController.swift in Sources */, 97486D06284A36790096E4DD /* ArticleCell.swift in Sources */, 9767E7902A756CEE00C5082D /* NavigationViewModel.swift in Sources */, 973A0E032831057200B41E71 /* URL.swift in Sources */, + 977DD4462AA570450001D6CC /* ArticleShortcutButtons.swift in Sources */, 973A0DE8281DD7EB00B41E71 /* Log.swift in Sources */, 972DE4BC2814A5BE004FD9B9 /* OPDSParser.swift in Sources */, 974C33A02853D4AC00DF6F4C /* About.swift in Sources */, @@ -766,30 +745,26 @@ 97486D0E284C0EBC0096E4DD /* Welcome.swift in Sources */, 976D90E92815D63A00CC7D29 /* ZimFilesNew.swift in Sources */, 97659CAC28DF817D002E6CE4 /* SearchResult.swift in Sources */, - 9767E78C2A75420400C5082D /* TabsManagement.swift in Sources */, + 9767E78C2A75420400C5082D /* TabLabel.swift in Sources */, 972727B92A898C1A00BCAF75 /* BookmarkButton.swift in Sources */, 972727B72A898BF900BCAF75 /* OutlineButton.swift in Sources */, - 97B605AC2A8DA35700317489 /* CompactView.swift in Sources */, + 977DD4492AA617680001D6CC /* SplitViewController.swift in Sources */, 975088B328763B6900273181 /* SheetContent.swift in Sources */, - 97B605AD2A8DA3D500317489 /* ReaderViewModel.swift in Sources */, 97176AD22A4FBD710093E3B0 /* BrowserViewModel.swift in Sources */, 972DE4B92814A56A004FD9B9 /* Database.swift in Sources */, 97659CAD28DF817D002E6CE4 /* SearchResult.m in Sources */, 97F3333028AFC1A2007FF53C /* SearchResults.swift in Sources */, 972727AE2A897FAA00BCAF75 /* GridSection.swift in Sources */, 9709C0982A8E4C5700E4564C /* Commands.swift in Sources */, - 97176AD42A506B3B0093E3B0 /* RegularView.swift in Sources */, - 972727B32A898BBF00BCAF75 /* RandomArticleButton.swift in Sources */, 972727BF2A8A52DC00BCAF75 /* AlertHandler.swift in Sources */, 97341C6E2852248500BC273E /* DownloadTaskCell.swift in Sources */, + 973206552AA28FF7003A1A8F /* TabsManagerButton.swift in Sources */, 976D90DC281586B400CC7D29 /* ZimFileCell.swift in Sources */, 97121EBE28849F0000371AEB /* ZimFileMissingIndicator.swift in Sources */, + 977DD44D2AA617DA0001D6CC /* CompactViewController.swift in Sources */, 975088BB287BBDFE00273181 /* LanguageSelector.swift in Sources */, 9767E7862A72A21300C5082D /* App_iOS.swift in Sources */, - 9745AB6028E9C72900067FF6 /* LibrarySettings.swift in Sources */, 972DE4BA2814A590004FD9B9 /* ZimFileMetaData.mm in Sources */, - 972727AC2A891B9000BCAF75 /* ReadingView.swift in Sources */, - 972727B52A898BD600BCAF75 /* MainArticleButton.swift in Sources */, 97486D0B284B96690096E4DD /* CellBackground.swift in Sources */, 972DE4B42814A4F6004FD9B9 /* DataModel.xcdatamodeld in Sources */, 976BAEBD2849056B0049404F /* SearchOperation.swift in Sources */, @@ -809,11 +784,10 @@ 972727BB2A89930600BCAF75 /* ExternalLinkHandler.swift in Sources */, 972DE4C42814AAAE004FD9B9 /* Library.swift in Sources */, 972727B12A898B9700BCAF75 /* NavigationButtons.swift in Sources */, + 976F5EC62A97909100938490 /* BrowserTab.swift in Sources */, 9724FC3028D5F5BE001B7DD2 /* BookmarkContextMenu.swift in Sources */, 976BAEBE284905760049404F /* SearchViewModel.swift in Sources */, 977B1B65289801DE001D07C4 /* Map.swift in Sources */, - 970258B528FE418A00B68E84 /* BookmarkOperations.swift in Sources */, - 9745AB5D28E9277900067FF6 /* ReadingSettings.swift in Sources */, 973A0DE7281DC8F400B41E71 /* DownloadService.swift in Sources */, 9721BBB72841C16D005C910D /* Message.swift in Sources */, 97BD6C9D2886E06B004A8532 /* HTMLParser.swift in Sources */, @@ -934,7 +908,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = Support/Kiwix.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 110; + CURRENT_PROJECT_VERSION = 119; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = L7HWM3SP3L; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; @@ -980,7 +954,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = Support/Kiwix.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 110; + CURRENT_PROJECT_VERSION = 119; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = L7HWM3SP3L; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; @@ -1062,7 +1036,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; @@ -1117,7 +1091,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; @@ -1234,6 +1208,14 @@ minimumVersion = 3.0.0; }; }; + 975346C22AC30CFD000DECB6 /* XCRemoteSwiftPackageReference "swift-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-collections.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.4; + }; + }; 97654724273214530070163D /* XCRemoteSwiftPackageReference "Defaults" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sindresorhus/Defaults"; @@ -1253,6 +1235,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 975346C32AC30CFD000DECB6 /* OrderedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = 975346C22AC30CFD000DECB6 /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = OrderedCollections; + }; 9753D94A285B56C900A626CC /* Defaults */ = { isa = XCSwiftPackageProductDependency; package = 97654724273214530070163D /* XCRemoteSwiftPackageReference "Defaults" */; diff --git a/Kiwix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Kiwix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2845dacc3..74c6c7734 100644 --- a/Kiwix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Kiwix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -19,12 +19,12 @@ } }, { - "identity" : "swiftbackports", + "identity" : "swift-collections", "kind" : "remoteSourceControl", - "location" : "https://github.com/shaps80/SwiftBackports", + "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "ddca6a237c1ba2291d5a3cc47ec8480ce6e9f805", - "version" : "1.0.3" + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/shaps80/SwiftUIBackports.git", "state" : { - "revision" : "556d42f391b74059a354b81b8c8e19cc7cb576f4", - "version" : "1.15.1" + "revision" : "4cab730480277c5ae6ac3ea8ee02b4845dee4903", + "version" : "1.6.3" } } ], diff --git a/SwiftUI/Patches.swift b/SwiftUI/Patches.swift index e2e7f08f8..998cd777a 100644 --- a/SwiftUI/Patches.swift +++ b/SwiftUI/Patches.swift @@ -65,6 +65,7 @@ extension Notification.Name { static let externalLink = Notification.Name("externalLink") static let openFiles = Notification.Name("openFiles") static let openURL = Notification.Name("openURL") + static let toggleSidebar = Notification.Name("toggleSidebar") } extension UTType { @@ -80,4 +81,8 @@ extension NotificationCenter { static func openFiles(_ urls: [URL], context: OpenFileContext) { NotificationCenter.default.post(name: .openFiles, object: nil, userInfo: ["urls": urls, "context": context]) } + + static func toggleSidebar() { + NotificationCenter.default.post(name: .toggleSidebar, object: nil, userInfo: nil) + } } diff --git a/SwiftUI/ViewModels/ReaderViewModel.swift b/SwiftUI/ViewModels/ReaderViewModel.swift deleted file mode 100644 index 9cec36091..000000000 --- a/SwiftUI/ViewModels/ReaderViewModel.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// ReaderViewModel.swift -// Kiwix -// -// Created by Chris Li on 2/13/22. -// Copyright © 2022 Chris Li. All rights reserved. -// - -import WebKit - -import Defaults - -class ReadingViewModel: NSObject, ObservableObject, WKScriptMessageHandler { - @Published var canGoBack: Bool = false - @Published var canGoForward: Bool = false - @Published var articleTitle: String = "" - @Published var zimFileName: String = "" - @Published var outlineItems = [OutlineItem]() - @Published var outlineItemTree = [OutlineItem]() - - let webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) - - static let bookmarkNotificationName = NSNotification.Name(rawValue: "Bookmark.toggle") - - // MARK: - delegates - - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - if message.name == "headings", let headings = message.body as? [[String: String]] { - DispatchQueue.global(qos: .userInitiated).async { - self.generateOutlineList(headings: headings) - self.generateOutlineTree(headings: headings) - } - } - } - - // MARK: - navigation - - func goBack() { - webView.goBack() - } - - func goForward() { - webView.goForward() - } - - // MARK: - outline - - /// Scroll to a outline item - /// - Parameter outlineItemID: ID of the outline item to scroll to - func scrollTo(outlineItemID: String) { - webView.evaluateJavaScript("scrollToHeading('\(outlineItemID)')") - } - - /// Convert flattened heading element data to a list of OutlineItems. - /// - Parameter headings: list of heading element data retrieved from webview - private func generateOutlineList(headings: [[String: String]]) { - let allLevels = headings.compactMap { Int($0["tag"]?.suffix(1) ?? "") } - let offset = allLevels.filter({ $0 == 1 }).count == 1 ? 2 : allLevels.min() ?? 0 - let outlineItems: [OutlineItem] = headings.enumerated().compactMap { index, heading in - guard let id = heading["id"], - let text = heading["text"], - let tag = heading["tag"], - let level = Int(tag.suffix(1)) else { return nil } - return OutlineItem(id: id, index: index, text: text, level: max(level - offset, 0)) - } - DispatchQueue.main.async { - self.outlineItems = outlineItems - } - } - - /// Convert flattened heading element data to a tree of OutlineItems. - /// - Parameter headings: list of heading element data retrieved from webview - private func generateOutlineTree(headings: [[String: String]]) { - let root = OutlineItem(index: -1, text: "", level: 0) - var stack: [OutlineItem] = [root] - var all = [String: OutlineItem]() - - headings.enumerated().forEach { index, heading in - guard let id = heading["id"], - let text = heading["text"], - let tag = heading["tag"], let level = Int(tag.suffix(1)) else { return } - let item = OutlineItem(id: id, index: index, text: text, level: level) - all[item.id] = item - - // get last item in stack - // if last item is child of item's sibling, unwind stack until a sibling is found - guard var lastItem = stack.last else { return } - while lastItem.level > item.level { - stack.removeLast() - lastItem = stack[stack.count - 1] - } - - // if item is last item's sibling, add item to parent and replace last item with itself in stack - // if item is last item's child, add item to parent and add item to stack - if lastItem.level == item.level { - stack[stack.count - 2].addChild(item) - stack[stack.count - 1] = item - } else if lastItem.level < item.level { - stack[stack.count - 1].addChild(item) - stack.append(item) - } - } - - // if there is only one h1, flatten one level - if let rootChildren = root.children, rootChildren.count == 1, let rootFirstChild = rootChildren.first { - let children = rootFirstChild.removeAllChildren() - DispatchQueue.main.async { - self.outlineItemTree = [rootFirstChild] + children - } - } else { - DispatchQueue.main.async { - self.outlineItemTree = root.children ?? [] - } - } - } -} diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index 1f25452e5..2354f6b4d 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -11,10 +11,32 @@ import CoreData import CoreLocation import WebKit +import OrderedCollections + class BrowserViewModel: NSObject, ObservableObject, WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate, NSFetchedResultsControllerDelegate { + static private var cache = OrderedDictionary() + + static func getCached(tabID: NSManagedObjectID) -> BrowserViewModel { + let viewModel = cache[tabID] ?? BrowserViewModel(tabID: tabID) + cache.removeValue(forKey: tabID) + cache[tabID] = viewModel + return viewModel + } + + static func purgeCache() { + guard cache.count > 10 else { return } + let range = 0 ..< cache.count - 5 + cache.values[range].forEach { viewModel in + viewModel.persistState() + } + cache.removeSubrange(range) + } + + // MARK: - Properties + @Published private(set) var canGoBack = false @Published private(set) var canGoForward = false @Published private(set) var articleTitle: String = "" @@ -24,23 +46,26 @@ class BrowserViewModel: NSObject, ObservableObject, @Published private(set) var outlineItemTree = [OutlineItem]() @Published private(set) var url: URL? - private(set) var tabID: NSManagedObjectID? + let tabID: NSManagedObjectID? + let webView: WKWebView private var canGoBackObserver: NSKeyValueObservation? private var canGoForwardObserver: NSKeyValueObservation? - private var titleObserver: NSKeyValueObservation? - private var urlObserver: NSKeyValueObservation? + private var titleURLObserver: AnyCancellable? private var bookmarkFetchedResultsController: NSFetchedResultsController? - var webView: WKWebView { - if let tabID { - return WebViewCache.shared.getWebView(tabID: tabID) - } else { - return WebViewCache.shared.webView - } - } + // MARK: - Lifecycle - func configure(tabID: NSManagedObjectID?) { + init(tabID: NSManagedObjectID? = nil) { self.tabID = tabID + self.webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) + super.init() + + // restore webview state, and set url before observer call back + // note: optionality of url determines what to show in a tab, so it should be set before tab is on screen + if let tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab { + webView.interactionState = tab.interactionState + url = webView.url + } // configure web view webView.allowsBackForwardNavigationGestures = true @@ -62,28 +87,42 @@ class BrowserViewModel: NSObject, ObservableObject, canGoForwardObserver = webView.observe(\.canGoForward, options: .initial) { [weak self] webView, _ in self?.canGoForward = webView.canGoForward } - titleObserver = webView.observe(\.title, options: .initial) { [weak self] webView, _ in - guard let title = webView.title, !title.isEmpty else { return } - self?.articleTitle = title + titleURLObserver = Publishers.CombineLatest( + webView.publisher(for: \.title, options: .initial), + webView.publisher(for: \.url, options: .initial) + ) + .debounce(for: 0.1, scheduler: DispatchQueue.global()) + .receive(on: DispatchQueue.main) + .sink { [weak self] title, url in + let title: String? = { + if let title, !title.isEmpty { + return title + } else { + return nil + } + }() + let zimFile: ZimFile? = { + guard let url, let zimFileID = UUID(uuidString: url.host ?? "") else { return nil } + return try? Database.viewContext.fetch(ZimFile.fetchRequest(fileID: zimFileID)).first + }() - guard let zimFileID = UUID(uuidString: webView.url?.host ?? ""), - let zimFile = try? Database.viewContext.fetch(ZimFile.fetchRequest(fileID: zimFileID)).first - else { return } - self?.zimFileName = zimFile.name + // update view model + self?.articleTitle = title ?? "" + self?.zimFileName = zimFile?.name ?? "" + self?.url = url - guard let tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab else { return } - tab.title = title - tab.zimFile = zimFile - tab.lastOpened = Date() - try? Database.viewContext.save() - } - urlObserver = webView.observe(\.url, options: .initial) { [weak self] webView, _ in - self?.url = webView.url + // update tab data + if let tabID = self?.tabID, + let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab, + let title { + tab.title = title + tab.zimFile = zimFile + } // setup bookmark fetched results controller self?.bookmarkFetchedResultsController = NSFetchedResultsController( fetchRequest: Bookmark.fetchRequest(predicate: { - if let url = webView.url { + if let url = url { return NSPredicate(format: "articleURL == %@", url as CVarArg) } else { return NSPredicate(format: "articleURL == nil") @@ -98,6 +137,17 @@ class BrowserViewModel: NSObject, ObservableObject, } } + func updateLastOpened() { + guard let tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab else { return } + tab.lastOpened = Date() + } + + func persistState() { + guard let tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab else { return } + tab.interactionState = webView.interactionState as? Data + try? Database.viewContext.save() + } + // MARK: - Content Loading func load(url: URL) { @@ -199,10 +249,16 @@ class BrowserViewModel: NSObject, ObservableObject, var actions = [UIAction]() // open url - let openAction = UIAction(title: "Open", image: UIImage(systemName: "doc.richtext")) { _ in - webView.load(URLRequest(url: url)) - } - actions.append(openAction) + actions.append( + UIAction(title: "Open", image: UIImage(systemName: "doc.text")) { _ in + webView.load(URLRequest(url: url)) + } + ) + actions.append( + UIAction(title: "Open in New Tab", image: UIImage(systemName: "doc.badge.plus")) { _ in + NotificationCenter.openURL(url, inNewTab: true) + } + ) // bookmark let bookmarkAction: UIAction = { @@ -211,11 +267,11 @@ class BrowserViewModel: NSObject, ObservableObject, let request = Bookmark.fetchRequest(predicate: predicate) if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { return UIAction(title: "Remove Bookmark", image: UIImage(systemName: "star.slash.fill")) { _ in - BookmarkOperations.delete(url, withNotification: false) + self.deleteBookmark(url: url) } } else { return UIAction(title: "Bookmark", image: UIImage(systemName: "star")) { _ in - BookmarkOperations.create(url, withNotification: false) + self.createBookmark(url: url) } } }() @@ -235,8 +291,8 @@ class BrowserViewModel: NSObject, ObservableObject, articleBookmarked = !snapshot.itemIdentifiers.isEmpty } - func createBookmark() { - guard let url = webView.url else { return } + func createBookmark(url: URL? = nil) { + guard let url = url ?? webView.url else { return } Database.performBackgroundTask { context in let bookmark = Bookmark(context: context) bookmark.articleURL = url @@ -255,8 +311,8 @@ class BrowserViewModel: NSObject, ObservableObject, } } - func deleteBookmark() { - guard let url = webView.url else { return } + func deleteBookmark(url: URL? = nil) { + guard let url = url ?? webView.url else { return } Database.performBackgroundTask { context in let request = Bookmark.fetchRequest(predicate: NSPredicate(format: "articleURL == %@", url as CVarArg)) guard let bookmark = try? context.fetch(request).first else { return } diff --git a/ViewModel/LibraryViewModel.swift b/ViewModel/LibraryViewModel.swift index 523f85021..657c0ff3e 100644 --- a/ViewModel/LibraryViewModel.swift +++ b/ViewModel/LibraryViewModel.swift @@ -12,7 +12,6 @@ import os import Defaults public class LibraryViewModel: ObservableObject { - @Published var selectedTabItem: LibraryTabItem = .opened @Published var selectedZimFile: ZimFile? @MainActor @Published public private(set) var error: Error? @MainActor @Published public private(set) var isInProgress = false diff --git a/ViewModel/NavigationViewModel.swift b/ViewModel/NavigationViewModel.swift index bcad99124..a0b54572a 100644 --- a/ViewModel/NavigationViewModel.swift +++ b/ViewModel/NavigationViewModel.swift @@ -52,79 +52,45 @@ class NavigationViewModel: ObservableObject { /// Delete a single tab, and select another tab /// - Parameter tabID: ID of the tab to delete - func deleteTab(tabID: NSManagedObjectID) async { - let deletedTabCreatedAt: Date? = await Database.performBackgroundTask { context in - defer { try? context.save() } - guard let tab = try? context.existingObject(with: tabID) as? Tab else { return nil } + func deleteTab(tabID: NSManagedObjectID) { + Database.performBackgroundTask { context in + guard let tab = try? context.existingObject(with: tabID) as? Tab else { return } + + // select a new tab if the currently selected tab is being deleted + if case let .tab(selectedTabID) = self.currentItem, selectedTabID == tabID { + let fetchRequest = Tab.fetchRequest( + predicate: NSPredicate(format: "created < %@", tab.created as CVarArg), + sortDescriptors: [NSSortDescriptor(key: "created", ascending: false)] + ) + fetchRequest.fetchLimit = 1 + let newTab = (try? context.fetch(fetchRequest).first) ?? self.makeTab(context: context) + try? context.obtainPermanentIDs(for: [newTab]) + DispatchQueue.main.async { + self.currentItem = NavigationItem.tab(objectID: newTab.objectID) + } + } + + // delete tab context.delete(tab) - return tab.created - } -// webViews.removeValue(forKey: tabID) - - // wait for a bit to avoid broken sidebar animation - try? await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) - - // select a new tab if current tab is deleted - guard case let .tab(selectedTabID) = self.currentItem, - selectedTabID == tabID, - let deletedTabCreatedAt else { return } - let tabID = await Database.performBackgroundTask { context in - let fetchRequest = Tab.fetchRequest( - predicate: NSPredicate(format: "created < %@", deletedTabCreatedAt as CVarArg), - sortDescriptors: [NSSortDescriptor(key: "created", ascending: false)] - ) - fetchRequest.fetchLimit = 1 - let newTab = (try? context.fetch(fetchRequest).first) ?? self.makeTab(context: context) - try? context.obtainPermanentIDs(for: [newTab]) try? context.save() - return newTab.objectID } - currentItem = NavigationItem.tab(objectID: tabID) } /// Delete all tabs, and open a new tab - func deleteAllTabs() async { - await Database.performBackgroundTask { context in + func deleteAllTabs() { + Database.performBackgroundTask { context in + // delete all existing tabs let tabs = try? context.fetch(Tab.fetchRequest()) tabs?.forEach { context.delete($0) } - try? context.save() - } -// webViews.removeAll() - - // wait for a bit to avoid broken sidebar animation - try? await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) - - // create a new tab - currentItem = NavigationItem.tab(objectID: createTab()) - } -} - -class WebViewCache { - static let shared = WebViewCache() - - private var webViews = [NSManagedObjectID: WKWebView]() - private(set) lazy var webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) - - private init() { } - - func getWebView(tabID: NSManagedObjectID) -> WKWebView { - if let webView = webViews[tabID] { - return webView - } else { - let webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) - if let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab { - webView.interactionState = tab.interactionState + + // create new tab + let newTab = self.makeTab(context: context) + try? context.obtainPermanentIDs(for: [newTab]) + DispatchQueue.main.async { + self.currentItem = NavigationItem.tab(objectID: newTab.objectID) } - webViews[tabID] = webView - return webView - } - } - - func persistStates() { - webViews.forEach { tabID, webView in - guard let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab else { return } - tab.interactionState = webView.interactionState as? Data + + try? context.save() } - try? Database.viewContext.save() } } diff --git a/ViewModel/SearchViewModel.swift b/ViewModel/SearchViewModel.swift index 5d9139bca..58c7e6984 100644 --- a/ViewModel/SearchViewModel.swift +++ b/ViewModel/SearchViewModel.swift @@ -67,11 +67,11 @@ class SearchViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDel queue.cancelAllOperations() let operation = SearchOperation(searchText: searchText, zimFileIDs: zimFileIDs) operation.extractMatchingSnippet = Defaults[.searchResultSnippetMode] == .matches - operation.completionBlock = { [unowned self] in + operation.completionBlock = { [weak self] in guard !operation.isCancelled else { return } DispatchQueue.main.sync { - self.results = operation.results - self.inProgress = false + self?.results = operation.results + self?.inProgress = false } } queue.addOperation(operation) diff --git a/Views/Bookmarks.swift b/Views/Bookmarks.swift index 17131aacc..f24ab25d5 100644 --- a/Views/Bookmarks.swift +++ b/Views/Bookmarks.swift @@ -35,6 +35,7 @@ struct Bookmarks: View { } } .modifier(GridCommon()) + .modifier(ToolbarRoleBrowser()) .navigationTitle("Bookmarks") .searchable(text: $searchText) .onChange(of: searchText) { searchText in @@ -45,6 +46,19 @@ struct Bookmarks: View { Message(text: "No bookmarks") } } + .toolbar { + #if os(iOS) + ToolbarItem(placement: .navigationBarLeading) { + if #unavailable(iOS 16), horizontalSizeClass == .regular { + Button { + NotificationCenter.toggleSidebar() + } label: { + Label("Show Sidebar", systemImage: "sidebar.left") + } + } + } + #endif + } } private var gridItem: GridItem { diff --git a/Views/BrowserTab.swift b/Views/BrowserTab.swift new file mode 100644 index 000000000..9a080449e --- /dev/null +++ b/Views/BrowserTab.swift @@ -0,0 +1,82 @@ +// +// BrowserTab.swift +// Kiwix +// +// Created by Chris Li on 8/24/23. +// Copyright © 2023 Chris Li. All rights reserved. +// + +import SwiftUI + +struct BrowserTab: View { + @EnvironmentObject private var browser: BrowserViewModel + @StateObject private var search = SearchViewModel() + + var body: some View { + Content().toolbar { + #if os(macOS) + ToolbarItemGroup(placement: .navigation) { NavigationButtons() } + #elseif os(iOS) + ToolbarItemGroup(placement: .navigationBarLeading) { + if #unavailable(iOS 16) { + Button { + NotificationCenter.toggleSidebar() + } label: { + Label("Show Sidebar", systemImage: "sidebar.left") + } + } + NavigationButtons() + } + #endif + ToolbarItemGroup(placement: .primaryAction) { + OutlineButton() + BookmarkButton() + ArticleShortcutButtons(displayMode: .mainAndRandomArticle) + } + } + .environmentObject(search) + .focusedSceneValue(\.browserViewModel, browser) + .focusedSceneValue(\.canGoBack, browser.canGoBack) + .focusedSceneValue(\.canGoForward, browser.canGoForward) + .modifier(ExternalLinkHandler()) + .searchable(text: $search.searchText, placement: .toolbar) + .modify { view in + #if os(macOS) + view.navigationTitle(browser.articleTitle.isEmpty ? "Kiwix" : browser.articleTitle) + .navigationSubtitle(browser.zimFileName) + #elseif os(iOS) + view + #endif + } + .onAppear { + browser.updateLastOpened() + } + .onDisappear { + browser.persistState() + } + } + + struct Content: View { + @Environment(\.isSearching) private var isSearching + @EnvironmentObject private var browser: BrowserViewModel + + var body: some View { + GeometryReader { proxy in + Group { + if isSearching { + SearchResults() + #if os(macOS) + .environment(\.horizontalSizeClass, proxy.size.width > 650 ? .regular : .compact) + #elseif os(iOS) + .environment(\.horizontalSizeClass, proxy.size.width > 750 ? .regular : .compact) + #endif + } else if browser.url == nil { + Welcome() + } else { + WebView().ignoresSafeArea() + } + } + } + } + } +} diff --git a/Views/BuildingBlocks/TabLabel.swift b/Views/BuildingBlocks/TabLabel.swift new file mode 100644 index 000000000..ebd8fa598 --- /dev/null +++ b/Views/BuildingBlocks/TabLabel.swift @@ -0,0 +1,27 @@ +// +// TabLabel.swift +// Kiwix +// +// Created by Chris Li on 7/29/23. +// Copyright © 2023 Chris Li. All rights reserved. +// + +import SwiftUI + +#if os(iOS) +struct TabLabel: View { + @ObservedObject var tab: Tab + + var body: some View { + if let zimFile = tab.zimFile, let category = Category(rawValue: zimFile.category) { + Label { + Text(tab.title ?? "New Tab").lineLimit(1) + } icon: { + Favicon(category: category, imageData: zimFile.faviconData).frame(width: 22, height: 22) + } + } else { + Label(tab.title ?? "New Tab", systemImage: "square") + } + } +} +#endif diff --git a/Views/Browser/WebView.swift b/Views/BuildingBlocks/WebView.swift similarity index 93% rename from Views/Browser/WebView.swift rename to Views/BuildingBlocks/WebView.swift index 140e2f7b5..1e84b0407 100644 --- a/Views/Browser/WebView.swift +++ b/Views/BuildingBlocks/WebView.swift @@ -15,36 +15,34 @@ import Defaults #if os(macOS) struct WebView: NSViewRepresentable { + @EnvironmentObject private var browser: BrowserViewModel + func makeNSView(context: Context) -> WKWebView { - WebViewCache.shared.webView + browser.webView } func updateNSView(_ webView: WKWebView, context: Context) { } func makeCoordinator() -> Coordinator { - Coordinator() + Coordinator(view: self) } class Coordinator { private let pageZoomObserver: Defaults.Observation - init() { + init(view: WebView) { pageZoomObserver = Defaults.observe(.webViewPageZoom) { change in - WebViewCache.shared.webView.pageZoom = change.newValue + view.browser.webView.pageZoom = change.newValue } } } } #elseif os(iOS) struct WebView: UIViewControllerRepresentable { - let tabID: NSManagedObjectID? + @EnvironmentObject private var browser: BrowserViewModel func makeUIViewController(context: Context) -> WebViewController { - if let tabID { - return WebViewController(webView: WebViewCache.shared.getWebView(tabID: tabID)) - } else { - return WebViewController(webView: WebViewCache.shared.webView) - } + WebViewController(webView: browser.webView) } func updateUIViewController(_ controller: WebViewController, context: Context) { } diff --git a/Views/Buttons/ArticleShortcutButtons.swift b/Views/Buttons/ArticleShortcutButtons.swift new file mode 100644 index 000000000..79ee831de --- /dev/null +++ b/Views/Buttons/ArticleShortcutButtons.swift @@ -0,0 +1,94 @@ +// +// ArticleShortcutButtons.swift +// Kiwix +// +// Created by Chris Li on 9/3/23. +// Copyright © 2023 Chris Li. All rights reserved. +// + +import SwiftUI + +struct ArticleShortcutButtons: View { + @Environment(\.dismissSearch) private var dismissSearch + @EnvironmentObject private var browser: BrowserViewModel + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \ZimFile.size, ascending: false)], + predicate: ZimFile.openedPredicate + ) private var zimFiles: FetchedResults + + let displayMode: DisplayMode + + enum DisplayMode { + case mainArticle, randomArticle, mainAndRandomArticle + } + + var body: some View { + switch displayMode { + case .mainArticle: + mainArticle + case .randomArticle: + randomArticle + case .mainAndRandomArticle: + mainArticle + randomArticle + } + } + + private var mainArticle: some View { + #if os(macOS) + Button { + browser.loadMainArticle() + dismissSearch() + } label: { + Label("Main Article", systemImage: "house") + } + .disabled(zimFiles.isEmpty) + .help("Show main article") + #elseif os(iOS) + Menu { + ForEach(zimFiles) { zimFile in + Button(zimFile.name) { + browser.loadMainArticle(zimFileID: zimFile.fileID) + dismissSearch() + } + } + } label: { + Label("Main Article", systemImage: "house") + } primaryAction: { + browser.loadMainArticle() + dismissSearch() + } + .disabled(zimFiles.isEmpty) + .help("Show main article") + #endif + } + + var randomArticle: some View { + #if os(macOS) + Button { + browser.loadRandomArticle() + dismissSearch() + } label: { + Label("Random Article", systemImage: "die.face.5") + } + .disabled(zimFiles.isEmpty) + .help("Show random article") + #elseif os(iOS) + Menu { + ForEach(zimFiles) { zimFile in + Button(zimFile.name) { + browser.loadRandomArticle(zimFileID: zimFile.fileID) + dismissSearch() + } + } + } label: { + Label("Random Page", systemImage: "die.face.5") + } primaryAction: { + browser.loadRandomArticle() + dismissSearch() + } + .disabled(zimFiles.isEmpty) + .help("Show random article") + #endif + } +} diff --git a/Views/Buttons/BookmarkButton.swift b/Views/Buttons/BookmarkButton.swift index 4c99d6497..385084421 100644 --- a/Views/Buttons/BookmarkButton.swift +++ b/Views/Buttons/BookmarkButton.swift @@ -73,21 +73,7 @@ struct BookmarkButton: View { } } .frame(idealWidth: 360, idealHeight: 600) - .modify { view in - #if os(macOS) - view - #elseif os(iOS) - if #available(iOS 16.0, *) { - view.presentationDetents([.medium, .large]) - } else { - /* - HACK: Use medium as selection so that half sized sheets are consistently shown - when tab manager button is pressed, user can still freely adjust sheet size. - */ - view.backport.presentationDetents([.medium, .large], selection: .constant(.medium)) - } - #endif - } + .modifier(MarkAsHalfSheet()) } #endif } diff --git a/Views/Buttons/MainArticleButton.swift b/Views/Buttons/MainArticleButton.swift deleted file mode 100644 index eb75a0c0c..000000000 --- a/Views/Buttons/MainArticleButton.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// MainArticleButton.swift -// Kiwix -// -// Created by Chris Li on 8/13/23. -// Copyright © 2023 Chris Li. All rights reserved. -// - -import SwiftUI - -struct MainArticleButton: View { - @EnvironmentObject private var browser: BrowserViewModel - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \ZimFile.size, ascending: false)], - predicate: ZimFile.openedPredicate - ) private var zimFiles: FetchedResults - - var body: some View { - #if os(macOS) - Button { - browser.loadMainArticle() - } label: { - Label("Main Article", systemImage: "house") - } - .disabled(zimFiles.isEmpty) - .help("Show main article") - #elseif os(iOS) - Menu { - ForEach(zimFiles) { zimFile in - Button(zimFile.name) { - browser.loadMainArticle(zimFileID: zimFile.fileID) - } - } - } label: { - Label("Main Article", systemImage: "house") - } primaryAction: { - browser.loadMainArticle() - } - .disabled(zimFiles.isEmpty) - .help("Show main article") - #endif - } -} diff --git a/Views/Buttons/NavigationButtons.swift b/Views/Buttons/NavigationButtons.swift index a0b2fc2b2..e30dec407 100644 --- a/Views/Buttons/NavigationButtons.swift +++ b/Views/Buttons/NavigationButtons.swift @@ -9,6 +9,7 @@ import SwiftUI struct NavigationButtons: View { + @Environment(\.dismissSearch) private var dismissSearch @Environment(\.horizontalSizeClass) private var horizontalSizeClass @EnvironmentObject private var browser: BrowserViewModel @@ -26,6 +27,7 @@ struct NavigationButtons: View { var goBackButton: some View { Button { browser.webView.goBack() + dismissSearch() } label: { Label("Go Back", systemImage: "chevron.left") }.disabled(!browser.canGoBack) @@ -34,6 +36,7 @@ struct NavigationButtons: View { var goForwardButton: some View { Button { browser.webView.goForward() + dismissSearch() } label: { Label("Go Forward", systemImage: "chevron.right") }.disabled(!browser.canGoForward) diff --git a/Views/Buttons/OutlineButton.swift b/Views/Buttons/OutlineButton.swift index 24da9e91a..671b74d9c 100644 --- a/Views/Buttons/OutlineButton.swift +++ b/Views/Buttons/OutlineButton.swift @@ -11,75 +11,63 @@ import SwiftUI import SwiftUIBackports struct OutlineButton: View { - @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.dismissSearch) private var dismissSearch @EnvironmentObject private var browser: BrowserViewModel @State private var isShowingOutline = false var body: some View { - if horizontalSizeClass == .regular { - Menu { - ForEach(browser.outlineItems) { item in - Button(String(repeating: " ", count: item.level) + item.text) { - browser.scrollTo(outlineItemID: item.id) - } + #if os(macOS) + Menu { + ForEach(browser.outlineItems) { item in + Button(String(repeating: " ", count: item.level) + item.text) { + browser.scrollTo(outlineItemID: item.id) + dismissSearch() } - } label: { - Label("Outline", systemImage: "list.bullet") - } - .disabled(browser.outlineItems.isEmpty) - .help("Show article outline") - } else { - Button { - isShowingOutline = true - } label: { - Image(systemName: "list.bullet") } - .disabled(browser.outlineItems.isEmpty) - .help("Show article outline") - .sheet(isPresented: $isShowingOutline) { - NavigationView { - Group { - if browser.outlineItemTree.isEmpty { - Message(text: "No outline available") - } else { - List(browser.outlineItemTree) { item in - OutlineNode(item: item) { item in - browser.scrollTo(outlineItemID: item.id) - isShowingOutline = false - } - }.listStyle(.plain) - } - } - .navigationTitle(browser.articleTitle) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button { + } label: { + Label("Outline", systemImage: "list.bullet") + } + .disabled(browser.outlineItems.isEmpty) + .help("Show article outline") + #elseif os(iOS) + Button { + isShowingOutline = true + } label: { + Image(systemName: "list.bullet") + } + .disabled(browser.outlineItems.isEmpty) + .help("Show article outline") + .popover(isPresented: $isShowingOutline) { + NavigationView { + Group { + if browser.outlineItemTree.isEmpty { + Message(text: "No outline available") + } else { + List(browser.outlineItemTree) { item in + OutlineNode(item: item) { item in + browser.scrollTo(outlineItemID: item.id) isShowingOutline = false - } label: { - Text("Done").fontWeight(.semibold) + dismissSearch() } - } + }.listStyle(.plain) } - }.modify { view in - #if os(macOS) - view - #elseif os(iOS) - if #available(iOS 16.0, *) { - view.presentationDetents([.medium, .large]) - } else { - /* - HACK: Use medium as selection so that half sized sheets are consistently shown - when tab manager button is pressed, user can still freely adjust sheet size. - */ - view.backport.presentationDetents([.medium, .large], selection: .constant(.medium)) + } + .navigationTitle(browser.articleTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + isShowingOutline = false + } label: { + Text("Done").fontWeight(.semibold) + } } - #endif } } + .frame(idealWidth: 360, idealHeight: 600) + .modifier(MarkAsHalfSheet()) } + #endif } struct OutlineNode: View { diff --git a/Views/Buttons/RandomArticleButton.swift b/Views/Buttons/RandomArticleButton.swift deleted file mode 100644 index f81559370..000000000 --- a/Views/Buttons/RandomArticleButton.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// RandomArticleButton.swift -// Kiwix -// -// Created by Chris Li on 8/13/23. -// Copyright © 2023 Chris Li. All rights reserved. -// - -import SwiftUI - -struct RandomArticleButton: View { - @EnvironmentObject private var browser: BrowserViewModel - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \ZimFile.size, ascending: false)], - predicate: ZimFile.openedPredicate - ) private var zimFiles: FetchedResults - - var body: some View { - #if os(macOS) - Button { - browser.loadRandomArticle() - } label: { - Label("Random Article", systemImage: "die.face.5") - } - .disabled(zimFiles.isEmpty) - .help("Show random article") - #elseif os(iOS) - Menu { - ForEach(zimFiles) { zimFile in - Button(zimFile.name) { - browser.loadRandomArticle(zimFileID: zimFile.fileID) - } - } - } label: { - Label("Random Page", systemImage: "die.face.5") - } primaryAction: { - browser.loadRandomArticle() - } - .disabled(zimFiles.isEmpty) - .help("Show random article") - #endif - } -} diff --git a/App/TabsManagement.swift b/Views/Buttons/TabsManagerButton.swift similarity index 51% rename from App/TabsManagement.swift rename to Views/Buttons/TabsManagerButton.swift index a7753a042..f8bf203a4 100644 --- a/App/TabsManagement.swift +++ b/Views/Buttons/TabsManagerButton.swift @@ -1,75 +1,14 @@ // -// TabsManagement.swift +// TabsManagerButton.swift // Kiwix // -// Created by Chris Li on 7/29/23. +// Created by Chris Li on 9/1/23. // Copyright © 2023 Chris Li. All rights reserved. // import SwiftUI #if os(iOS) -@available(iOS 16.0, *) -struct TabsSectionContent: View { - @EnvironmentObject private var navigation: NavigationViewModel - @FetchRequest( - sortDescriptors: [SortDescriptor(\Tab.created, order: .reverse)], - animation: .easeInOut - ) private var tabs: FetchedResults - - private struct Item { - let tab: Tab - let navigationItem: NavigationItem - - init(tab: Tab) { - self.tab = tab - self.navigationItem = NavigationItem.tab(objectID: tab.objectID) - } - } - - var body: some View { - ForEach(tabs.map({ Item(tab: $0) }), id: \.navigationItem) { item in - Group { - if let zimFile = item.tab.zimFile, let category = Category(rawValue: zimFile.category) { - Label { Text(item.tab.title ?? "New Tab") } icon: { - Favicon(category: category, imageData: zimFile.faviconData).frame(width: 22, height: 22) - } - } else { - Label(item.tab.title ?? "New Tab", systemImage: "square") - } - } - .lineLimit(1) - .swipeActions { - Button(role: .destructive) { - Task { await navigation.deleteTab(tabID: item.tab.objectID) } - } label: { - Label("Close Tab", systemImage: "xmark") - } - } - } - } -} - -@available(iOS 16.0, *) -struct NewTabButton: View { - @EnvironmentObject private var navigation: NavigationViewModel - - var body: some View { - Menu { - Button(role: .destructive) { - Task { await navigation.deleteAllTabs() } - } label: { - Label("Close All Tabs", systemImage: "xmark.square.fill") - } - } label: { - Label("New Tab", systemImage: "plus.square") - } primaryAction: { - navigation.createTab() - } - } -} - -@available(iOS 16.0, *) struct TabsManagerButton: View { @EnvironmentObject private var browser: BrowserViewModel @EnvironmentObject private var navigation: NavigationViewModel @@ -93,21 +32,19 @@ struct TabsManagerButton: View { Label("New Tab", systemImage: "plus.square") } Button(role: .destructive) { - Task { - guard case .tab(let tabID) = navigation.currentItem else { return } - await navigation.deleteTab(tabID: tabID) - } + guard case .tab(let tabID) = navigation.currentItem else { return } + navigation.deleteTab(tabID: tabID) } label: { Label("Close This Tab", systemImage: "xmark.square") } Button(role: .destructive) { - Task { await navigation.deleteAllTabs() } + navigation.deleteAllTabs() } label: { Label("Close All Tabs", systemImage: "xmark.square.fill") } } Section { - ForEach(zimFiles) { zimFile in + ForEach(zimFiles.prefix(5)) { zimFile in Button { browser.loadMainArticle(zimFileID: zimFile.fileID) } label: { Label(zimFile.name, systemImage: "house") } @@ -133,22 +70,90 @@ struct TabsManagerButton: View { .sheet(item: $presentedSheet) { presentedSheet in switch presentedSheet { case .tabsManager: - SheetContent { - List(selection: $navigation.currentItem) { - TabsSectionContent() + NavigationView { + TabManager().toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + self.presentedSheet = nil + } label: { + Text("Done").fontWeight(.semibold) + } + } } - .navigationTitle("Tabs") - .navigationBarTitleDisplayMode(.inline) - .toolbar { NewTabButton() } - }.presentationDetents([.medium, .large]) + }.modifier(MarkAsHalfSheet()) case .library: Library() case .settings: - SheetContent { Settings() } + NavigationView { + Settings().toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + self.presentedSheet = nil + } label: { + Text("Done").fontWeight(.semibold) + } + } + } + } } } - .onChange(of: navigation.currentItem) { newValue in - presentedSheet = nil + } +} + +struct TabManager: View { + @Environment(\.dismiss) private var dismiss: DismissAction + @EnvironmentObject private var navigation: NavigationViewModel + @FetchRequest( + sortDescriptors: [SortDescriptor(\Tab.created, order: .reverse)], + animation: .easeInOut + ) private var tabs: FetchedResults + + var body: some View { + List(tabs) { tab in + Button { + if #available(iOS 16.0, *) { + navigation.currentItem = NavigationItem.tab(objectID: tab.objectID) + } else { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + navigation.currentItem = NavigationItem.tab(objectID: tab.objectID) + } + } + } label: { + TabLabel(tab: tab) + } + .listRowBackground( + navigation.currentItem == NavigationItem.tab(objectID: tab.objectID) ? Color.blue.opacity(0.2) : nil + ) + .swipeActions { + Button(role: .destructive) { + navigation.deleteTab(tabID: tab.objectID) + } label: { + Label("Close Tab", systemImage: "xmark") + } + } + } + .listStyle(.plain) + .navigationTitle("Tabs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + Menu { + Button(role: .destructive) { + guard case let .tab(tabID) = navigation.currentItem else { return } + navigation.deleteTab(tabID: tabID) + } label: { + Label("Close This Tab", systemImage: "xmark.square") + } + Button(role: .destructive) { + navigation.deleteAllTabs() + } label: { + Label("Close All Tabs", systemImage: "xmark.square.fill") + } + } label: { + Label("New Tab", systemImage: "plus.square") + } primaryAction: { + navigation.createTab() + } } } } diff --git a/Views/Library/Library.swift b/Views/Library/Library.swift index 66429cf2e..06d9770cd 100644 --- a/Views/Library/Library.swift +++ b/Views/Library/Library.swift @@ -13,9 +13,16 @@ import UniformTypeIdentifiers /// Tabbed library view on iOS & iPadOS struct Library: View { @EnvironmentObject private var viewModel: LibraryViewModel + @SceneStorage("LibraryTabItem") private var tabItem: LibraryTabItem = .opened + + private let defaultTabItem: LibraryTabItem? + + init(tabItem: LibraryTabItem? = nil) { + self.defaultTabItem = tabItem + } var body: some View { - TabView(selection: $viewModel.selectedTabItem) { + TabView(selection: $tabItem) { ForEach(LibraryTabItem.allCases) { tabItem in SheetContent { switch tabItem { @@ -46,9 +53,10 @@ struct Library: View { .tabItem { Label(tabItem.name, systemImage: tabItem.icon) } } }.onAppear { + if let defaultTabItem = defaultTabItem { + tabItem = defaultTabItem + } viewModel.start(isUserInitiated: false) - }.onChange(of: viewModel.selectedTabItem) { _ in - viewModel.selectedZimFile = nil } } } @@ -104,7 +112,7 @@ struct LibraryZimFileContext: ViewModifier { content }.buttonStyle(.plain) #elseif os(iOS) - NavigationLink(tag: zimFile, selection: $viewModel.selectedZimFile) { + NavigationLink { ZimFileDetail(zimFile: zimFile) } label: { content @@ -122,13 +130,13 @@ struct LibraryZimFileContext: ViewModifier { var articleActions: some View { Button { guard let url = ZimFileService.shared.getMainPageURL(zimFileID: zimFile.fileID) else { return } - NotificationCenter.default.post(name: Notification.Name.openURL, object: nil, userInfo: ["url": url]) + NotificationCenter.openURL(url, inNewTab: true) } label: { Label("Main Page", systemImage: "house") } Button { guard let url = ZimFileService.shared.getRandomPageURL(zimFileID: zimFile.fileID) else { return } - NotificationCenter.default.post(name: Notification.Name.openURL, object: nil, userInfo: ["url": url]) + NotificationCenter.openURL(url, inNewTab: true) } label: { Label("Random Page", systemImage: "die.face.5") } @@ -136,11 +144,6 @@ struct LibraryZimFileContext: ViewModifier { @ViewBuilder var supplementaryActions: some View { - Button { - viewModel.selectedZimFile = zimFile - } label: { - Label("Show Detail", systemImage: "info.circle") - } if let downloadURL = zimFile.downloadURL { Button { #if os(macOS) diff --git a/Views/Library/ZimFileDetail.swift b/Views/Library/ZimFileDetail.swift index bfd21f187..9ac816564 100644 --- a/Views/Library/ZimFileDetail.swift +++ b/Views/Library/ZimFileDetail.swift @@ -86,7 +86,7 @@ struct ZimFileDetail: View { } else if zimFile.fileURLBookmark != nil { // zim file is opened Action(title: "Open Main Page") { guard let url = ZimFileService.shared.getMainPageURL(zimFileID: zimFile.fileID) else { return } - NotificationCenter.default.post(name: Notification.Name.openURL, object: nil, userInfo: ["url": url]) + NotificationCenter.openURL(url, inNewTab: true) } #if os(macOS) Action(title: "Reveal in Finder") { diff --git a/Views/Library/ZimFilesCategories.swift b/Views/Library/ZimFilesCategories.swift index 5dd74cb89..b58277097 100644 --- a/Views/Library/ZimFilesCategories.swift +++ b/Views/Library/ZimFilesCategories.swift @@ -16,11 +16,25 @@ struct ZimFilesCategories: View { var body: some View { ZimFilesCategory(category: $selected) + .modifier(ToolbarRoleBrowser()) .navigationTitle(NavigationItem.categories.name) .toolbar { - Picker("Category", selection: $selected) { - ForEach(Category.allCases) { - Text($0.name).tag($0) + #if os(iOS) + ToolbarItem(placement: .navigationBarLeading) { + if #unavailable(iOS 16) { + Button { + NotificationCenter.toggleSidebar() + } label: { + Label("Show Sidebar", systemImage: "sidebar.left") + } + } + } + #endif + ToolbarItem { + Picker("Category", selection: $selected) { + ForEach(Category.allCases) { + Text($0.name).tag($0) + } } } } diff --git a/Views/Library/ZimFilesDownloads.swift b/Views/Library/ZimFilesDownloads.swift index 0b111b32a..54c3f678d 100644 --- a/Views/Library/ZimFilesDownloads.swift +++ b/Views/Library/ZimFilesDownloads.swift @@ -11,31 +11,44 @@ import SwiftUI /// A grid of zim files that are being downloaded. struct ZimFilesDownloads: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @FetchRequest( sortDescriptors: [NSSortDescriptor(keyPath: \DownloadTask.created, ascending: false)], animation: .easeInOut ) private var downloadTasks: FetchedResults var body: some View { - Group { + LazyVGrid( + columns: ([GridItem(.adaptive(minimum: 250, maximum: 500), spacing: 12)]), + alignment: .leading, + spacing: 12 + ) { + ForEach(downloadTasks) { downloadTask in + if let zimFile = downloadTask.zimFile { + DownloadTaskCell(downloadTask).modifier(LibraryZimFileContext(zimFile: zimFile)) + } + } + } + .modifier(GridCommon()) + .modifier(ToolbarRoleBrowser()) + .navigationTitle(NavigationItem.downloads.name) + .overlay { if downloadTasks.isEmpty { Message(text: "No download tasks") - } else { - LazyVGrid( - columns: ([GridItem(.adaptive(minimum: 250, maximum: 500), spacing: 12)]), - alignment: .leading, - spacing: 12 - ) { - ForEach(downloadTasks) { downloadTask in - if let zimFile = downloadTask.zimFile { - DownloadTaskCell(downloadTask).modifier(LibraryZimFileContext(zimFile: zimFile)) -// .modifier(ZimFileContextMenu(selected: $selected, url: $url, zimFile: zimFile)) -// .modifier(ZimFileSelection(selected: $selected, url: $url, zimFile: zimFile)) - } + } + } + .toolbar { + #if os(iOS) + ToolbarItem(placement: .navigationBarLeading) { + if #unavailable(iOS 16), horizontalSizeClass == .regular { + Button { + NotificationCenter.toggleSidebar() + } label: { + Label("Show Sidebar", systemImage: "sidebar.left") } - }.modifier(GridCommon()) + } } + #endif } - .navigationTitle(NavigationItem.downloads.name) } } diff --git a/Views/Library/ZimFilesNew.swift b/Views/Library/ZimFilesNew.swift index b7468e21f..3ae5c3aeb 100644 --- a/Views/Library/ZimFilesNew.swift +++ b/Views/Library/ZimFilesNew.swift @@ -12,6 +12,7 @@ import Defaults /// A grid of zim files that are newly available. struct ZimFilesNew: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @EnvironmentObject var viewModel: LibraryViewModel @Default(.libraryLanguageCodes) private var languageCodes @FetchRequest( @@ -26,42 +27,58 @@ struct ZimFilesNew: View { @State private var searchText = "" var body: some View { - Group { - if zimFiles.isEmpty { - Message(text: "No new zim file") - } else { - LazyVGrid( - columns: ([GridItem(.adaptive(minimum: 250, maximum: 500), spacing: 12)]), - alignment: .leading, - spacing: 12 - ) { - ForEach(zimFiles) { zimFile in - ZimFileCell(zimFile, prominent: .name) - .modifier(LibraryZimFileContext(zimFile: zimFile)) - } - }.modifier(GridCommon()) + LazyVGrid( + columns: ([GridItem(.adaptive(minimum: 250, maximum: 500), spacing: 12)]), + alignment: .leading, + spacing: 12 + ) { + ForEach(zimFiles) { zimFile in + ZimFileCell(zimFile, prominent: .name) + .modifier(LibraryZimFileContext(zimFile: zimFile)) } } + .modifier(GridCommon()) + .modifier(ToolbarRoleBrowser()) .navigationTitle(NavigationItem.new.name) .searchable(text: $searchText) - .onAppear { viewModel.start(isUserInitiated: false) } + .onAppear { + viewModel.start(isUserInitiated: false) + } .onChange(of: languageCodes) { _ in zimFiles.nsPredicate = ZimFilesNew.buildPredicate(searchText: searchText) } .onChange(of: searchText) { searchText in zimFiles.nsPredicate = ZimFilesNew.buildPredicate(searchText: searchText) } + .overlay { + if zimFiles.isEmpty { + Message(text: "No new zim file") + } + } .toolbar { - if viewModel.isInProgress { - ProgressView() - #if os(macOS) - .scaleEffect(0.5) - #endif - } else { - Button { - viewModel.start(isUserInitiated: true) - } label: { - Label("Refresh", systemImage: "arrow.triangle.2.circlepath.circle") + #if os(iOS) + ToolbarItem(placement: .navigationBarLeading) { + if #unavailable(iOS 16), horizontalSizeClass == .regular { + Button { + NotificationCenter.toggleSidebar() + } label: { + Label("Show Sidebar", systemImage: "sidebar.left") + } + } + } + #endif + ToolbarItem { + if viewModel.isInProgress { + ProgressView() + #if os(macOS) + .scaleEffect(0.5) + #endif + } else { + Button { + viewModel.start(isUserInitiated: true) + } label: { + Label("Refresh", systemImage: "arrow.triangle.2.circlepath.circle") + } } } } diff --git a/Views/Library/ZimFilesOpened.swift b/Views/Library/ZimFilesOpened.swift index 8097fbe5a..0b19b0159 100644 --- a/Views/Library/ZimFilesOpened.swift +++ b/Views/Library/ZimFilesOpened.swift @@ -11,6 +11,7 @@ import UniformTypeIdentifiers /// A grid of zim files that are opened, or was open but is now missing. struct ZimFilesOpened: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @FetchRequest( sortDescriptors: [NSSortDescriptor(keyPath: \ZimFile.size, ascending: false)], predicate: ZimFile.withFileURLBookmarkPredicate, @@ -19,37 +20,24 @@ struct ZimFilesOpened: View { @State private var isFileImporterPresented = false var body: some View { - Group { - if zimFiles.isEmpty { - Message(text: "No opened zim file") - } else { - LazyVGrid( - columns: ([GridItem(.adaptive(minimum: 250, maximum: 500), spacing: 12)]), - alignment: .leading, - spacing: 12 - ) { - ForEach(zimFiles) { zimFile in - ZimFileCell(zimFile, prominent: .name) - .modifier(LibraryZimFileContext(zimFile: zimFile)) - } - }.modifier(GridCommon()) + LazyVGrid( + columns: ([GridItem(.adaptive(minimum: 250, maximum: 500), spacing: 12)]), + alignment: .leading, + spacing: 12 + ) { + ForEach(zimFiles) { zimFile in + ZimFileCell(zimFile, prominent: .name).modifier(LibraryZimFileContext(zimFile: zimFile)) } } + .modifier(GridCommon(edges: .all)) + .modifier(ToolbarRoleBrowser()) .navigationTitle(NavigationItem.opened.name) - .toolbar { - Button { - // On iOS/iPadOS 15, fileimporter's isPresented binding is not reset to false if user swipe to dismiss - // the sheet. In order to mitigate the issue, the binding is set to false then true with a 0.1s delay. - isFileImporterPresented = false - DispatchQueue.main.asyncAfter(deadline: .now()+0.1) { - isFileImporterPresented = true - } - isFileImporterPresented = true - } label: { - Label("Open...", systemImage: "plus") - }.help("Open a zim file") + .overlay { + if zimFiles.isEmpty { + Message(text: "No opened zim file") + } } - // not using OpenFileButton here, because it does not work on iOS/iPadOS 15, since this view is in a modal + // not using OpenFileButton here, because it does not work on iOS/iPadOS 15 when this view is in a modal .fileImporter( isPresented: $isFileImporterPresented, allowedContentTypes: [UTType.zimFile], @@ -58,5 +46,31 @@ struct ZimFilesOpened: View { guard case let .success(urls) = result else { return } NotificationCenter.openFiles(urls, context: .library) } + .toolbar { + #if os(iOS) + ToolbarItem(placement: .navigationBarLeading) { + if #unavailable(iOS 16), horizontalSizeClass == .regular { + Button { + NotificationCenter.toggleSidebar() + } label: { + Label("Show Sidebar", systemImage: "sidebar.left") + } + } + } + #endif + ToolbarItem { + Button { + // On iOS/iPadOS 15, fileimporter's isPresented binding is not reset to false if user swipe to dismiss + // the sheet. In order to mitigate the issue, the binding is set to false then true with a 0.1s delay. + isFileImporterPresented = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isFileImporterPresented = true + } + isFileImporterPresented = true + } label: { + Label("Open...", systemImage: "plus") + }.help("Open a zim file") + } + } } } diff --git a/Views/Browser/SearchResults.swift b/Views/SearchResults.swift similarity index 88% rename from Views/Browser/SearchResults.swift rename to Views/SearchResults.swift index 4fee76035..c1192cf1f 100644 --- a/Views/Browser/SearchResults.swift +++ b/Views/SearchResults.swift @@ -23,13 +23,19 @@ struct SearchResults: View { ) private var zimFiles: FetchedResults @State private var isClearSearchConfirmationPresented = false + private let openURL = NotificationCenter.default.publisher(for: .openURL) + var body: some View { Group { if zimFiles.isEmpty { Message(text: "No opened zim file") } else if horizontalSizeClass == .regular { HStack(spacing: 0) { - sidebar.frame(width: 320) + #if os(macOS) + sidebar.frame(width: 250) + #elseif os(iOS) + sidebar.frame(width: 350) + #endif Divider().ignoresSafeArea(.all, edges: .vertical) content.frame(maxWidth: .infinity) }.safeAreaInset(edge: .top, spacing: 0) { @@ -40,13 +46,25 @@ struct SearchResults: View { } else { content } - }.background(Color.background) + } + .background(Color.background) + .onReceive(openURL) { _ in + dismissSearch() + } } @ViewBuilder var content: some View { if viewModel.inProgress { - ProgressView() + VStack { + Spacer() + HStack { + Spacer() + ProgressView() + Spacer() + } + Spacer() + } } else if viewModel.results.isEmpty { Message(text: "No result") } else { @@ -60,10 +78,7 @@ struct SearchResults: View { searchTexts.insert(viewModel.searchText, at: 0) return searchTexts }() - NotificationCenter.default.post( - name: Notification.Name.openURL, object: nil, userInfo: ["url": result.url] - ) - dismissSearch() + NotificationCenter.openURL(result.url) } label: { ArticleCell(result: result, zimFile: viewModel.zimFiles[result.zimFileID]) }.buttonStyle(.plain) diff --git a/Views/Settings/LibrarySettings.swift b/Views/Settings/LibrarySettings.swift deleted file mode 100644 index 41ad1aac5..000000000 --- a/Views/Settings/LibrarySettings.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// LibrarySettings.swift -// Kiwix -// -// Created by Chris Li on 10/2/22. -// Copyright © 2022 Chris Li. All rights reserved. -// - -import SwiftUI - -import Defaults - -struct LibrarySettings: View { - @Default(.backupDocumentDirectory) private var backupDocumentDirectory - @Default(.downloadUsingCellular) private var downloadUsingCellular - @Default(.libraryAutoRefresh) private var libraryAutoRefresh - @Default(.libraryLanguageCodes) private var libraryLanguageCodes - @EnvironmentObject private var viewModel: LibraryViewModel - - var body: some View { - #if os(macOS) - VStack(spacing: 16) { - SettingSection(name: "Catalog") { - HStack(spacing: 6) { - Button("Refresh Now") { - viewModel.start(isUserInitiated: true) - }.disabled(viewModel.isInProgress) - if viewModel.isInProgress { - ProgressView().progressViewStyle(.circular).scaleEffect(0.5).frame(height: 1) - } - Spacer() - Text("Last refresh:").foregroundColor(.secondary) - LibraryLastRefreshTime().foregroundColor(.secondary) - } - VStack(alignment: .leading) { - Toggle("Auto refresh", isOn: $libraryAutoRefresh) - Text("When enabled, the library catalog will be refreshed automatically when outdated.") - .foregroundColor(.secondary) - } - } - SettingSection(name: "Languages", alignment: .top) { - LanguageSelector() - } - } - .padding() - .tabItem { Label("Library", systemImage: "folder.badge.gearshape") } - #elseif os(iOS) - Section { - NavigationLink { - LanguageSelector() - } label: { - HStack { - Text("Languages") - Spacer() - if libraryLanguageCodes.count == 1, - let languageCode = libraryLanguageCodes.first, - let languageName = Locale.current.localizedString(forLanguageCode: languageCode) { - Text(languageName).foregroundColor(.secondary) - } else if libraryLanguageCodes.count > 1 { - Text("\(libraryLanguageCodes.count)").foregroundColor(.secondary) - } - } - } - Toggle("Download using cellular", isOn: $downloadUsingCellular) - } header: { - Text("Library") - } footer: { - Text("Change will only apply to new download tasks.") - } - Section { - HStack { - Text("Last refresh") - Spacer() - LibraryLastRefreshTime().foregroundColor(.secondary) - } - if viewModel.isInProgress { - HStack { - Text("Refreshing...").foregroundColor(.secondary) - Spacer() - ProgressView().progressViewStyle(.circular) - } - } else { - Button("Refresh Now") { - viewModel.start(isUserInitiated: true) - } - } - Toggle("Auto refresh", isOn: $libraryAutoRefresh) - } header: { - Text("Catalog") - } footer: { - Text("When enabled, the library catalog will be refreshed automatically when outdated.") - }.onChange(of: libraryAutoRefresh) { LibraryOperations.applyLibraryAutoRefreshSetting(isEnabled: $0) } - Section { - Toggle("Include zim files in backup", isOn: $backupDocumentDirectory) - } header: { - Text("Backup") - } footer: { - Text("Does not apply to files opened in place.") - }.onChange(of: backupDocumentDirectory) { LibraryOperations.applyFileBackupSetting(isEnabled: $0) } - #endif - } -} - -struct LibrarySettings_Previews: PreviewProvider { - static var previews: some View { - TabView { LibrarySettings() }.frame(width: 480).preferredColorScheme(.light) - TabView { LibrarySettings() }.frame(width: 480).preferredColorScheme(.dark) - } -} diff --git a/Views/Settings/ReadingSettings.swift b/Views/Settings/ReadingSettings.swift deleted file mode 100644 index e142f57b3..000000000 --- a/Views/Settings/ReadingSettings.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// ReadingSettings.swift -// Kiwix -// -// Created by Chris Li on 10/1/22. -// Copyright © 2022 Chris Li. All rights reserved. -// - -import SwiftUI - -import Defaults - -struct ReadingSettings: View { - @Default(.externalLinkLoadingPolicy) private var externalLinkLoadingPolicy - @Default(.searchResultSnippetMode) private var searchResultSnippetMode - @Default(.webViewPageZoom) private var webViewPageZoom - - var body: some View { - #if os(macOS) - VStack(spacing: 16) { - SettingSection(name: "Page zoom") { - HStack { - Stepper(webViewPageZoom.formatted(.percent), value: $webViewPageZoom, in: 0.5...2, step: 0.05) - Spacer() - Button("Reset") { webViewPageZoom = 1 }.disabled(webViewPageZoom == 1) - } - } - SettingSection(name: "External link") { - Picker(selection: $externalLinkLoadingPolicy) { - ForEach(ExternalLinkLoadingPolicy.allCases) { loadingPolicy in - Text(loadingPolicy.name).tag(loadingPolicy) - } - } label: { } - } - SettingSection(name: "Search snippet") { - Picker(selection: $searchResultSnippetMode) { - ForEach(SearchResultSnippetMode.allCases) { snippetMode in - Text(snippetMode.name).tag(snippetMode) - } - } label: { } - } - } - .padding() - .tabItem { Label("Reading", systemImage: "book") } - #elseif os(iOS) - Section { - Stepper(value: $webViewPageZoom, in: 0.5...2, step: 0.05) { - Text("Page zoom: \(Formatter.percent.string(from: NSNumber(value: webViewPageZoom)) ?? "")") - } - Picker("External link", selection: $externalLinkLoadingPolicy) { - ForEach(ExternalLinkLoadingPolicy.allCases) { loadingPolicy in - Text(loadingPolicy.name).tag(loadingPolicy) - } - } - Picker("Search snippet", selection: $searchResultSnippetMode) { - ForEach(SearchResultSnippetMode.allCases) { snippetMode in - Text(snippetMode.name).tag(snippetMode) - } - } - } header: { Text("Reading") } - #endif - } -} - -struct ReadingSettings_Previews: PreviewProvider { - static var previews: some View { - ReadingSettings() - } -} diff --git a/Views/Settings/Settings.swift b/Views/Settings/Settings.swift index e0a77338b..fd6610509 100644 --- a/Views/Settings/Settings.swift +++ b/Views/Settings/Settings.swift @@ -1,5 +1,5 @@ // -// SettingsContent.swift +// Settings.swift // Kiwix // // Created by Chris Li on 10/1/22. @@ -8,24 +8,75 @@ import SwiftUI -#if os(iOS) -struct Settings: View { +import Defaults + +#if os(macOS) +struct ReadingSettings: View { + @Default(.externalLinkLoadingPolicy) private var externalLinkLoadingPolicy + @Default(.searchResultSnippetMode) private var searchResultSnippetMode + @Default(.webViewPageZoom) private var webViewPageZoom + var body: some View { - List { - ReadingSettings() - LibrarySettings() - Section("Misc") { - Button("Feedback") { UIApplication.shared.open(URL(string: "mailto:feedback@kiwix.org")!) } - Button("Rate the App") { - let url = URL(string:"itms-apps://itunes.apple.com/us/app/kiwix/id997079563?action=write-review")! - UIApplication.shared.open(url) + VStack(spacing: 16) { + SettingSection(name: "Page zoom") { + HStack { + Stepper(webViewPageZoom.formatted(.percent), value: $webViewPageZoom, in: 0.5...2, step: 0.05) + Spacer() + Button("Reset") { webViewPageZoom = 1 }.disabled(webViewPageZoom == 1) } - NavigationLink("About") { About() } } - }.navigationTitle("Settings") + SettingSection(name: "External link") { + Picker(selection: $externalLinkLoadingPolicy) { + ForEach(ExternalLinkLoadingPolicy.allCases) { loadingPolicy in + Text(loadingPolicy.name).tag(loadingPolicy) + } + } label: { } + } + SettingSection(name: "Search snippet") { + Picker(selection: $searchResultSnippetMode) { + ForEach(SearchResultSnippetMode.allCases) { snippetMode in + Text(snippetMode.name).tag(snippetMode) + } + } label: { } + } + } + .padding() + .tabItem { Label("Reading", systemImage: "book") } + } +} + +struct LibrarySettings: View { + @Default(.libraryAutoRefresh) private var libraryAutoRefresh + @EnvironmentObject private var library: LibraryViewModel + + var body: some View { + VStack(spacing: 16) { + SettingSection(name: "Catalog") { + HStack(spacing: 6) { + Button("Refresh Now") { + library.start(isUserInitiated: true) + }.disabled(library.isInProgress) + if library.isInProgress { + ProgressView().progressViewStyle(.circular).scaleEffect(0.5).frame(height: 1) + } + Spacer() + Text("Last refresh:").foregroundColor(.secondary) + LibraryLastRefreshTime().foregroundColor(.secondary) + } + VStack(alignment: .leading) { + Toggle("Auto refresh", isOn: $libraryAutoRefresh) + Text("When enabled, the library catalog will be refreshed automatically when outdated.") + .foregroundColor(.secondary) + } + } + SettingSection(name: "Languages", alignment: .top) { + LanguageSelector() + } + } + .padding() + .tabItem { Label("Library", systemImage: "folder.badge.gearshape") } } } -#endif struct SettingSection: View { let name: String @@ -50,3 +101,130 @@ struct SettingSection: View { } } } + +#elseif os(iOS) + +struct Settings: View { + @Default(.backupDocumentDirectory) private var backupDocumentDirectory + @Default(.downloadUsingCellular) private var downloadUsingCellular + @Default(.externalLinkLoadingPolicy) private var externalLinkLoadingPolicy + @Default(.libraryAutoRefresh) private var libraryAutoRefresh + @Default(.searchResultSnippetMode) private var searchResultSnippetMode + @Default(.webViewPageZoom) private var webViewPageZoom + @EnvironmentObject private var library: LibraryViewModel + + enum Route { + case languageSelector, about + } + + var body: some View { + List { + readingSettings + librarySettings + catalogSettings + backupSettings + miscellaneous + } + .modifier(ToolbarRoleBrowser()) + .navigationTitle("Settings") + } + + var readingSettings: some View { + Section("Reading") { + Stepper(value: $webViewPageZoom, in: 0.5...2, step: 0.05) { + Text("Page zoom: \(Formatter.percent.string(from: NSNumber(value: webViewPageZoom)) ?? "")") + } + Picker("External link", selection: $externalLinkLoadingPolicy) { + ForEach(ExternalLinkLoadingPolicy.allCases) { loadingPolicy in + Text(loadingPolicy.name).tag(loadingPolicy) + } + } + Picker("Search snippet", selection: $searchResultSnippetMode) { + ForEach(SearchResultSnippetMode.allCases) { snippetMode in + Text(snippetMode.name).tag(snippetMode) + } + } + } + } + + var librarySettings: some View { + Section { + NavigationLink { + LanguageSelector() + } label: { + SelectedLanaguageLabel() + } + Toggle("Download using cellular", isOn: $downloadUsingCellular) + } header: { + Text("Library") + } footer: { + Text("Change will only apply to new download tasks.") + } + } + + var catalogSettings: some View { + Section { + HStack { + Text("Last refresh") + Spacer() + LibraryLastRefreshTime().foregroundColor(.secondary) + } + if library.isInProgress { + HStack { + Text("Refreshing...").foregroundColor(.secondary) + Spacer() + ProgressView().progressViewStyle(.circular) + } + } else { + Button("Refresh Now") { + library.start(isUserInitiated: true) + } + } + Toggle("Auto refresh", isOn: $libraryAutoRefresh) + } header: { + Text("Catalog") + } footer: { + Text("When enabled, the library catalog will be refreshed automatically when outdated.") + }.onChange(of: libraryAutoRefresh) { LibraryOperations.applyLibraryAutoRefreshSetting(isEnabled: $0) } + } + + var backupSettings: some View { + Section { + Toggle("Include zim files in backup", isOn: $backupDocumentDirectory) + } header: { + Text("Backup") + } footer: { + Text("Does not apply to files opened in place.") + }.onChange(of: backupDocumentDirectory) { LibraryOperations.applyFileBackupSetting(isEnabled: $0) } + } + + var miscellaneous: some View { + Section("Misc") { + Button("Feedback") { UIApplication.shared.open(URL(string: "mailto:feedback@kiwix.org")!) } + Button("Rate the App") { + let url = URL(string:"itms-apps://itunes.apple.com/us/app/kiwix/id997079563?action=write-review")! + UIApplication.shared.open(url) + } + NavigationLink("About") { About() } + } + } +} + +private struct SelectedLanaguageLabel: View { + @Default(.libraryLanguageCodes) private var languageCodes + + var body: some View { + HStack { + Text("Languages") + Spacer() + if languageCodes.count == 1, + let languageCode = languageCodes.first, + let languageName = Locale.current.localizedString(forLanguageCode: languageCode) { + Text(languageName).foregroundColor(.secondary) + } else if languageCodes.count > 1 { + Text("\(languageCodes.count)").foregroundColor(.secondary) + } + } + } +} +#endif diff --git a/Views/ViewModifiers/GridCommon.swift b/Views/ViewModifiers/GridCommon.swift index 08bbf3d44..4d2507725 100644 --- a/Views/ViewModifiers/GridCommon.swift +++ b/Views/ViewModifiers/GridCommon.swift @@ -10,6 +10,7 @@ import SwiftUI /// Add padding around the modified view. On iOS, the padding is adjusted so that the modified view align with the search bar. struct GridCommon: ViewModifier { + @Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.verticalSizeClass) var verticalSizeClass let edges: Edge.Set? @@ -27,8 +28,10 @@ struct GridCommon: ViewModifier { GeometryReader { proxy in ScrollView { content.padding( - edges ?? (verticalSizeClass == .compact ? .all : [.horizontal, .bottom]), - proxy.size.width > 375 ? 20 : 16 + edges ?? ( + horizontalSizeClass == .compact || verticalSizeClass == .compact ? [.horizontal, .bottom] : .all + ), + proxy.size.width > 380 && verticalSizeClass == .regular ? 20 : 16 ) } } diff --git a/Views/ViewModifiers/ViewModifiers.swift b/Views/ViewModifiers/ViewModifiers.swift new file mode 100644 index 000000000..bd296f61b --- /dev/null +++ b/Views/ViewModifiers/ViewModifiers.swift @@ -0,0 +1,37 @@ +// +// ToolbarRoleBrowser.swift +// Kiwix +// +// Created by Chris Li on 9/1/23. +// Copyright © 2023 Chris Li. All rights reserved. +// + +import SwiftUI + +struct MarkAsHalfSheet: ViewModifier { + func body(content: Content) -> some View { + if #available(macOS 13.0, iOS 16.0, *) { + content.presentationDetents([.medium, .large]) + } else { + /* + HACK: Use medium as selection so that half sized sheets are consistently shown + when tab manager button is pressed, user can still freely adjust sheet size. + */ + content.backport.presentationDetents([.medium, .large], selection: .constant(.medium)) + } + } +} + +struct ToolbarRoleBrowser: ViewModifier { + func body(content: Content) -> some View { + #if os(macOS) + content + #elseif os(iOS) + if #available(iOS 16.0, *) { + content.toolbarRole(.browser) + } else { + content + } + #endif + } +} diff --git a/Views/Browser/Welcome.swift b/Views/Welcome.swift similarity index 85% rename from Views/Browser/Welcome.swift rename to Views/Welcome.swift index 39f3ca95e..3c079fe44 100644 --- a/Views/Browser/Welcome.swift +++ b/Views/Welcome.swift @@ -10,6 +10,7 @@ import SwiftUI struct Welcome: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @EnvironmentObject private var browser: BrowserViewModel @EnvironmentObject private var library: LibraryViewModel @EnvironmentObject private var navigation: NavigationViewModel @FetchRequest( @@ -25,11 +26,20 @@ struct Welcome: View { var body: some View { if zimFiles.isEmpty { - VStack(spacing: 20) { + HStack { Spacer() - logo - Divider() - actions + VStack(spacing: 20) { + Spacer() + logo + Divider() + actions + Spacer() + } + #if os(macOS) + .frame(maxWidth: 300) + #elseif os(iOS) + .frame(maxWidth: 600) + #endif Spacer() } .padding() @@ -46,13 +56,9 @@ struct Welcome: View { } #endif } - #if os(macOS) - .frame(maxWidth: 300) - #elseif os(iOS) - .frame(maxWidth: 600) + #if os(iOS) .sheet(isPresented: $isLibraryPresented) { - // TODO: show categories directly - Library() + Library(tabItem: .categories) } #endif } else { @@ -64,7 +70,8 @@ struct Welcome: View { GridSection(title: "Main Page") { ForEach(zimFiles) { zimFile in Button { - NotificationCenter.openURL(ZimFileService.shared.getMainPageURL(zimFileID: zimFile.fileID)) + guard let url = ZimFileService.shared.getMainPageURL(zimFileID: zimFile.fileID) else { return } + browser.load(url: url) } label: { ZimFileCell(zimFile, prominent: .name) }.buttonStyle(.plain) @@ -74,7 +81,7 @@ struct Welcome: View { GridSection(title: "Bookmarks") { ForEach(bookmarks.prefix(6)) { bookmark in Button { - NotificationCenter.openURL(bookmark.articleURL) + browser.load(url: bookmark.articleURL) } label: { ArticleCell(bookmark: bookmark) }