diff --git a/App/App_iOS.swift b/App/App_iOS.swift index 8b1b628e..c862269f 100644 --- a/App/App_iOS.swift +++ b/App/App_iOS.swift @@ -25,14 +25,23 @@ struct Kiwix: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate private let fileMonitor: DirectoryMonitor + private let activityService: ActivityService? init() { fileMonitor = DirectoryMonitor(url: URL.documentDirectory) { LibraryOperations.scanDirectory($0) } + // MARK: - live activities + switch AppType.current { + case .kiwix: + activityService = nil //ActivityService() + case .custom: + activityService = nil + } UNUserNotificationCenter.current().delegate = appDelegate // MARK: - migrations if !ProcessInfo.processInfo.arguments.contains("testing") { _ = MigrationService().migrateAll() } + } var body: some Scene { diff --git a/Common/DownloadActivityAttributes.swift b/Common/DownloadActivityAttributes.swift new file mode 100644 index 00000000..79d9c40c --- /dev/null +++ b/Common/DownloadActivityAttributes.swift @@ -0,0 +1,59 @@ +// 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/. +#if os(iOS) + +import ActivityKit + +public struct DownloadActivityAttributes: ActivityAttributes { + + public let title: String + + public init(title: String) { + self.title = title + } + + public struct ContentState: Codable & Hashable { + public let items: [DownloadItem] + public var totalProgress: Double { + let sum = items.reduce(Double(0.0), { partialResult, item in + partialResult + item.progress + }) + return sum / Double(items.count) + } + + public init(items: [DownloadItem]) { + self.items = items + } + } + + public struct DownloadItem: Codable & Hashable { + public let uuid: UUID + public let description: String + public let progress: Double + + public init(uuid: UUID, description: String, progress: Double) { + self.uuid = uuid + self.description = description + self.progress = progress + } + + public init(completedFor uuid: UUID) { + self.uuid = uuid + self.progress = 1.0 + self.description = "Completed!" //TODO: update + } + } +} +#endif diff --git a/Support/Info.plist b/Support/Info.plist index 916b9d80..029fb8ad 100644 --- a/Support/Info.plist +++ b/Support/Info.plist @@ -63,5 +63,9 @@ + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + diff --git a/Views/LiveActivity/ActivityService.swift b/Views/LiveActivity/ActivityService.swift index 590dc3e6..09df3c4a 100644 --- a/Views/LiveActivity/ActivityService.swift +++ b/Views/LiveActivity/ActivityService.swift @@ -12,39 +12,39 @@ // // You should have received a copy of the GNU General Public License // along with Kiwix; If not, see https://www.gnu.org/licenses/. -// -//#if os(iOS) -// + +#if os(iOS) + import Combine import ActivityKit -import KiwixWidgetsExtension -// -//@MainActor -//final class ActivityService { -// -// private var cancellables = Set() -// private var activity: Activity? -// -// init( -// publisher: @MainActor () -> CurrentValueSubject<[UUID: DownloadState], Never> = { DownloadService.shared.progress.publisher -// } -// ) { -// publisher().sink { [weak self] (state: [UUID : DownloadState]) in -// guard let self else { return } -// if state.isEmpty { -// stop() -// } else { -// update(state: state) -// } -// }.store(in: &cancellables) -// } -// -// private func start(with state: [UUID: DownloadState]) { -// let content = ActivityContent( -// state: activityState(from: state), -// staleDate: nil, -// relevanceScore: 0.0 -// ) + +@MainActor +final class ActivityService { + + private var cancellables = Set() + private var activity: Activity? + + init( + publisher: @MainActor () -> CurrentValueSubject<[UUID: DownloadState], Never> = { DownloadService.shared.progress.publisher + } + ) { + publisher().sink { [weak self] (state: [UUID : DownloadState]) in + guard let self else { return } + if state.isEmpty { + stop() + } else { + update(state: state) + } + }.store(in: &cancellables) + } + + private func start(with state: [UUID: DownloadState]) { + let content = ActivityContent( + state: activityState(from: state), + staleDate: nil, + relevanceScore: 0.0 + ) + debugPrint("start with: \(state)") // if let activity = try? Activity // .request( // attributes: DownloadActivityAttributes(title: "Downloads"), @@ -59,9 +59,10 @@ import KiwixWidgetsExtension // } // } // } -// } -// -// private func update(state: [UUID: DownloadState]) { + } + + private func update(state: [UUID: DownloadState]) { + debugPrint("update state: \(state)") // guard let activity else { // start(with: state) // return @@ -74,9 +75,10 @@ import KiwixWidgetsExtension // ) // ) // } -// } -// -// private func stop() { + } + + private func stop() { + debugPrint("stop") // if let activity { // let previousState = activity.content.state // Task { @@ -86,21 +88,21 @@ import KiwixWidgetsExtension // self.activity = nil // } // } -// } -// -// private func activityState(from state: [UUID: DownloadState]) -> DownloadActivityAttributes.ContentState { -// DownloadActivityAttributes.ContentState( -// items: state.map { (key: UUID, download: DownloadState)-> DownloadActivityAttributes.DownloadItem in -// DownloadActivityAttributes.DownloadItem(uuid: key, description: key.uuidString, progress: Double(download.downloaded/download.total)) -// }) -// } -// -// private func completeState(for previousState: DownloadActivityAttributes.ContentState) -> DownloadActivityAttributes.ContentState { -// DownloadActivityAttributes -// .ContentState(items: previousState.items.map { item in -// DownloadActivityAttributes.DownloadItem(completedFor: item.uuid) -// }) -// } -//} -// -//#endif + } + + private func activityState(from state: [UUID: DownloadState]) -> DownloadActivityAttributes.ContentState { + DownloadActivityAttributes.ContentState( + items: state.map { (key: UUID, download: DownloadState)-> DownloadActivityAttributes.DownloadItem in + DownloadActivityAttributes.DownloadItem(uuid: key, description: key.uuidString, progress: Double(download.downloaded/download.total)) + }) + } + + private func completeState(for previousState: DownloadActivityAttributes.ContentState) -> DownloadActivityAttributes.ContentState { + DownloadActivityAttributes + .ContentState(items: previousState.items.map { item in + DownloadActivityAttributes.DownloadItem(completedFor: item.uuid) + }) + } +} + +#endif diff --git a/Widgets/Assets.xcassets/AccentColor.colorset/Contents.json b/Widgets/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Widgets/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/Assets.xcassets/AppIcon.appiconset/Contents.json b/Widgets/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/Widgets/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/Assets.xcassets/Contents.json b/Widgets/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Widgets/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/Assets.xcassets/WidgetBackground.colorset/Contents.json b/Widgets/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Widgets/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/DownloadsLiveActivity.swift b/Widgets/DownloadsLiveActivity.swift new file mode 100644 index 00000000..5144fdb9 --- /dev/null +++ b/Widgets/DownloadsLiveActivity.swift @@ -0,0 +1,94 @@ +// 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 ActivityKit +import WidgetKit +import SwiftUI + +struct DownloadsLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: DownloadActivityAttributes.self) { context in + // Lock screen/banner UI goes here + VStack { + ForEach(context.state.items, id: \.uuid) { item in + HStack { + Text(item.description) + ProgressView(value: item.progress) + } + + } + } + .activityBackgroundTint(Color.cyan) + .activitySystemActionForegroundColor(Color.black) + + } dynamicIsland: { context in + DynamicIsland { + // Expanded UI goes here. Compose the expanded UI through + // various regions, like leading/trailing/center/bottom + DynamicIslandExpandedRegion(.leading) { + VStack { + ForEach(context.state.items, id: \.uuid) { item in + Text(item.description) + } + } + } + DynamicIslandExpandedRegion(.trailing) { + ProgressView(value: context.state.totalProgress) + } + DynamicIslandExpandedRegion(.bottom) { + ProgressView(value: context.state.totalProgress) + } + } compactLeading: { + ProgressView(value: context.state.totalProgress) + } compactTrailing: { + ProgressView(value: context.state.totalProgress) + } minimal: { + ProgressView(value: context.state.totalProgress) + } + .widgetURL(URL(string: "https://www.kiwix.org")) + .keylineTint(Color.red) + } + } +} + +extension DownloadActivityAttributes { + fileprivate static var preview: DownloadActivityAttributes { + DownloadActivityAttributes(title: "Downloads") + } +} + +extension DownloadActivityAttributes.ContentState { + fileprivate static var midProgress: DownloadActivityAttributes.ContentState { + DownloadActivityAttributes.ContentState(items: [ + DownloadActivityAttributes.DownloadItem(uuid: UUID(), description: "First item", progress: 0.5), + DownloadActivityAttributes.DownloadItem(uuid: UUID(), description: "2nd item", progress: 0.9) + ]) + } + + fileprivate static var completed: DownloadActivityAttributes.ContentState { + DownloadActivityAttributes.ContentState(items: [ + DownloadActivityAttributes.DownloadItem(uuid: UUID(), description: "First item", progress: 1.0), + DownloadActivityAttributes.DownloadItem(uuid: UUID(), description: "2nd item", progress: 0.8) + ]) + } +} + +@available(iOS 18.0, *) +#Preview("Notification", as: .content, using: DownloadActivityAttributes.preview) { + DownloadsLiveActivity() +} contentStates: { + DownloadActivityAttributes.ContentState.midProgress + DownloadActivityAttributes.ContentState.completed +} diff --git a/Widgets/Info.plist b/Widgets/Info.plist new file mode 100644 index 00000000..0f118fb7 --- /dev/null +++ b/Widgets/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/Widgets/WidgetsBundle.swift b/Widgets/WidgetsBundle.swift new file mode 100644 index 00000000..251891f5 --- /dev/null +++ b/Widgets/WidgetsBundle.swift @@ -0,0 +1,25 @@ +// 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 WidgetKit +import SwiftUI + +@available(iOS 16.6, *) +@main +struct WidgetsBundle: WidgetBundle { + var body: some Widget { + DownloadsLiveActivity() + } +} diff --git a/project.yml b/project.yml index 54650797..329babaa 100644 --- a/project.yml +++ b/project.yml @@ -2,7 +2,7 @@ name: Kiwix options: xcodeVersion: 15.2 deploymentTarget: # the three latest major versions should be supported - iOS: 16.0 + iOS: 16.6 macOS: 13.0 generateEmptyDirectories: true useTabs: false @@ -87,6 +87,9 @@ targetTemplates: - path: PrivacyInfo.xcprivacy destinationFilters: - iOS + - path: Common + destinationFilters: + - iOS - path: Contents includes: - Resources @@ -136,6 +139,16 @@ targets: - path: Tests dependencies: - target: Kiwix + Widgets: + type: app-extension + supportedDestinations: [iOS] + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: self.Kiwix.widgets + INFOPLIST_FILE: Widgets/Info.plist + sources: + - path: Common + - path: Widgets schemes: Kiwix: