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.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 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 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 else { return } currentActivity = nil Task { await activity.end(nil, dismissalPolicy: .immediate) } } }