diff --git a/App/App_macOS.swift b/App/App_macOS.swift index 7ddbcb2e..59c94ef9 100644 --- a/App/App_macOS.swift +++ b/App/App_macOS.swift @@ -127,7 +127,7 @@ struct RootView: View { } detail: { switch navigation.currentItem { case .loading: - LoadingView() + LoadingDataView() case .reading: BrowserTab().environmentObject(browser) .withHostingWindow { window in diff --git a/App/CompactViewController.swift b/App/CompactViewController.swift index 9e573067..5250adb6 100644 --- a/App/CompactViewController.swift +++ b/App/CompactViewController.swift @@ -22,6 +22,7 @@ import Combine import SwiftUI import UIKit import CoreData +import Defaults final class CompactViewController: UIHostingController, UISearchControllerDelegate, UISearchResultsUpdating { private let searchViewModel: SearchViewModel @@ -133,6 +134,7 @@ final class CompactViewController: UIHostingController, UISearchControl private struct CompactView: View { @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var library: LibraryViewModel @State private var presentedSheet: PresentedSheet? private enum PresentedSheet: String, Identifiable { @@ -149,8 +151,14 @@ private struct CompactView: View { var body: some View { if case .loading = navigation.currentItem { - LoadingView() + LoadingDataView() } else if case let .tab(tabID) = navigation.currentItem { + let browser = BrowserViewModel.getCached(tabID: tabID) + let model = if FeatureFlags.hasLibrary { + CatalogLaunchViewModel(library: library, browser: browser) + } else { + NoCatalogLaunchViewModel(browser: browser) + } Content(tabID: tabID, showLibrary: { if presentedSheet == nil { presentedSheet = .library @@ -158,7 +166,7 @@ private struct CompactView: View { // there's a sheet already presented by the user // do nothing } - }) + }, model: model) .id(tabID) .toolbar { ToolbarItemGroup(placement: .bottomBar) { @@ -189,7 +197,7 @@ private struct CompactView: View { Spacer() } } - .environmentObject(BrowserViewModel.getCached(tabID: tabID)) + .environmentObject(browser) .sheet(item: $presentedSheet) { presentedSheet in switch presentedSheet { case .library: @@ -212,22 +220,49 @@ private struct CompactView: View { } } -private struct Content: View { +private struct Content: View where LaunchModel: LaunchProtocol { @Environment(\.scenePhase) private var scenePhase + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @EnvironmentObject private var browser: BrowserViewModel + @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 + + /// 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 let tabID: NSManagedObjectID? let showLibrary: () -> Void + @ObservedObject var model: LaunchModel var body: some View { Group { - if browser.url == nil { - Welcome(showLibrary: showLibrary) - } else { - WebView().ignoresSafeArea() + // 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() + .ignoresSafeArea() + .overlay { + if isLoading { + LoadingProgressView() + } + } + case .catalog(let catalogSequence): + switch catalogSequence { + case .fetching: + FetchingCatalogView() + case .list: + LocalLibraryList() + case .welcome(let welcomeViewState): + WelcomeCatalog(viewState: welcomeViewState) + } } } .focusedSceneValue(\.browserViewModel, browser) @@ -262,6 +297,23 @@ private struct Content: View { 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 diff --git a/App/SplitViewController.swift b/App/SplitViewController.swift index 85f51973..cefd138e 100644 --- a/App/SplitViewController.swift +++ b/App/SplitViewController.swift @@ -141,7 +141,7 @@ final class SplitViewController: UISplitViewController { let controller = UIHostingController(rootView: Settings()) setViewController(UINavigationController(rootViewController: controller), for: .secondary) case .loading: - let controller = UIHostingController(rootView: LoadingView()) + let controller = UIHostingController(rootView: LoadingDataView()) setViewController(UINavigationController(rootViewController: controller), for: .secondary) default: let controller = UIHostingController(rootView: Text("vc-not-implemented")) diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index 0c082557..40dc48a3 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -51,6 +51,7 @@ final class BrowserViewModel: NSObject, ObservableObject, // MARK: - Properties + @Published private(set) var isLoading: Bool? @Published private(set) var canGoBack = false @Published private(set) var canGoForward = false @Published private(set) var articleTitle: String = "" @@ -58,6 +59,7 @@ final class BrowserViewModel: NSObject, ObservableObject, @Published private(set) var articleBookmarked = false @Published private(set) var outlineItems = [OutlineItem]() @Published private(set) var outlineItemTree = [OutlineItem]() + @MainActor @Published private(set) var hasURL: Bool = false @MainActor @Published private(set) var url: URL? { didSet { if !FeatureFlags.hasLibrary, url == nil { @@ -67,6 +69,7 @@ final class BrowserViewModel: NSObject, ObservableObject, bookmarkFetchedResultsController.fetchRequest.predicate = Self.bookmarksPredicateFor(url: url) try? bookmarkFetchedResultsController.performFetch() } + hasURL = url != nil } } @Published var externalURL: URL? @@ -87,6 +90,7 @@ final class BrowserViewModel: NSObject, ObservableObject, } #endif let webView: WKWebView + private var isLoadingObserver: NSKeyValueObservation? private var canGoBackObserver: NSKeyValueObservation? private var canGoForwardObserver: NSKeyValueObservation? private var titleURLObserver: AnyCancellable? @@ -96,8 +100,8 @@ final class BrowserViewModel: NSObject, ObservableObject, // MARK: - Lifecycle - @MainActor - init(tabID: NSManagedObjectID? = nil) { + // swiftlint:disable:next function_body_length + @MainActor init(tabID: NSManagedObjectID? = nil) { self.tabID = tabID webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) if !Bundle.main.isProduction, #available(iOS 16.4, macOS 13.3, *) { @@ -159,6 +163,14 @@ final class BrowserViewModel: NSObject, ObservableObject, guard let title, let url else { return } self?.didUpdate(title: title, url: url) } + + isLoadingObserver = webView.observe(\.isLoading, options: .new) { [weak self] _, change in + Task { @MainActor [weak self] in + if change.newValue != self?.isLoading { + self?.isLoading = change.newValue + } + } + } } /// Get the webpage in a binary format diff --git a/ViewModel/LaunchViewModel.swift b/ViewModel/LaunchViewModel.swift new file mode 100644 index 00000000..2c8aea0a --- /dev/null +++ b/ViewModel/LaunchViewModel.swift @@ -0,0 +1,172 @@ +// This file is part of Kiwix for iOS & macOS. +// +// Kiwix is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// any later version. +// +// Kiwix is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kiwix; If not, see https://www.gnu.org/licenses/. + +import Foundation +import Combine +import Defaults + +enum LaunchSequence: Equatable { + case loadingData + case webPage(isLoading: Bool) + case catalog(CatalogSequence) + + var shouldShowCatalog: Bool { + switch self { + case .loadingData: return true + case .webPage: return false + case .catalog(.fetching): return true + case .catalog(.welcome): return true + case .catalog(.list): return false + } + } +} + +enum CatalogSequence: Equatable { + case fetching + case list + case welcome(WelcomeViewState) +} + +enum WelcomeViewState: Equatable { + case loading + case error + case complete +} + +protocol LaunchProtocol: ObservableObject { + var state: LaunchSequence { get } + func updateWith(hasZimFiles: Bool, hasSeenCategories: Bool) +} + +// MARK: No Library (custom apps) + +/// Keeps us int the .loadingData state, +/// while the main page is not fully loaded for the first time +final class NoCatalogLaunchViewModel: LaunchViewModelBase { + + private static var wasLoaded = false + + convenience init(browser: BrowserViewModel) { + self.init(browserIsLoading: browser.$isLoading) + } + + /// - Parameter browserIsLoading: assumed to start with a nil value (see: WKWebView.isLoading) + init(browserIsLoading: Published.Publisher) { + super.init() + browserIsLoading.sink { [weak self] (isLoading: Bool?) in + guard let self = self else { return } + switch isLoading { + case .none: + updateTo(.loadingData) + case .some(true): + if !Self.wasLoaded { + updateTo(.loadingData) + } else { + updateTo(.webPage(isLoading: true)) + } + case .some(false): + Self.wasLoaded = true + updateTo(.webPage(isLoading: false)) + } + }.store(in: &cancellables) + } + + override func updateWith(hasZimFiles: Bool, hasSeenCategories: Bool) { + // to be ignored on purpose + } +} + +// MARK: With Catalog Library +final class CatalogLaunchViewModel: LaunchViewModelBase { + + private var hasZIMFiles = CurrentValueSubject(false) + private var hasSeenCategoriesOnce = CurrentValueSubject(false) + + convenience init(library: LibraryViewModel, + browser: BrowserViewModel) { + self.init(libraryState: library.$state, browserIsLoading: browser.$isLoading) + } + + // swiftlint:disable closure_parameter_position + init(libraryState: Published.Publisher, + browserIsLoading: Published.Publisher) { + super.init() + + hasZIMFiles.combineLatest( + libraryState, + browserIsLoading, + hasSeenCategoriesOnce + ).sink { [weak self] ( + hasZIMs: Bool, + libState: LibraryState, + isBrowserLoading: Bool?, + hasSeenCategories: Bool + ) in + guard let self = self else { return } + + switch (isBrowserLoading, hasZIMs, libState) { + + // MARK: initial app start state + case (_, _, .initial): updateTo(.loadingData) + + // MARK: browser must be empty as there are no ZIMs: + case (_, false, .inProgress): + if hasSeenCategories { + updateTo(.catalog(.welcome(.loading))) + } else { + updateTo(.catalog(.fetching)) + } + case (_, false, .complete): + if hasSeenCategories { + updateTo(.catalog(.welcome(.complete))) + } else { + updateTo(.catalog(.fetching)) + } + case (_, false, .error): + // safety path to display the welcome buttons + // in case of a fetch error, the user can try again + hasSeenCategoriesOnce.send(true) + updateTo(.catalog(.welcome(.error))) + + // MARK: has zims and opens a new empty tab + case (.none, true, _): updateTo(.catalog(.list)) + + // MARK: actively browsing + case (.some(true), true, _): updateTo(.webPage(isLoading: true)) + case (.some(false), true, _): updateTo(.webPage(isLoading: false)) + } + }.store(in: &cancellables) + } + // swiftlint:enable closure_parameter_position + + override func updateWith(hasZimFiles: Bool, hasSeenCategories: Bool) { + hasZIMFiles.send(hasZimFiles) + hasSeenCategoriesOnce.send(hasSeenCategories) + } +} + +class LaunchViewModelBase: LaunchProtocol, ObservableObject { + var state: LaunchSequence = .loadingData + var cancellables = Set() + + func updateTo(_ newState: LaunchSequence) { + guard newState != state else { return } + state = newState + } + + func updateWith(hasZimFiles: Bool, hasSeenCategories: Bool) { + fatalError("should be overriden") + } +} diff --git a/Views/BrowserTab.swift b/Views/BrowserTab.swift index 23d0f307..25e191a5 100644 --- a/Views/BrowserTab.swift +++ b/Views/BrowserTab.swift @@ -14,18 +14,25 @@ // along with Kiwix; If not, see https://www.gnu.org/licenses/. import SwiftUI +import Defaults /// This is macOS and iPad only specific, not used on iPhone struct BrowserTab: View { @Environment(\.scenePhase) private var scenePhase @EnvironmentObject private var browser: BrowserViewModel + @EnvironmentObject private var library: LibraryViewModel @StateObject private var search = SearchViewModel() var body: some View { - Content().toolbar { - #if os(macOS) + let model = if FeatureFlags.hasLibrary { + CatalogLaunchViewModel(library: library, browser: browser) + } else { + NoCatalogLaunchViewModel(browser: browser) + } + Content(model: model).toolbar { +#if os(macOS) ToolbarItemGroup(placement: .navigation) { NavigationButtons() } - #elseif os(iOS) +#elseif os(iOS) ToolbarItemGroup(placement: .navigationBarLeading) { if #unavailable(iOS 16) { Button { @@ -36,17 +43,17 @@ struct BrowserTab: View { } NavigationButtons() } - #endif +#endif ToolbarItemGroup(placement: .primaryAction) { OutlineButton() ExportButton() - #if os(macOS) +#if os(macOS) PrintButton() - #endif +#endif BookmarkButton() - #if os(iOS) +#if os(iOS) ContentSearchButton() - #endif +#endif ArticleShortcutButtons(displayMode: .mainAndRandomArticle) } } @@ -62,12 +69,12 @@ struct BrowserTab: View { } } .modify { view in - #if os(macOS) +#if os(macOS) view.navigationTitle(browser.articleTitle.isEmpty ? Brand.appName : browser.articleTitle) .navigationSubtitle(browser.zimFileName) - #elseif os(iOS) +#elseif os(iOS) view - #endif +#endif } .onAppear { browser.updateLastOpened() @@ -78,11 +85,25 @@ struct BrowserTab: View { } } - struct Content: View { + private struct Content: View where LaunchModel: LaunchProtocol { @Environment(\.isSearching) private var isSearching + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @EnvironmentObject private var browser: BrowserViewModel + @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 + /// 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 + @ObservedObject var model: LaunchModel var body: some View { + // swiftlint:disable:next redundant_discardable_let + let _ = model.updateWith(hasZimFiles: !zimFiles.isEmpty, + hasSeenCategories: hasSeenCategories) GeometryReader { proxy in Group { if isSearching { @@ -92,20 +113,46 @@ struct BrowserTab: View { #elseif os(iOS) .environment(\.horizontalSizeClass, proxy.size.width > 750 ? .regular : .compact) #endif - } else if browser.url == nil && FeatureFlags.hasLibrary { - Welcome(showLibrary: nil) } else { - WebView().ignoresSafeArea() - #if os(macOS) - .overlay(alignment: .bottomTrailing) { - ContentSearchBar( - model: ContentSearchViewModel(findInWebPage: browser.webView.find(_:configuration:)) - ) - } - #endif + switch model.state { + case .loadingData: + LoadingDataView() + case .webPage(let isLoading): + WebView() + .ignoresSafeArea() + .overlay { + if isLoading { + LoadingProgressView() + } + } +#if os(macOS) + .overlay(alignment: .bottomTrailing) { + ContentSearchBar( + model: ContentSearchViewModel( + findInWebPage: browser.webView.find(_:configuration:) + ) + ) + } +#endif + case .catalog(.fetching): + FetchingCatalogView() + case .catalog(.list): + LocalLibraryList() + case .catalog(.welcome(let welcomeViewState)): + WelcomeCatalog(viewState: welcomeViewState) + } } } } + .onChange(of: library.state) { state in + guard state == .complete else { return } + showTheLibrary() + } + } + + private func showTheLibrary() { + guard model.state.shouldShowCatalog else { return } + navigation.currentItem = .categories } } } diff --git a/Views/BuildingBlocks/LoadingView.swift b/Views/BuildingBlocks/LoadingView.swift index c20a52b1..30af9a54 100644 --- a/Views/BuildingBlocks/LoadingView.swift +++ b/Views/BuildingBlocks/LoadingView.swift @@ -189,7 +189,16 @@ struct LoadingProgressView: View { } } -struct LoadingView: View { +struct FetchingCatalogView: View { + var body: some View { + ZStack { + LogoView() + LoadingMessageView(message: "welcome.button.status.fetching_catalog.text".localized) + }.ignoresSafeArea() + } +} + +struct LoadingDataView: View { var body: some View { ZStack { LogoView() @@ -199,5 +208,5 @@ struct LoadingView: View { } #Preview { - LoadingView() + LoadingDataView() } diff --git a/Views/LocalLibraryList.swift b/Views/LocalLibraryList.swift new file mode 100644 index 00000000..01a0b9b5 --- /dev/null +++ b/Views/LocalLibraryList.swift @@ -0,0 +1,68 @@ +// This file is part of Kiwix for iOS & macOS. +// +// Kiwix is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// any later version. +// +// Kiwix is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kiwix; If not, see https://www.gnu.org/licenses/. + +import SwiftUI +import Combine +import Defaults + +/// Displays a grid of available local ZIM files. Used on new tab. +struct LocalLibraryList: View { + @EnvironmentObject private var browser: BrowserViewModel + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \Bookmark.created, ascending: false)], + animation: .easeInOut + ) private var bookmarks: FetchedResults + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \ZimFile.size, ascending: false)], + predicate: ZimFile.openedPredicate, + animation: .easeInOut + ) private var zimFiles: FetchedResults + + var body: some View { + LazyVGrid( + columns: ([GridItem(.adaptive(minimum: 250, maximum: 500), spacing: 12)]), + alignment: .leading, + spacing: 12 + ) { + GridSection(title: "welcome.main_page.title".localized) { + ForEach(zimFiles) { zimFile in + AsyncButtonView { + guard let url = await ZimFileService.shared + .getMainPageURL(zimFileID: zimFile.fileID) else { return } + browser.load(url: url) + } label: { + ZimFileCell(zimFile, prominent: .name) + } loading: { + ZimFileCell(zimFile, prominent: .name, isLoading: true) + } + .buttonStyle(.plain) + } + } + if !bookmarks.isEmpty { + GridSection(title: "welcome.grid.bookmarks.title".localized) { + ForEach(bookmarks.prefix(6)) { bookmark in + Button { + browser.load(url: bookmark.articleURL) + } label: { + ArticleCell(bookmark: bookmark) + } + .buttonStyle(.plain) + .modifier(BookmarkContextMenu(bookmark: bookmark)) + } + } + } + }.modifier(GridCommon(edges: .all)) + } +} diff --git a/Views/Welcome.swift b/Views/Welcome.swift deleted file mode 100644 index c7702322..00000000 --- a/Views/Welcome.swift +++ /dev/null @@ -1,187 +0,0 @@ -// This file is part of Kiwix for iOS & macOS. -// -// Kiwix is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 3 of the License, or -// any later version. -// -// Kiwix is distributed in the hope that it will be useful, but -// WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -// General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Kiwix; If not, see https://www.gnu.org/licenses/. - -import SwiftUI -import Combine -import Defaults - -struct Welcome: View { - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @Environment(\.verticalSizeClass) private var verticalSizeClass - @EnvironmentObject private var browser: BrowserViewModel - @EnvironmentObject private var library: LibraryViewModel - @EnvironmentObject private var navigation: NavigationViewModel - @Default(.hasSeenCategories) private var hasSeenCategories - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \Bookmark.created, ascending: false)], - animation: .easeInOut - ) private var bookmarks: FetchedResults - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \ZimFile.size, ascending: false)], - predicate: ZimFile.openedPredicate, - animation: .easeInOut - ) private var zimFiles: FetchedResults - /// Used only for iPhone - let showLibrary: (() -> Void)? - - var body: some View { - if zimFiles.isEmpty { - ZStack { - LogoView() - welcomeContent - .onAppear { - if !hasSeenCategories, library.state == .complete { - // safety path for upgrading user with no ZIM files, but fetched categories - // to make sure we do display the buttons - hasSeenCategories = true - } - } - if library.state == .inProgress { - if hasSeenCategories { - LoadingProgressView() - } else { - LoadingMessageView(message: "welcome.button.status.fetching_catalog.text".localized) - } - } - }.ignoresSafeArea() - } else { - LazyVGrid( - columns: ([GridItem(.adaptive(minimum: 250, maximum: 500), spacing: 12)]), - alignment: .leading, - spacing: 12 - ) { - GridSection(title: "welcome.main_page.title".localized) { - ForEach(zimFiles) { zimFile in - AsyncButtonView { - guard let url = await ZimFileService.shared - .getMainPageURL(zimFileID: zimFile.fileID) else { return } - browser.load(url: url) - } label: { - ZimFileCell(zimFile, prominent: .name) - } loading: { - ZimFileCell(zimFile, prominent: .name, isLoading: true) - } - .buttonStyle(.plain) - } - } - if !bookmarks.isEmpty { - GridSection(title: "welcome.grid.bookmarks.title".localized) { - ForEach(bookmarks.prefix(6)) { bookmark in - Button { - browser.load(url: bookmark.articleURL) - } label: { - ArticleCell(bookmark: bookmark) - } - .buttonStyle(.plain) - .modifier(BookmarkContextMenu(bookmark: bookmark)) - } - } - } - }.modifier(GridCommon(edges: .all)) - } - } - - private var welcomeContent: some View { - GeometryReader { geometry in - let logoCalc = LogoCalc( - geometry: geometry.size, - originalImageSize: Brand.loadingLogoSize, - horizontal: horizontalSizeClass, - vertical: verticalSizeClass - ) - actions - .position( - x: geometry.size.width * 0.5, - y: logoCalc.buttonCenterY) - .opacity(hasSeenCategories ? 1 : 0) - .frame(maxWidth: logoCalc.buttonsWidth) - .onChange(of: library.state) { state in - if state == .error { - hasSeenCategories = true - } - guard state == .complete else { return } -#if os(macOS) - navigation.currentItem = .categories -#elseif os(iOS) - if horizontalSizeClass == .regular { - navigation.currentItem = .categories - } else { - showLibrary?() - } -#endif - } - Text("library_refresh_error.retrieve.description".localized) - .foregroundColor(.red) - .opacity(library.state == .error ? 1 : 0) - .position( - x: geometry.size.width * 0.5, - y: logoCalc.errorTextCenterY - ) - } - } - - /// Onboarding actions, open a zim file or refresh catalog - private var actions: some View { - if verticalSizeClass == .compact { // iPhone landscape - AnyView(HStack { - openFileButton - catalogButton - }) - } else { - AnyView(VStack { - openFileButton - catalogButton - }) - } - } - - private var openFileButton: some View { - OpenFileButton(context: .onBoarding) { - HStack { - Spacer() - Text("welcome.actions.open_file".localized) - Spacer() - }.padding(6) - } - .font(.subheadline) - .buttonStyle(.bordered) - } - - private var catalogButton: some View { - Button { - library.start(isUserInitiated: true) - } label: { - HStack { - Spacer() - if library.state == .inProgress { - Text("welcome.button.status.fetching_catalog.text".localized) - } else { - Text("welcome.button.status.fetch_catalog.text".localized) - } - Spacer() - }.padding(6) - } - .disabled(library.state == .inProgress) - .font(.subheadline) - .buttonStyle(.bordered) - } -} - -struct WelcomeView_Previews: PreviewProvider { - static var previews: some View { - Welcome(showLibrary: nil).environmentObject(LibraryViewModel()).preferredColorScheme(.light).padding() - Welcome(showLibrary: nil).environmentObject(LibraryViewModel()).preferredColorScheme(.dark).padding() - } -} diff --git a/Views/WelcomeCatalog.swift b/Views/WelcomeCatalog.swift new file mode 100644 index 00000000..87e71ce5 --- /dev/null +++ b/Views/WelcomeCatalog.swift @@ -0,0 +1,111 @@ +// This file is part of Kiwix for iOS & macOS. +// +// Kiwix is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// any later version. +// +// Kiwix is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kiwix; If not, see https://www.gnu.org/licenses/. + +import SwiftUI +import Combine +import Defaults + +/// Displays the Logo and 2 buttons open file | fetch catalog. +/// Used on new tab, when no ZIM files are available +struct WelcomeCatalog: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + @EnvironmentObject private var library: LibraryViewModel + let viewState: WelcomeViewState + + var body: some View { + ZStack { + LogoView() + welcomeContent + }.ignoresSafeArea() + } + + private var welcomeContent: some View { + GeometryReader { geometry in + let logoCalc = LogoCalc( + geometry: geometry.size, + originalImageSize: Brand.loadingLogoSize, + horizontal: horizontalSizeClass, + vertical: verticalSizeClass + ) + actions + .position( + x: geometry.size.width * 0.5, + y: logoCalc.buttonCenterY) + .frame(maxWidth: logoCalc.buttonsWidth) + if viewState == .error { + Text("library_refresh_error.retrieve.description".localized) + .foregroundColor(.red) + .position( + x: geometry.size.width * 0.5, + y: logoCalc.errorTextCenterY + ) + } + } + } + + /// Onboarding actions, open a zim file or refetch catalog + private var actions: some View { + if verticalSizeClass == .compact { // iPhone landscape + AnyView(HStack { + openFileButton + fetchCatalogButton + }) + } else { + AnyView(VStack { + openFileButton + fetchCatalogButton + }) + } + } + + private var openFileButton: some View { + OpenFileButton(context: .onBoarding) { + HStack { + Spacer() + Text("welcome.actions.open_file".localized) + Spacer() + }.padding(6) + } + .font(.subheadline) + .buttonStyle(.bordered) + } + + private var fetchCatalogButton: some View { + Button { + library.start(isUserInitiated: true) + } label: { + HStack { + Spacer() + if viewState == .loading { + Text("welcome.button.status.fetching_catalog.text".localized) + } else { + Text("welcome.button.status.fetch_catalog.text".localized) + } + Spacer() + }.padding(6) + } + .disabled(viewState == .loading) + .font(.subheadline) + .buttonStyle(.bordered) + } +} + +struct WelcomeView_Previews: PreviewProvider { + static var previews: some View { + WelcomeCatalog(viewState: .loading).environmentObject(LibraryViewModel()).preferredColorScheme(.light).padding() + WelcomeCatalog(viewState: .error).environmentObject(LibraryViewModel()).preferredColorScheme(.dark).padding() + } +}