mirror of
https://github.com/kiwix/kiwix-apple.git
synced 2025-09-22 02:52:39 -04:00
Merge pull request #1126 from kiwix/live-activities-update
Use progress bar with timer updates for live activities
This commit is contained in:
commit
83518f2640
@ -52,7 +52,15 @@ public struct DownloadActivityAttributes: ActivityAttributes {
|
||||
}
|
||||
|
||||
public var estimatedTimeLeft: TimeInterval {
|
||||
items.map(\.timeRemaining).max() ?? 0
|
||||
items.filter { (item: DownloadActivityAttributes.DownloadItem) in
|
||||
!item.isPaused
|
||||
}.map(\.timeRemaining).max() ?? 0
|
||||
}
|
||||
|
||||
public var isAllPaused: Bool {
|
||||
items.allSatisfy { (item: DownloadActivityAttributes.DownloadItem) in
|
||||
item.isPaused
|
||||
}
|
||||
}
|
||||
|
||||
public var progress: Double {
|
||||
@ -70,6 +78,7 @@ public struct DownloadActivityAttributes: ActivityAttributes {
|
||||
let downloaded: Int64
|
||||
let total: Int64
|
||||
let timeRemaining: TimeInterval
|
||||
let isPaused: Bool
|
||||
var progress: Double {
|
||||
progressFor(items: [self]).fractionCompleted
|
||||
}
|
||||
@ -77,12 +86,20 @@ public struct DownloadActivityAttributes: ActivityAttributes {
|
||||
progressFor(items: [self]).localizedAdditionalDescription
|
||||
}
|
||||
|
||||
public init(uuid: UUID, description: String, downloaded: Int64, total: Int64, timeRemaining: TimeInterval) {
|
||||
public init(
|
||||
uuid: UUID,
|
||||
description: String,
|
||||
downloaded: Int64,
|
||||
total: Int64,
|
||||
timeRemaining: TimeInterval,
|
||||
isPaused: Bool
|
||||
) {
|
||||
self.uuid = uuid
|
||||
self.description = description
|
||||
self.downloaded = downloaded
|
||||
self.total = total
|
||||
self.timeRemaining = timeRemaining
|
||||
self.isPaused = isPaused
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,102 +13,11 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
//
|
||||
// DownloadService.swift
|
||||
// Kiwix
|
||||
|
||||
import Combine
|
||||
import CoreData
|
||||
import UserNotifications
|
||||
import os
|
||||
|
||||
struct DownloadState: Codable {
|
||||
let downloaded: Int64
|
||||
let total: Int64
|
||||
let resumeData: Data?
|
||||
|
||||
static func empty() -> DownloadState {
|
||||
.init(downloaded: 0, total: 1, resumeData: nil)
|
||||
}
|
||||
|
||||
init(downloaded: Int64, total: Int64, resumeData: Data?) {
|
||||
guard total >= downloaded, total > 0 else {
|
||||
assertionFailure("invalid download progress values: downloaded \(downloaded) total: \(total)")
|
||||
self.downloaded = downloaded
|
||||
self.total = downloaded
|
||||
self.resumeData = resumeData
|
||||
return
|
||||
}
|
||||
self.downloaded = downloaded
|
||||
self.total = total
|
||||
self.resumeData = resumeData
|
||||
}
|
||||
|
||||
func updatedWith(downloaded: Int64, total: Int64) -> DownloadState {
|
||||
DownloadState(downloaded: downloaded, total: total, resumeData: resumeData)
|
||||
}
|
||||
|
||||
func updatedWith(resumeData: Data?) -> DownloadState {
|
||||
DownloadState(downloaded: downloaded, total: total, resumeData: resumeData)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DownloadTasksPublisher {
|
||||
|
||||
let publisher: CurrentValueSubject<[UUID: DownloadState], Never>
|
||||
private var states = [UUID: DownloadState]()
|
||||
|
||||
init() {
|
||||
publisher = CurrentValueSubject(states)
|
||||
if let jsonData = UserDefaults.standard.object(forKey: "downloadStates") as? Data,
|
||||
let storedStates = try? JSONDecoder().decode([UUID: DownloadState].self, from: jsonData) {
|
||||
states = storedStates
|
||||
publisher.send(states)
|
||||
}
|
||||
}
|
||||
|
||||
func updateFor(uuid: UUID, downloaded: Int64, total: Int64) {
|
||||
if let state = states[uuid] {
|
||||
states[uuid] = state.updatedWith(downloaded: downloaded, total: total)
|
||||
} else {
|
||||
states[uuid] = DownloadState(downloaded: downloaded, total: total, resumeData: nil)
|
||||
}
|
||||
publisher.send(states)
|
||||
saveState()
|
||||
}
|
||||
|
||||
func resetFor(uuid: UUID) {
|
||||
states.removeValue(forKey: uuid)
|
||||
publisher.send(states)
|
||||
saveState()
|
||||
}
|
||||
|
||||
func isEmpty() -> Bool {
|
||||
states.isEmpty
|
||||
}
|
||||
|
||||
func resumeDataFor(uuid: UUID) -> Data? {
|
||||
states[uuid]?.resumeData
|
||||
}
|
||||
|
||||
func updateFor(uuid: UUID, withResumeData resumeData: Data?) {
|
||||
if let state = states[uuid] {
|
||||
states[uuid] = state.updatedWith(resumeData: resumeData)
|
||||
publisher.send(states)
|
||||
saveState()
|
||||
} else {
|
||||
assertionFailure("there should be a download task for: \(uuid)")
|
||||
}
|
||||
}
|
||||
|
||||
private func saveState() {
|
||||
if let jsonStates = try? JSONEncoder().encode(states) {
|
||||
UserDefaults.standard.setValue(jsonStates, forKey: "downloadStates")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class DownloadService: NSObject, URLSessionDelegate, URLSessionTaskDelegate, URLSessionDownloadDelegate {
|
||||
static let shared = DownloadService()
|
||||
private let queue = DispatchQueue(label: "downloads", qos: .background)
|
52
Model/Downloads/DownloadState.swift
Normal file
52
Model/Downloads/DownloadState.swift
Normal file
@ -0,0 +1,52 @@
|
||||
// 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
|
||||
|
||||
struct DownloadState: Codable {
|
||||
let downloaded: Int64
|
||||
let total: Int64
|
||||
let resumeData: Data?
|
||||
|
||||
var isPaused: Bool {
|
||||
resumeData != nil
|
||||
}
|
||||
|
||||
static func empty() -> DownloadState {
|
||||
.init(downloaded: 0, total: 1, resumeData: nil)
|
||||
}
|
||||
|
||||
init(downloaded: Int64, total: Int64, resumeData: Data?) {
|
||||
guard total >= downloaded, total > 0 else {
|
||||
assertionFailure("invalid download progress values: downloaded \(downloaded) total: \(total)")
|
||||
self.downloaded = downloaded
|
||||
self.total = downloaded
|
||||
self.resumeData = resumeData
|
||||
return
|
||||
}
|
||||
self.downloaded = downloaded
|
||||
self.total = total
|
||||
self.resumeData = resumeData
|
||||
}
|
||||
|
||||
func updatedWith(downloaded: Int64, total: Int64) -> DownloadState {
|
||||
DownloadState(downloaded: downloaded, total: total, resumeData: resumeData)
|
||||
}
|
||||
|
||||
func updatedWith(resumeData: Data?) -> DownloadState {
|
||||
DownloadState(downloaded: downloaded, total: total, resumeData: resumeData)
|
||||
}
|
||||
}
|
73
Model/Downloads/DownloadTasksPublisher.swift
Normal file
73
Model/Downloads/DownloadTasksPublisher.swift
Normal file
@ -0,0 +1,73 @@
|
||||
// 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
|
||||
|
||||
@MainActor
|
||||
final class DownloadTasksPublisher {
|
||||
|
||||
let publisher: CurrentValueSubject<[UUID: DownloadState], Never>
|
||||
private var states = [UUID: DownloadState]()
|
||||
|
||||
init() {
|
||||
publisher = CurrentValueSubject(states)
|
||||
if let jsonData = UserDefaults.standard.object(forKey: "downloadStates") as? Data,
|
||||
let storedStates = try? JSONDecoder().decode([UUID: DownloadState].self, from: jsonData) {
|
||||
states = storedStates
|
||||
publisher.send(states)
|
||||
}
|
||||
}
|
||||
|
||||
func updateFor(uuid: UUID, downloaded: Int64, total: Int64) {
|
||||
if let state = states[uuid] {
|
||||
states[uuid] = state.updatedWith(downloaded: downloaded, total: total)
|
||||
} else {
|
||||
states[uuid] = DownloadState(downloaded: downloaded, total: total, resumeData: nil)
|
||||
}
|
||||
publisher.send(states)
|
||||
saveState()
|
||||
}
|
||||
|
||||
func resetFor(uuid: UUID) {
|
||||
states.removeValue(forKey: uuid)
|
||||
publisher.send(states)
|
||||
saveState()
|
||||
}
|
||||
|
||||
func isEmpty() -> Bool {
|
||||
states.isEmpty
|
||||
}
|
||||
|
||||
func resumeDataFor(uuid: UUID) -> Data? {
|
||||
states[uuid]?.resumeData
|
||||
}
|
||||
|
||||
func updateFor(uuid: UUID, withResumeData resumeData: Data?) {
|
||||
if let state = states[uuid] {
|
||||
states[uuid] = state.updatedWith(resumeData: resumeData)
|
||||
publisher.send(states)
|
||||
saveState()
|
||||
} else {
|
||||
assertionFailure("there should be a download task for: \(uuid)")
|
||||
}
|
||||
}
|
||||
|
||||
private func saveState() {
|
||||
if let jsonStates = try? JSONEncoder().encode(states) {
|
||||
UserDefaults.standard.setValue(jsonStates, forKey: "downloadStates")
|
||||
}
|
||||
}
|
||||
}
|
@ -611,8 +611,7 @@ final class BrowserViewModel: NSObject, ObservableObject,
|
||||
)
|
||||
actions.append(
|
||||
UIAction(title: LocalString.common_dialog_button_open_in_new_tab,
|
||||
image: UIImage(systemName: "doc.badge.plus")) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
image: UIImage(systemName: "doc.badge.plus")) { _ in
|
||||
Task { @MainActor in
|
||||
NotificationCenter.openURL(url, inNewTab: true)
|
||||
}
|
||||
|
@ -11,7 +11,7 @@
|
||||
// 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.orgllll/llicenses/.
|
||||
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
@ -49,9 +49,9 @@ final class ActivityService {
|
||||
publisher().sink { [weak self] (state: [UUID: DownloadState]) in
|
||||
guard let self else { return }
|
||||
if state.isEmpty {
|
||||
stop()
|
||||
self.stop()
|
||||
} else {
|
||||
update(state: state)
|
||||
self.update(state: state)
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
@ -93,19 +93,31 @@ final class ActivityService {
|
||||
return
|
||||
}
|
||||
let now = CACurrentMediaTime()
|
||||
guard let activity, (now - lastUpdate) > updateFrequency else {
|
||||
// make sure we don't update too frequently
|
||||
// unless there's a pause, we do want immediate update
|
||||
let isTooEarlyToUpdate = if hasAnyPause(in: state) {
|
||||
false
|
||||
} else {
|
||||
(now - lastUpdate) <= updateFrequency
|
||||
}
|
||||
guard let activity, !isTooEarlyToUpdate else {
|
||||
return
|
||||
}
|
||||
lastUpdate = now
|
||||
Task {
|
||||
let activityState = await activityState(from: state, downloadTimes: downloadTimes)
|
||||
await activity.update(
|
||||
ActivityContent<DownloadActivityAttributes.ContentState>(
|
||||
state: activityState,
|
||||
staleDate: nil
|
||||
)
|
||||
let newContent = ActivityContent<DownloadActivityAttributes.ContentState>(
|
||||
state: activityState,
|
||||
staleDate: nil
|
||||
)
|
||||
if #available(iOS 17.2, *) {
|
||||
// important to define a timestamp, this way iOS knows which updates
|
||||
// can be dropped, if too many of them queues up
|
||||
await activity.update(newContent, timestamp: Date.now)
|
||||
} else {
|
||||
await activity.update(newContent)
|
||||
}
|
||||
}
|
||||
lastUpdate = now
|
||||
}
|
||||
|
||||
private func updatedDownloadTimes(from states: [UUID: DownloadState]) -> [UUID: CFTimeInterval] {
|
||||
@ -171,9 +183,18 @@ final class ActivityService {
|
||||
description: titles[key] ?? key.uuidString,
|
||||
downloaded: download.downloaded,
|
||||
total: download.total,
|
||||
timeRemaining: downloadTimes[key] ?? 0)
|
||||
timeRemaining: downloadTimes[key] ?? 0,
|
||||
isPaused: download.isPaused
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private func hasAnyPause(in state: [UUID: DownloadState]) -> Bool {
|
||||
guard !state.isEmpty else { return false }
|
||||
return !state.values.allSatisfy { (download: DownloadState) in
|
||||
download.isPaused == false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
@ -21,73 +21,51 @@ 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 goes here
|
||||
// Lock screen/banner UI
|
||||
let timeInterval = startTime...Date(
|
||||
timeInterval: context.state.estimatedTimeLeft,
|
||||
since: .now
|
||||
)
|
||||
VStack {
|
||||
HStack {
|
||||
KiwixLogo(maxHeight: 50)
|
||||
.padding()
|
||||
VStack(alignment: .leading) {
|
||||
Text(context.state.title)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.headline)
|
||||
.bold()
|
||||
HStack {
|
||||
Text(
|
||||
timerInterval: Date.now...Date(
|
||||
timeInterval: context.state.estimatedTimeLeft,
|
||||
since: .now
|
||||
)
|
||||
)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.caption)
|
||||
.tint(.secondary)
|
||||
Text(context.state.progressDescription)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.caption)
|
||||
.tint(.secondary)
|
||||
}
|
||||
titleFor(context.state.title)
|
||||
progressFor(state: context.state, timeInterval: timeInterval)
|
||||
}
|
||||
Spacer()
|
||||
ProgressView(value: context.state.progress)
|
||||
.progressViewStyle(CircularProgressGaugeStyle(lineWidth: 5.7))
|
||||
.frame(width: 24, height: 24)
|
||||
.padding()
|
||||
.padding()
|
||||
KiwixLogo(maxHeight: 50)
|
||||
.padding(.trailing)
|
||||
}
|
||||
}
|
||||
.modifier(WidgetBackgroundModifier())
|
||||
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
// Expanded UI goes here. Compose the expanded UI through
|
||||
// various regions, like leading/trailing/center/bottom
|
||||
// Expanded UI
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Spacer()
|
||||
KiwixLogo(maxHeight: 50)
|
||||
Spacer()
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
ProgressView(value: context.state.progress)
|
||||
.progressViewStyle(CircularProgressGaugeStyle(lineWidth: 11.4))
|
||||
.padding(6.0)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
let timeInterval = startTime...Date(
|
||||
timeInterval: context.state.estimatedTimeLeft,
|
||||
since: .now
|
||||
)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(context.state.title)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.headline)
|
||||
.bold()
|
||||
Text(context.state.progressDescription)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.caption)
|
||||
.tint(.secondary)
|
||||
titleFor(context.state.title)
|
||||
progressFor(state: context.state, timeInterval: timeInterval)
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.dynamicIsland(verticalPlacement: .belowIfTooWide)
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
KiwixLogo(maxHeight: 50)
|
||||
.padding()
|
||||
}
|
||||
} compactLeading: {
|
||||
KiwixLogo()
|
||||
@ -105,6 +83,49 @@ struct DownloadsLiveActivity: Widget {
|
||||
.keylineTint(Color.red)
|
||||
}.containerBackgroundRemovable()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func titleFor(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.lineLimit(1)
|
||||
.frame(minWidth: 150, alignment: .leading)
|
||||
.font(.headline)
|
||||
.bold()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func progressText(_ description: String) -> some View {
|
||||
Text(description)
|
||||
.lineLimit(1)
|
||||
.font(.caption)
|
||||
.tint(.secondary)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func progressFor(
|
||||
state: DownloadActivityAttributes.ContentState,
|
||||
timeInterval: ClosedRange<Date>
|
||||
) -> some View {
|
||||
if !state.isAllPaused {
|
||||
ProgressView(timerInterval: timeInterval, countsDown: false, label: {
|
||||
progressText(state.progressDescription)
|
||||
}, currentValueLabel: {
|
||||
Text(timerInterval: timeInterval)
|
||||
.font(.caption)
|
||||
.tint(.secondary)
|
||||
})
|
||||
.tint(Color.primary)
|
||||
} else {
|
||||
ProgressView(value: state.progress, label: {
|
||||
progressText(state.progressDescription)
|
||||
}, currentValueLabel: {
|
||||
Label("", systemImage: "pause.fill")
|
||||
.font(.caption)
|
||||
.tint(.secondary)
|
||||
})
|
||||
.tint(Color.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadActivityAttributes {
|
||||
@ -123,14 +144,16 @@ extension DownloadActivityAttributes.ContentState {
|
||||
description: "First item",
|
||||
downloaded: 128,
|
||||
total: 256,
|
||||
timeRemaining: 3
|
||||
timeRemaining: 15,
|
||||
isPaused: true
|
||||
),
|
||||
DownloadActivityAttributes.DownloadItem(
|
||||
uuid: UUID(),
|
||||
description: "2nd item",
|
||||
downloaded: 90,
|
||||
total: 124,
|
||||
timeRemaining: 2
|
||||
timeRemaining: 2,
|
||||
isPaused: true
|
||||
)
|
||||
]
|
||||
)
|
||||
@ -145,14 +168,16 @@ extension DownloadActivityAttributes.ContentState {
|
||||
description: "First item",
|
||||
downloaded: 256,
|
||||
total: 256,
|
||||
timeRemaining: 0
|
||||
timeRemaining: 0,
|
||||
isPaused: false
|
||||
),
|
||||
DownloadActivityAttributes.DownloadItem(
|
||||
uuid: UUID(),
|
||||
description: "2nd item",
|
||||
downloaded: 110,
|
||||
total: 124,
|
||||
timeRemaining: 2
|
||||
timeRemaining: 2,
|
||||
isPaused: false
|
||||
)
|
||||
]
|
||||
)
|
||||
|
@ -31,7 +31,7 @@ struct KiwixLogo: View {
|
||||
Image("KiwixLogo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: maxHeight / 1.6182, height: maxHeight / 1.6182)
|
||||
.frame(width: maxHeight * 0.75, height: maxHeight * 0.75)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user