Merge pull request #1134 from kiwix/live-activities-v2

Live activities
This commit is contained in:
Kelson 2025-03-07 14:56:22 +01:00 committed by GitHub
commit 31c4d5214b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 105 additions and 64 deletions

View File

@ -25,17 +25,17 @@ struct Kiwix: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
private let fileMonitor: DirectoryMonitor private let fileMonitor: DirectoryMonitor
// private let activityService: ActivityService? private let activityService: ActivityService?
init() { init() {
fileMonitor = DirectoryMonitor(url: URL.documentDirectory) { LibraryOperations.scanDirectory($0) } fileMonitor = DirectoryMonitor(url: URL.documentDirectory) { LibraryOperations.scanDirectory($0) }
// MARK: - live activities // MARK: - live activities
// switch AppType.current { switch AppType.current {
// case .kiwix: case .kiwix:
// activityService = ActivityService() activityService = ActivityService()
// case .custom: case .custom:
// activityService = nil activityService = nil
// } }
UNUserNotificationCenter.current().delegate = appDelegate UNUserNotificationCenter.current().delegate = appDelegate
// MARK: - migrations // MARK: - migrations
if !ProcessInfo.processInfo.arguments.contains("testing") { if !ProcessInfo.processInfo.arguments.contains("testing") {
@ -73,7 +73,14 @@ struct Kiwix: App {
if url.isFileURL { if url.isFileURL {
NotificationCenter.openFiles([url], context: .file) NotificationCenter.openFiles([url], context: .file)
} else if url.isZIMURL { } else if url.isZIMURL {
NotificationCenter.openURL(url) switch url {
case DownloadActivityAttributes.downloadsDeepLink:
if FeatureFlags.hasLibrary {
navigation.showDownloads.send()
}
default:
NotificationCenter.openURL(url)
}
} }
} }
.task { .task {
@ -85,7 +92,7 @@ struct Kiwix: App {
LibraryOperations.scanDirectory(URL.documentDirectory) LibraryOperations.scanDirectory(URL.documentDirectory)
LibraryOperations.applyFileBackupSetting() LibraryOperations.applyFileBackupSetting()
DownloadService.shared.restartHeartbeatIfNeeded() DownloadService.shared.restartHeartbeatIfNeeded()
// activityService?.start() activityService?.start()
case let .custom(zimFileURL): case let .custom(zimFileURL):
await LibraryOperations.open(url: zimFileURL) await LibraryOperations.open(url: zimFileURL)
ZimMigration.forCustomApps() ZimMigration.forCustomApps()

View File

@ -137,11 +137,15 @@ private struct CompactView: View {
@EnvironmentObject private var library: LibraryViewModel @EnvironmentObject private var library: LibraryViewModel
@State private var presentedSheet: PresentedSheet? @State private var presentedSheet: PresentedSheet?
private enum PresentedSheet: String, Identifiable { private enum PresentedSheet: Identifiable {
case library case library(downloads: Bool)
case settings case settings
var id: String { 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: { Content(tabID: tabID, showLibrary: {
if presentedSheet == nil { if presentedSheet == nil {
presentedSheet = .library presentedSheet = .library(downloads: false)
} else { } else {
// there's a sheet already presented by the user // there's a sheet already presented by the user
// do nothing // do nothing
@ -183,7 +187,7 @@ private struct CompactView: View {
Spacer() Spacer()
if FeatureFlags.hasLibrary { if FeatureFlags.hasLibrary {
Button { Button {
presentedSheet = .library presentedSheet = .library(downloads: false)
} label: { } label: {
Label(LocalString.common_tab_menu_library, systemImage: "folder") Label(LocalString.common_tab_menu_library, systemImage: "folder")
} }
@ -200,8 +204,10 @@ private struct CompactView: View {
.environmentObject(browser) .environmentObject(browser)
.sheet(item: $presentedSheet) { presentedSheet in .sheet(item: $presentedSheet) { presentedSheet in
switch presentedSheet { switch presentedSheet {
case .library: case .library(downloads: false):
Library(dismiss: dismiss) Library(dismiss: dismiss)
case .library(downloads: true):
Library(dismiss: dismiss, tabItem: .downloads)
case .settings: case .settings:
NavigationStack { NavigationStack {
Settings().toolbar { 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)
}
}
} }
} }
} }

View File

@ -21,6 +21,7 @@ import UIKit
final class SplitViewController: UISplitViewController { final class SplitViewController: UISplitViewController {
let navigationViewModel: NavigationViewModel let navigationViewModel: NavigationViewModel
private var navigationItemObserver: AnyCancellable? private var navigationItemObserver: AnyCancellable?
private var showDownloadsObserver: AnyCancellable?
private var openURLObserver: NSObjectProtocol? private var openURLObserver: NSObjectProtocol?
private var hasZimFiles: Bool private var hasZimFiles: Bool
@ -74,6 +75,17 @@ final class SplitViewController: UISplitViewController {
self?.preferredDisplayMode = .automatic 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( openURLObserver = NotificationCenter.default.addObserver(
forName: .openURL, object: nil, queue: nil forName: .openURL, object: nil, queue: nil
) { [weak self] notification in ) { [weak self] notification in

View File

@ -18,6 +18,8 @@ import ActivityKit
public struct DownloadActivityAttributes: ActivityAttributes { public struct DownloadActivityAttributes: ActivityAttributes {
static let downloadsDeepLink = URL(string: "zim://downloads")
private static func progressFor(items: [DownloadItem]) -> Progress { private static func progressFor(items: [DownloadItem]) -> Progress {
let sumOfTotal = items.reduce(0) { result, item in let sumOfTotal = items.reduce(0) { result, item in
result + item.total result + item.total

View File

@ -43,11 +43,11 @@ final class DownloadTime {
} }
let average = averagePerSecond() let average = averagePerSecond()
let remainingAmount = totalAmount - latestAmount let remainingAmount = totalAmount - latestAmount
let remaingTime = Double(remainingAmount) / average - (now - latestTime) let remainingTime = Double(remainingAmount) / average - (now - latestTime)
guard remaingTime > 0 else { guard remainingTime > 0 else {
return 0 return 0
} }
return remaingTime return remainingTime
} }
private func filterOutSamples(now: CFTimeInterval) { private func filterOutSamples(now: CFTimeInterval) {

View File

@ -15,12 +15,15 @@
import CoreData import CoreData
import WebKit import WebKit
import Combine
@MainActor @MainActor
final class NavigationViewModel: ObservableObject { final class NavigationViewModel: ObservableObject {
let uuid = UUID() let uuid = UUID()
// remained optional due to focusedSceneValue conformance // remained optional due to focusedSceneValue conformance
@Published var currentItem: NavigationItem? = .loading @Published var currentItem: NavigationItem? = .loading
private(set) var showDownloads = PassthroughSubject<Void, Never>()
#if os(macOS) #if os(macOS)
var isTerminating: Bool = false var isTerminating: Bool = false

View File

@ -21,16 +21,19 @@ import Defaults
/// Tabbed library view on iOS & iPadOS /// Tabbed library view on iOS & iPadOS
struct Library: View { struct Library: View {
@EnvironmentObject private var viewModel: LibraryViewModel @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 @Default(.hasSeenCategories) private var hasSeenCategories
private let categories: [Category] private let categories: [Category]
let dismiss: (() -> Void)? let dismiss: (() -> Void)?
init( init(
dismiss: (() -> Void)?, dismiss: (() -> Void)?,
tabItem: LibraryTabItem = .categories,
categories: [Category] = CategoriesToLanguages().allCategories() categories: [Category] = CategoriesToLanguages().allCategories()
) { ) {
self.dismiss = dismiss self.dismiss = dismiss
self.tabItem = tabItem
self.categories = categories self.categories = categories
} }
@ -70,6 +73,10 @@ struct Library: View {
viewModel.start(isUserInitiated: false) viewModel.start(isUserInitiated: false)
}.onDisappear { }.onDisappear {
hasSeenCategories = true hasSeenCategories = true
}.onReceive(navigation.showDownloads) { _ in
if tabItem != .downloads {
tabItem = .downloads
}
} }
} }
} }

View File

@ -35,7 +35,7 @@ final class ActivityService {
publisher: @MainActor @escaping () -> CurrentValueSubject<[UUID: DownloadState], Never> = { publisher: @MainActor @escaping () -> CurrentValueSubject<[UUID: DownloadState], Never> = {
DownloadService.shared.progress.publisher DownloadService.shared.progress.publisher
}, },
updateFrequency: Double = 10, updateFrequency: Double = 2,
averageDownloadSpeedFromLastSeconds: Double = 30 averageDownloadSpeedFromLastSeconds: Double = 30
) { ) {
assert(updateFrequency > 0) assert(updateFrequency > 0)

View File

@ -21,22 +21,14 @@ struct DownloadsLiveActivity: Widget {
// @Environment(\.isActivityFullscreen) var isActivityFullScreen has a bug, when min iOS is 16 // @Environment(\.isActivityFullscreen) var isActivityFullScreen has a bug, when min iOS is 16
// https://developer.apple.com/forums/thread/763594 // 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 { var body: some WidgetConfiguration {
ActivityConfiguration(for: DownloadActivityAttributes.self) { context in ActivityConfiguration(for: DownloadActivityAttributes.self) { context in
// Lock screen/banner UI // Lock screen/banner UI
let timeInterval = startTime...Date(
timeInterval: context.state.estimatedTimeLeft,
since: .now
)
VStack { VStack {
HStack { HStack {
VStack(alignment: .leading) { VStack(alignment: .leading) {
titleFor(context.state.title) titleFor(context.state.title)
progressFor(state: context.state, timeInterval: timeInterval) progressFor(state: context.state)
} }
.padding() .padding()
KiwixLogo(maxHeight: 50) KiwixLogo(maxHeight: 50)
@ -44,19 +36,15 @@ struct DownloadsLiveActivity: Widget {
} }
} }
.modifier(WidgetBackgroundModifier()) .modifier(WidgetBackgroundModifier())
.widgetURL(DownloadActivityAttributes.downloadsDeepLink)
} dynamicIsland: { context in } dynamicIsland: { context in
DynamicIsland { DynamicIsland {
// Expanded UI // Expanded UI
DynamicIslandExpandedRegion(.leading) { DynamicIslandExpandedRegion(.leading) {
let timeInterval = startTime...Date(
timeInterval: context.state.estimatedTimeLeft,
since: .now
)
VStack(alignment: .leading) { VStack(alignment: .leading) {
titleFor(context.state.title) titleFor(context.state.title)
progressFor(state: context.state, timeInterval: timeInterval) progressFor(state: context.state)
Spacer() Spacer()
} }
.padding() .padding()
@ -79,7 +67,7 @@ struct DownloadsLiveActivity: Widget {
.progressViewStyle(CircularProgressGaugeStyle(lineWidth: 5.7)) .progressViewStyle(CircularProgressGaugeStyle(lineWidth: 5.7))
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
} }
.widgetURL(URL(string: "https://www.kiwix.org")) .widgetURL(DownloadActivityAttributes.downloadsDeepLink)
.keylineTint(Color.red) .keylineTint(Color.red)
}.containerBackgroundRemovable() }.containerBackgroundRemovable()
} }
@ -101,11 +89,25 @@ struct DownloadsLiveActivity: Widget {
.tint(.secondary) .tint(.secondary)
} }
private func currentTimeInterval(
state: DownloadActivityAttributes.ContentState
) -> ClosedRange<Date> {
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 @ViewBuilder
private func progressFor( private func progressFor(
state: DownloadActivityAttributes.ContentState, state: DownloadActivityAttributes.ContentState
timeInterval: ClosedRange<Date>
) -> some View { ) -> some View {
let timeInterval = currentTimeInterval(state: state)
if !state.isAllPaused { if !state.isAllPaused {
ProgressView(timerInterval: timeInterval, countsDown: false, label: { ProgressView(timerInterval: timeInterval, countsDown: false, label: {
progressText(state.progressDescription) progressText(state.progressDescription)
@ -146,14 +148,6 @@ extension DownloadActivityAttributes.ContentState {
total: 256, total: 256,
timeRemaining: 15, timeRemaining: 15,
isPaused: true 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", description: "2nd item",
downloaded: 110, downloaded: 110,
total: 124, total: 124,
timeRemaining: 2, timeRemaining: 20,
isPaused: false isPaused: false
) )
] ]

View File

@ -121,10 +121,10 @@ targets:
- path: Kiwix/SplashScreenKiwix.storyboard - path: Kiwix/SplashScreenKiwix.storyboard
destinationFilters: destinationFilters:
- iOS - iOS
# dependencies: dependencies:
# - target: Widgets - target: Widgets
# destinationFilters: destinationFilters:
# - iOS - iOS
UnitTests: UnitTests:
type: bundle.unit-test type: bundle.unit-test
supportedDestinations: [iOS, macOS] supportedDestinations: [iOS, macOS]
@ -143,19 +143,19 @@ targets:
- path: Tests - path: Tests
dependencies: dependencies:
- target: Kiwix - target: Kiwix
# Widgets: Widgets:
# type: app-extension type: app-extension
# supportedDestinations: [iOS] supportedDestinations: [iOS]
# settings: settings:
# base: base:
# PRODUCT_BUNDLE_IDENTIFIER: self.Kiwix.ioswidgets PRODUCT_BUNDLE_IDENTIFIER: self.Kiwix.ioswidgets
# INFOPLIST_FILE: Widgets/Info.plist INFOPLIST_FILE: Widgets/Info.plist
# sources: sources:
# - path: Common - path: Common
# - path: Widgets - path: Widgets
# dependencies: dependencies:
# - framework: SwiftUI.framework - framework: SwiftUI.framework
# - framework: WidgetKit.framework - framework: WidgetKit.framework
schemes: schemes:
Kiwix: Kiwix: