Update: 2026-07-30 02:27:45
This commit is contained in:
@@ -1,13 +1,234 @@
|
||||
import ActivityKit
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Live Activity attributes.
|
||||
//
|
||||
// This MUST stay byte-for-byte compatible with InvoiceBatchAttributes in
|
||||
// MusadaqLiveActivity/MusadaqLiveActivityBundle.swift. ActivityKit matches the
|
||||
// app's activity to the widget's UI by attributes type name and shape, and the
|
||||
// two targets compile separately, so the declaration is intentionally duplicated
|
||||
// rather than shared. Change one, change the other.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
struct InvoiceBatchAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
var current: Int
|
||||
var total: Int
|
||||
var isDone: Bool
|
||||
var failed: Int = 0
|
||||
var statusText: String = ""
|
||||
}
|
||||
|
||||
var companyName: String
|
||||
}
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
|
||||
/// The Live Activity currently on screen, if any.
|
||||
private var currentActivity: Any?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
|
||||
if let controller = window?.rootViewController as? FlutterViewController {
|
||||
registerLiveActivityChannel(with: controller.binaryMessenger)
|
||||
}
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
// MARK: - Flutter channel
|
||||
|
||||
private func registerLiveActivityChannel(with messenger: FlutterBinaryMessenger) {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "com.musadaq.liveactivity",
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let args = call.arguments as? [String: Any] ?? [:]
|
||||
|
||||
switch call.method {
|
||||
case "start":
|
||||
self.startActivity(args: args, result: result)
|
||||
case "update":
|
||||
self.updateActivity(args: args, result: result)
|
||||
case "end":
|
||||
self.endActivity(args: args, result: result)
|
||||
case "isSupported":
|
||||
result(self.areActivitiesEnabled())
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ActivityKit
|
||||
|
||||
private func areActivitiesEnabled() -> Bool {
|
||||
if #available(iOS 16.2, *) {
|
||||
return ActivityAuthorizationInfo().areActivitiesEnabled
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Starts a Live Activity and returns its ActivityKit push token (hex) so the
|
||||
/// server can update it remotely while the app is closed.
|
||||
private func startActivity(args: [String: Any], result: @escaping FlutterResult) {
|
||||
guard #available(iOS 16.2, *) else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||
// The user disabled Live Activities for this app. Not an error.
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Never stack activities: replace any previous one.
|
||||
endCurrentActivityImmediately()
|
||||
|
||||
let companyName = args["companyName"] as? String ?? ""
|
||||
let total = args["total"] as? Int ?? 1
|
||||
let current = args["current"] as? Int ?? 0
|
||||
|
||||
let attributes = InvoiceBatchAttributes(companyName: companyName)
|
||||
let state = InvoiceBatchAttributes.ContentState(
|
||||
current: current,
|
||||
total: max(total, 1),
|
||||
isDone: false,
|
||||
failed: 0,
|
||||
statusText: "جارٍ الرفع"
|
||||
)
|
||||
|
||||
do {
|
||||
let activity = try Activity<InvoiceBatchAttributes>.request(
|
||||
attributes: attributes,
|
||||
content: .init(state: state, staleDate: Date().addingTimeInterval(30 * 60)),
|
||||
pushType: .token
|
||||
)
|
||||
self.currentActivity = activity
|
||||
|
||||
// The push token arrives asynchronously, so hand the result back from the
|
||||
// first token update rather than blocking here.
|
||||
var didReturn = false
|
||||
let lock = NSLock()
|
||||
|
||||
Task {
|
||||
for await tokenData in activity.pushTokenUpdates {
|
||||
let token = tokenData.map { String(format: "%02x", $0) }.joined()
|
||||
lock.lock()
|
||||
let shouldReturn = !didReturn
|
||||
didReturn = true
|
||||
lock.unlock()
|
||||
|
||||
if shouldReturn {
|
||||
DispatchQueue.main.async { result(token) }
|
||||
} else {
|
||||
// Token rotated mid-activity: tell Flutter so it can re-register.
|
||||
DispatchQueue.main.async {
|
||||
if let controller = self.window?.rootViewController as? FlutterViewController {
|
||||
FlutterMethodChannel(
|
||||
name: "com.musadaq.liveactivity",
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
).invokeMethod("onPushTokenChanged", arguments: token)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't leave Flutter awaiting forever if no token ever arrives.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
|
||||
lock.lock()
|
||||
let shouldReturn = !didReturn
|
||||
didReturn = true
|
||||
lock.unlock()
|
||||
if shouldReturn { result(nil) }
|
||||
}
|
||||
} catch {
|
||||
NSLog("[LiveActivity] request failed: \(error.localizedDescription)")
|
||||
result(FlutterError(
|
||||
code: "START_FAILED",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private func updateActivity(args: [String: Any], result: @escaping FlutterResult) {
|
||||
guard #available(iOS 16.2, *),
|
||||
let activity = currentActivity as? Activity<InvoiceBatchAttributes> else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let current = args["current"] as? Int ?? 0
|
||||
let total = max(args["total"] as? Int ?? 1, 1)
|
||||
let failed = args["failed"] as? Int ?? 0
|
||||
let isDone = args["isDone"] as? Bool ?? false
|
||||
let statusText = args["statusText"] as? String ?? ""
|
||||
|
||||
let state = InvoiceBatchAttributes.ContentState(
|
||||
current: current,
|
||||
total: total,
|
||||
isDone: isDone,
|
||||
failed: failed,
|
||||
statusText: statusText
|
||||
)
|
||||
|
||||
Task {
|
||||
await activity.update(.init(state: state, staleDate: Date().addingTimeInterval(30 * 60)))
|
||||
DispatchQueue.main.async { result(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
private func endActivity(args: [String: Any], result: @escaping FlutterResult) {
|
||||
guard #available(iOS 16.2, *),
|
||||
let activity = currentActivity as? Activity<InvoiceBatchAttributes> else {
|
||||
currentActivity = nil
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let finalText = args["finalText"] as? String ?? "اكتمل"
|
||||
let finalState = InvoiceBatchAttributes.ContentState(
|
||||
current: activity.content.state.total,
|
||||
total: activity.content.state.total,
|
||||
isDone: true,
|
||||
failed: activity.content.state.failed,
|
||||
statusText: finalText
|
||||
)
|
||||
|
||||
currentActivity = nil
|
||||
|
||||
Task {
|
||||
// Leave the finished state visible briefly instead of vanishing instantly.
|
||||
await activity.end(
|
||||
.init(state: finalState, staleDate: nil),
|
||||
dismissalPolicy: .after(Date().addingTimeInterval(10))
|
||||
)
|
||||
DispatchQueue.main.async { result(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
private func endCurrentActivityImmediately() {
|
||||
guard #available(iOS 16.2, *),
|
||||
let activity = currentActivity as? Activity<InvoiceBatchAttributes> else {
|
||||
return
|
||||
}
|
||||
currentActivity = nil
|
||||
Task { await activity.end(nil, dismissalPolicy: .immediate) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user