diff --git a/App/App_iOS.swift b/App/App_iOS.swift index 3436932f..c0a946e5 100644 --- a/App/App_iOS.swift +++ b/App/App_iOS.swift @@ -25,17 +25,17 @@ struct Kiwix: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate private let fileMonitor: DirectoryMonitor -// private let activityService: ActivityService? + private let activityService: ActivityService? init() { fileMonitor = DirectoryMonitor(url: URL.documentDirectory) { LibraryOperations.scanDirectory($0) } // MARK: - live activities -// switch AppType.current { -// case .kiwix: -// activityService = ActivityService() -// case .custom: -// activityService = nil -// } + switch AppType.current { + case .kiwix: + activityService = ActivityService() + case .custom: + activityService = nil + } UNUserNotificationCenter.current().delegate = appDelegate // MARK: - migrations if !ProcessInfo.processInfo.arguments.contains("testing") { @@ -73,7 +73,14 @@ struct Kiwix: App { if url.isFileURL { NotificationCenter.openFiles([url], context: .file) } else if url.isZIMURL { - NotificationCenter.openURL(url) + switch url { + case DownloadActivityAttributes.downloadsDeepLink: + if FeatureFlags.hasLibrary { + navigation.showDownloads.send() + } + default: + NotificationCenter.openURL(url) + } } } .task { @@ -85,7 +92,7 @@ struct Kiwix: App { LibraryOperations.scanDirectory(URL.documentDirectory) LibraryOperations.applyFileBackupSetting() DownloadService.shared.restartHeartbeatIfNeeded() -// activityService?.start() + activityService?.start() case let .custom(zimFileURL): await LibraryOperations.open(url: zimFileURL) ZimMigration.forCustomApps() diff --git a/App/CompactViewController.swift b/App/CompactViewController.swift index d64003d5..f301aaa7 100644 --- a/App/CompactViewController.swift +++ b/App/CompactViewController.swift @@ -137,11 +137,15 @@ private struct CompactView: View { @EnvironmentObject private var library: LibraryViewModel @State private var presentedSheet: PresentedSheet? - private enum PresentedSheet: String, Identifiable { - case library + private enum PresentedSheet: Identifiable { + case library(downloads: Bool) case settings var id: String { - rawValue + switch self { + case .library(true): return "library-downloads" + case .library(false): return "library" + case .settings: return "settings" + } } } @@ -161,7 +165,7 @@ private struct CompactView: View { } Content(tabID: tabID, showLibrary: { if presentedSheet == nil { - presentedSheet = .library + presentedSheet = .library(downloads: false) } else { // there's a sheet already presented by the user // do nothing @@ -183,7 +187,7 @@ private struct CompactView: View { Spacer() if FeatureFlags.hasLibrary { Button { - presentedSheet = .library + presentedSheet = .library(downloads: false) } label: { Label(LocalString.common_tab_menu_library, systemImage: "folder") } @@ -200,8 +204,10 @@ private struct CompactView: View { .environmentObject(browser) .sheet(item: $presentedSheet) { presentedSheet in switch presentedSheet { - case .library: + case .library(downloads: false): Library(dismiss: dismiss) + case .library(downloads: true): + Library(dismiss: dismiss, tabItem: .downloads) case .settings: NavigationStack { Settings().toolbar { @@ -216,6 +222,16 @@ private struct CompactView: View { } } } + .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) + } + } } } } diff --git a/App/SplitViewController.swift b/App/SplitViewController.swift index c13cfffe..df51efaa 100644 --- a/App/SplitViewController.swift +++ b/App/SplitViewController.swift @@ -21,6 +21,7 @@ import UIKit final class SplitViewController: UISplitViewController { let navigationViewModel: NavigationViewModel private var navigationItemObserver: AnyCancellable? + private var showDownloadsObserver: AnyCancellable? private var openURLObserver: NSObjectProtocol? private var hasZimFiles: Bool @@ -74,6 +75,17 @@ final class SplitViewController: UISplitViewController { self?.preferredDisplayMode = .automatic } } + showDownloadsObserver = navigationViewModel + .showDownloads + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] _ in + if self?.traitCollection.horizontalSizeClass == .regular, + self?.navigationViewModel.currentItem != .downloads { + self?.navigationViewModel.currentItem = .downloads + } + // the compact one is triggered in CompactViewController + }) + openURLObserver = NotificationCenter.default.addObserver( forName: .openURL, object: nil, queue: nil ) { [weak self] notification in diff --git a/Common/DownloadActivityAttributes.swift b/Common/DownloadActivityAttributes.swift index e894c9b3..69e2ad18 100644 --- a/Common/DownloadActivityAttributes.swift +++ b/Common/DownloadActivityAttributes.swift @@ -18,6 +18,8 @@ import ActivityKit public struct DownloadActivityAttributes: ActivityAttributes { + static let downloadsDeepLink = URL(string: "zim://downloads") + private static func progressFor(items: [DownloadItem]) -> Progress { let sumOfTotal = items.reduce(0) { result, item in result + item.total diff --git a/Model/Utilities/DownloadTime.swift b/Model/Utilities/DownloadTime.swift index b5e3290a..02f44ae9 100644 --- a/Model/Utilities/DownloadTime.swift +++ b/Model/Utilities/DownloadTime.swift @@ -43,11 +43,11 @@ final class DownloadTime { } let average = averagePerSecond() let remainingAmount = totalAmount - latestAmount - let remaingTime = Double(remainingAmount) / average - (now - latestTime) - guard remaingTime > 0 else { + let remainingTime = Double(remainingAmount) / average - (now - latestTime) + guard remainingTime > 0 else { return 0 } - return remaingTime + return remainingTime } private func filterOutSamples(now: CFTimeInterval) { diff --git a/ViewModel/NavigationViewModel.swift b/ViewModel/NavigationViewModel.swift index 37b88768..e4f9939e 100644 --- a/ViewModel/NavigationViewModel.swift +++ b/ViewModel/NavigationViewModel.swift @@ -15,12 +15,15 @@ import CoreData import WebKit +import Combine @MainActor final class NavigationViewModel: ObservableObject { let uuid = UUID() // remained optional due to focusedSceneValue conformance @Published var currentItem: NavigationItem? = .loading + private(set) var showDownloads = PassthroughSubject() + #if os(macOS) var isTerminating: Bool = false diff --git a/Views/Library/Library.swift b/Views/Library/Library.swift index 99e52acb..39cc1619 100644 --- a/Views/Library/Library.swift +++ b/Views/Library/Library.swift @@ -21,16 +21,19 @@ import Defaults /// Tabbed library view on iOS & iPadOS struct Library: View { @EnvironmentObject private var viewModel: LibraryViewModel - @SceneStorage("LibraryTabItem") private var tabItem: LibraryTabItem = .categories + @EnvironmentObject private var navigation: NavigationViewModel + @State private var tabItem: LibraryTabItem @Default(.hasSeenCategories) private var hasSeenCategories private let categories: [Category] let dismiss: (() -> Void)? init( dismiss: (() -> Void)?, + tabItem: LibraryTabItem = .categories, categories: [Category] = CategoriesToLanguages().allCategories() ) { self.dismiss = dismiss + self.tabItem = tabItem self.categories = categories } @@ -70,6 +73,10 @@ struct Library: View { viewModel.start(isUserInitiated: false) }.onDisappear { hasSeenCategories = true + }.onReceive(navigation.showDownloads) { _ in + if tabItem != .downloads { + tabItem = .downloads + } } } } diff --git a/Views/LiveActivity/ActivityService.swift b/Views/LiveActivity/ActivityService.swift index 9f8e2354..741e6ca9 100644 --- a/Views/LiveActivity/ActivityService.swift +++ b/Views/LiveActivity/ActivityService.swift @@ -35,7 +35,7 @@ final class ActivityService { publisher: @MainActor @escaping () -> CurrentValueSubject<[UUID: DownloadState], Never> = { DownloadService.shared.progress.publisher }, - updateFrequency: Double = 10, + updateFrequency: Double = 2, averageDownloadSpeedFromLastSeconds: Double = 30 ) { assert(updateFrequency > 0) diff --git a/Widgets/DownloadsLiveActivity.swift b/Widgets/DownloadsLiveActivity.swift index 4eaca6f5..c735025e 100644 --- a/Widgets/DownloadsLiveActivity.swift +++ b/Widgets/DownloadsLiveActivity.swift @@ -21,22 +21,14 @@ struct DownloadsLiveActivity: Widget { // @Environment(\.isActivityFullscreen) var isActivityFullScreen has a bug, when min iOS is 16 // https://developer.apple.com/forums/thread/763594 - /// A start time from the creation of the activity, - /// this way the progress bar is not jumping back to 0 - private let startTime: Date = .now - var body: some WidgetConfiguration { ActivityConfiguration(for: DownloadActivityAttributes.self) { context in // Lock screen/banner UI - let timeInterval = startTime...Date( - timeInterval: context.state.estimatedTimeLeft, - since: .now - ) VStack { HStack { VStack(alignment: .leading) { titleFor(context.state.title) - progressFor(state: context.state, timeInterval: timeInterval) + progressFor(state: context.state) } .padding() KiwixLogo(maxHeight: 50) @@ -44,19 +36,15 @@ struct DownloadsLiveActivity: Widget { } } .modifier(WidgetBackgroundModifier()) + .widgetURL(DownloadActivityAttributes.downloadsDeepLink) } dynamicIsland: { context in DynamicIsland { // Expanded UI DynamicIslandExpandedRegion(.leading) { - let timeInterval = startTime...Date( - timeInterval: context.state.estimatedTimeLeft, - since: .now - ) - VStack(alignment: .leading) { titleFor(context.state.title) - progressFor(state: context.state, timeInterval: timeInterval) + progressFor(state: context.state) Spacer() } .padding() @@ -79,7 +67,7 @@ struct DownloadsLiveActivity: Widget { .progressViewStyle(CircularProgressGaugeStyle(lineWidth: 5.7)) .frame(width: 24, height: 24) } - .widgetURL(URL(string: "https://www.kiwix.org")) + .widgetURL(DownloadActivityAttributes.downloadsDeepLink) .keylineTint(Color.red) }.containerBackgroundRemovable() } @@ -101,11 +89,25 @@ struct DownloadsLiveActivity: Widget { .tint(.secondary) } + private func currentTimeInterval( + state: DownloadActivityAttributes.ContentState + ) -> ClosedRange { + if state.progress < 1 { + let timePassed: TimeInterval = state.progress / (1 - state.progress) * state.estimatedTimeLeft + return Date(timeInterval: 0 - timePassed, since: .now)...Date( + timeInterval: state.estimatedTimeLeft, + since: .now + ) + } else { + return Date(timeIntervalSinceNow: 0)...Date(timeIntervalSinceNow: 0) + } + } + @ViewBuilder private func progressFor( - state: DownloadActivityAttributes.ContentState, - timeInterval: ClosedRange + state: DownloadActivityAttributes.ContentState ) -> some View { + let timeInterval = currentTimeInterval(state: state) if !state.isAllPaused { ProgressView(timerInterval: timeInterval, countsDown: false, label: { progressText(state.progressDescription) @@ -146,14 +148,6 @@ extension DownloadActivityAttributes.ContentState { total: 256, timeRemaining: 15, isPaused: true - ), - DownloadActivityAttributes.DownloadItem( - uuid: UUID(), - description: "2nd item", - downloaded: 90, - total: 124, - timeRemaining: 2, - isPaused: true ) ] ) @@ -176,7 +170,7 @@ extension DownloadActivityAttributes.ContentState { description: "2nd item", downloaded: 110, total: 124, - timeRemaining: 2, + timeRemaining: 20, isPaused: false ) ] diff --git a/project.yml b/project.yml index d4cbf432..abfc69cb 100644 --- a/project.yml +++ b/project.yml @@ -121,10 +121,10 @@ targets: - path: Kiwix/SplashScreenKiwix.storyboard destinationFilters: - iOS - # dependencies: - # - target: Widgets - # destinationFilters: - # - iOS + dependencies: + - target: Widgets + destinationFilters: + - iOS UnitTests: type: bundle.unit-test supportedDestinations: [iOS, macOS] @@ -143,19 +143,19 @@ targets: - path: Tests dependencies: - target: Kiwix - # Widgets: - # type: app-extension - # supportedDestinations: [iOS] - # settings: - # base: - # PRODUCT_BUNDLE_IDENTIFIER: self.Kiwix.ioswidgets - # INFOPLIST_FILE: Widgets/Info.plist - # sources: - # - path: Common - # - path: Widgets - # dependencies: - # - framework: SwiftUI.framework - # - framework: WidgetKit.framework + Widgets: + type: app-extension + supportedDestinations: [iOS] + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: self.Kiwix.ioswidgets + INFOPLIST_FILE: Widgets/Info.plist + sources: + - path: Common + - path: Widgets + dependencies: + - framework: SwiftUI.framework + - framework: WidgetKit.framework schemes: Kiwix: