mirror of
https://github.com/kiwix/kiwix-apple.git
synced 2025-08-04 04:57:05 -04:00
356 lines
13 KiB
Swift
356 lines
13 KiB
Swift
// This file is part of Kiwix for iOS & macOS.
|
|
//
|
|
// Kiwix is free software; you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation; either version 3 of the License, or
|
|
// any later version.
|
|
//
|
|
// Kiwix is distributed in the hope that it will be useful, but
|
|
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
// General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
|
|
|
//
|
|
// CompactViewController.swift
|
|
// Kiwix
|
|
|
|
#if os(iOS)
|
|
import Combine
|
|
import SwiftUI
|
|
import UIKit
|
|
import CoreData
|
|
import Defaults
|
|
|
|
final class CompactViewController: UIHostingController<AnyView>, UISearchControllerDelegate, UISearchResultsUpdating {
|
|
private let searchViewModel: SearchViewModel
|
|
private let searchController: UISearchController
|
|
private var searchTextObserver: AnyCancellable?
|
|
private var openURLObserver: NSObjectProtocol?
|
|
|
|
private var trailingNavItemGroups: [UIBarButtonItemGroup] = []
|
|
private var rightNavItem: UIBarButtonItem?
|
|
private let navigation: NavigationViewModel
|
|
private var navigationItemObserver: AnyCancellable?
|
|
|
|
init(navigation: NavigationViewModel) {
|
|
self.navigation = navigation
|
|
searchViewModel = SearchViewModel.shared
|
|
let searchResult = SearchResults().environmentObject(searchViewModel)
|
|
searchController = UISearchController(searchResultsController: UIHostingController(rootView: searchResult))
|
|
super.init(rootView: AnyView(CompactViewWrapper()))
|
|
searchController.searchResultsUpdater = self
|
|
}
|
|
|
|
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
navigationItemObserver = navigation.$currentItem
|
|
.receive(on: DispatchQueue.main)
|
|
.sink(receiveValue: { [weak self] currentItem in
|
|
if currentItem != .loading {
|
|
self?.navigationController?.isToolbarHidden = false
|
|
self?.navigationController?.isNavigationBarHidden = false
|
|
// listen to only the first change from .loading to something else
|
|
self?.navigationItemObserver?.cancel()
|
|
}
|
|
})
|
|
|
|
// the .loading initial state:
|
|
navigationController?.isToolbarHidden = true
|
|
navigationController?.isNavigationBarHidden = true
|
|
// eof .loading initial state
|
|
|
|
definesPresentationContext = true
|
|
navigationController?.toolbar.scrollEdgeAppearance = {
|
|
let apperance = UIToolbarAppearance()
|
|
apperance.configureWithDefaultBackground()
|
|
return apperance
|
|
}()
|
|
navigationItem.scrollEdgeAppearance = {
|
|
let apperance = UINavigationBarAppearance()
|
|
apperance.configureWithDefaultBackground()
|
|
return apperance
|
|
}()
|
|
searchController.searchBar.autocorrectionType = .no
|
|
navigationItem.titleView = searchController.searchBar
|
|
searchController.automaticallyShowsCancelButton = false
|
|
searchController.delegate = self
|
|
searchController.hidesNavigationBarDuringPresentation = false
|
|
searchController.showsSearchResultsController = true
|
|
searchController.searchBar.searchTextField.placeholder = LocalString.common_search
|
|
|
|
searchTextObserver = searchViewModel.$searchText.sink { [weak self] searchText in
|
|
guard self?.searchController.searchBar.text != searchText else { return }
|
|
self?.searchController.searchBar.text = searchText
|
|
}
|
|
openURLObserver = NotificationCenter.default.addObserver(
|
|
forName: .openURL, object: nil, queue: nil
|
|
) { [weak self] _ in
|
|
self?.searchController.isActive = false
|
|
self?.navigationItem.setRightBarButton(nil, animated: true)
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
func willPresentSearchController(_ searchController: UISearchController) {
|
|
navigationController?.setToolbarHidden(true, animated: true)
|
|
trailingNavItemGroups = navigationItem.trailingItemGroups
|
|
navigationItem.setRightBarButton(
|
|
UIBarButtonItem(
|
|
title: LocalString.common_button_cancel,
|
|
style: .done,
|
|
target: self,
|
|
action: #selector(onSearchCancelled)
|
|
),
|
|
animated: true
|
|
)
|
|
}
|
|
@objc func onSearchCancelled() {
|
|
searchController.isActive = false
|
|
navigationItem.setRightBarButtonItems(nil, animated: false)
|
|
navigationItem.trailingItemGroups = trailingNavItemGroups
|
|
}
|
|
|
|
func willDismissSearchController(_ searchController: UISearchController) {
|
|
navigationController?.setToolbarHidden(false, animated: true)
|
|
searchViewModel.searchText = ""
|
|
navigationItem.trailingItemGroups = trailingNavItemGroups
|
|
}
|
|
|
|
func updateSearchResults(for searchController: UISearchController) {
|
|
searchViewModel.searchText = searchController.searchBar.text ?? ""
|
|
}
|
|
}
|
|
|
|
private struct CompactViewWrapper: View {
|
|
@EnvironmentObject private var navigation: NavigationViewModel
|
|
|
|
var body: some View {
|
|
if case .loading = navigation.currentItem {
|
|
LoadingDataView()
|
|
} else if case let .tab(tabID) = navigation.currentItem {
|
|
CompactView(tabID: tabID)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct CompactView: View {
|
|
@EnvironmentObject private var navigation: NavigationViewModel
|
|
@EnvironmentObject private var library: LibraryViewModel
|
|
@State private var presentedSheet: PresentedSheet?
|
|
@ObservedObject private var browser: BrowserViewModel
|
|
|
|
private enum PresentedSheet: Identifiable {
|
|
case library(downloads: Bool)
|
|
case settings
|
|
var id: String {
|
|
switch self {
|
|
case .library(true): return "library-downloads"
|
|
case .library(false): return "library"
|
|
case .settings: return "settings"
|
|
}
|
|
}
|
|
}
|
|
|
|
init(tabID: NSManagedObjectID) {
|
|
self.browser = BrowserViewModel.getCached(tabID: tabID)
|
|
}
|
|
|
|
private func dismiss() {
|
|
presentedSheet = nil
|
|
}
|
|
|
|
var body: some View {
|
|
let model = if FeatureFlags.hasLibrary {
|
|
CatalogLaunchViewModel(library: library, browser: browser)
|
|
} else {
|
|
NoCatalogLaunchViewModel(browser: browser)
|
|
}
|
|
Content(browser: browser, tabID: browser.tabID, showLibrary: {
|
|
if presentedSheet == nil {
|
|
presentedSheet = .library(downloads: false)
|
|
} else {
|
|
// there's a sheet already presented by the user
|
|
// do nothing
|
|
}
|
|
}, model: model)
|
|
.id(browser.tabID)
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .bottomBar) {
|
|
Spacer()
|
|
NavigationButtons(
|
|
goBack: { [weak browser] in
|
|
browser?.webView.goBack()
|
|
},
|
|
goForward: { [weak browser] in
|
|
browser?.webView.goForward()
|
|
})
|
|
Spacer()
|
|
OutlineButton(browser: browser)
|
|
Spacer()
|
|
BookmarkButton(articleBookmarked: browser.articleBookmarked,
|
|
isButtonDisabled: browser.zimFileName.isEmpty,
|
|
createBookmark: { [weak browser] in browser?.createBookmark() },
|
|
deleteBookmark: { [weak browser] in browser?.deleteBookmark() })
|
|
Spacer()
|
|
ExportButton(
|
|
webViewURL: browser.webView.url,
|
|
pageDataWithExtension: browser.pageDataWithExtension,
|
|
isButtonDisabled: browser.zimFileName.isEmpty
|
|
)
|
|
Spacer()
|
|
TabsManagerButton()
|
|
Spacer()
|
|
if FeatureFlags.hasLibrary {
|
|
Button {
|
|
presentedSheet = .library(downloads: false)
|
|
} label: {
|
|
Label(LocalString.common_tab_menu_library, systemImage: "folder")
|
|
}
|
|
Spacer()
|
|
}
|
|
Button {
|
|
presentedSheet = .settings
|
|
} label: {
|
|
Label(LocalString.common_tab_menu_settings, systemImage: "gear")
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
.sheet(item: $presentedSheet) { presentedSheet in
|
|
switch presentedSheet {
|
|
case .library(downloads: false):
|
|
Library(dismiss: dismiss)
|
|
case .library(downloads: true):
|
|
Library(dismiss: dismiss, tabItem: .downloads)
|
|
case .settings:
|
|
NavigationStack {
|
|
Settings().toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button {
|
|
self.presentedSheet = nil
|
|
} label: {
|
|
Text(LocalString.common_button_done).fontWeight(.semibold)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onReceive(navigation.showDownloads) { _ in
|
|
switch presentedSheet {
|
|
case .library:
|
|
// switching to the downloads tab
|
|
// is done within Library
|
|
break
|
|
case .settings, nil:
|
|
presentedSheet = .library(downloads: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct Content<LaunchModel>: View where LaunchModel: LaunchProtocol {
|
|
@ObservedObject var browser: BrowserViewModel
|
|
let tabID: NSManagedObjectID?
|
|
let showLibrary: () -> Void
|
|
@ObservedObject var model: LaunchModel
|
|
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
@EnvironmentObject private var library: LibraryViewModel
|
|
@EnvironmentObject private var navigation: NavigationViewModel
|
|
@FetchRequest(
|
|
sortDescriptors: [NSSortDescriptor(keyPath: \ZimFile.size, ascending: false)],
|
|
predicate: ZimFile.openedPredicate
|
|
) private var zimFiles: FetchedResults<ZimFile>
|
|
|
|
/// this is still hacky a bit, as the change from here re-validates the view
|
|
/// which triggers the model to be revalidated
|
|
@Default(.hasSeenCategories) private var hasSeenCategories
|
|
|
|
var body: some View {
|
|
Group {
|
|
// swiftlint:disable:next redundant_discardable_let
|
|
let _ = model.updateWith(hasZimFiles: !zimFiles.isEmpty,
|
|
hasSeenCategories: hasSeenCategories)
|
|
switch model.state {
|
|
case .loadingData:
|
|
LoadingDataView()
|
|
case .webPage(let isLoading):
|
|
WebView(browser: browser)
|
|
.ignoresSafeArea()
|
|
.overlay {
|
|
if isLoading {
|
|
LoadingProgressView()
|
|
}
|
|
}
|
|
case .catalog(let catalogSequence):
|
|
switch catalogSequence {
|
|
case .fetching:
|
|
FetchingCatalogView()
|
|
case .list:
|
|
LocalLibraryList(browser: browser)
|
|
case .welcome(let welcomeViewState):
|
|
WelcomeCatalog(viewState: welcomeViewState)
|
|
}
|
|
}
|
|
}
|
|
.focusedSceneValue(\.isBrowserURLSet, browser.url != nil)
|
|
.focusedSceneValue(\.canGoBack, browser.canGoBack)
|
|
.focusedSceneValue(\.canGoForward, browser.canGoForward)
|
|
.modifier(ExternalLinkHandler(externalURL: $browser.externalURL))
|
|
.onAppear { [weak browser] in
|
|
browser?.updateLastOpened()
|
|
}
|
|
.onDisappear { [weak browser] in
|
|
if tabID != nil {
|
|
browser?.pauseVideoWhenNotInPIP()
|
|
browser?.persistState()
|
|
}
|
|
}
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .primaryAction) {
|
|
Button(LocalString.article_shortcut_random_button_title_ios,
|
|
systemImage: "die.face.5",
|
|
action: { [weak browser] in browser?.loadRandomArticle() })
|
|
.disabled(zimFiles.isEmpty)
|
|
ContentSearchButton(browser: browser)
|
|
}
|
|
}
|
|
.onChange(of: scenePhase) { newValue in
|
|
if case .active = newValue {
|
|
browser.refreshVideoState()
|
|
}
|
|
}
|
|
.onChange(of: library.state) { state in
|
|
guard state == .complete else { return }
|
|
showTheLibrary()
|
|
}
|
|
}
|
|
|
|
private func showTheLibrary() {
|
|
guard model.state.shouldShowCatalog else { return }
|
|
#if os(macOS)
|
|
navigation.currentItem = .categories
|
|
#else
|
|
if horizontalSizeClass == .regular {
|
|
navigation.currentItem = .categories
|
|
} else {
|
|
showLibrary()
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
#endif
|