Update: 2026-07-30 02:27:45

This commit is contained in:
Hamza-Ayed
2026-07-30 02:27:45 +03:00
parent 5f62455113
commit ca4a7c2e70
56 changed files with 3391 additions and 709 deletions
@@ -2,83 +2,15 @@
// MusadaqLiveActivity.swift
// MusadaqLiveActivity
//
// Created by Hamza Aleghwairyeen on 07/05/2026.
// Intentionally empty.
//
// This file used to hold the stock Xcode widget template (a "😀" static widget
// with its own TimelineProvider). It was never added to
// MusadaqLiveActivityBundle, so it only ever shipped as dead code — and its
// file-scope `Provider` type collided conceptually with the control widget's.
//
// The real implementation is InvoiceBatchLiveActivity in
// MusadaqLiveActivityBundle.swift.
//
import WidgetKit
import SwiftUI
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), emoji: "😀")
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date(), emoji: "😀")
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
// Generate a timeline consisting of five entries an hour apart, starting from the current date.
let currentDate = Date()
for hourOffset in 0 ..< 5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(date: entryDate, emoji: "😀")
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
// func relevances() async -> WidgetRelevances<Void> {
// // Generate a list containing the contexts this widget is relevant in.
// }
}
struct SimpleEntry: TimelineEntry {
let date: Date
let emoji: String
}
struct MusadaqLiveActivityEntryView : View {
var entry: Provider.Entry
var body: some View {
VStack {
Text("Time:")
Text(entry.date, style: .time)
Text("Emoji:")
Text(entry.emoji)
}
}
}
struct MusadaqLiveActivity: Widget {
let kind: String = "MusadaqLiveActivity"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
if #available(iOS 17.0, *) {
MusadaqLiveActivityEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
} else {
MusadaqLiveActivityEntryView(entry: entry)
.padding()
.background()
}
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
}
}
#Preview(as: .systemSmall) {
MusadaqLiveActivity()
} timeline: {
SimpleEntry(date: .now, emoji: "😀")
SimpleEntry(date: .now, emoji: "🤩")
}
import Foundation
@@ -10,11 +10,18 @@ import SwiftUI
import ActivityKit
// ─── 1. Data Model ───────────────────────────────────
//
// Must stay identical to InvoiceBatchAttributes in Runner/AppDelegate.swift and
// to the "content-state" keys the server sends in
// NotificationService::dispatchLiveActivityUpdate(). A mismatch makes iOS drop
// the update silently.
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
}
@@ -27,7 +34,31 @@ struct MusadaqLiveActivityBundle: WidgetBundle {
}
}
// ─── 3. Widget ───────────────────────────────────────
// ─── 3. Copy helpers ─────────────────────────────────
// A partially failed batch must not read as a clean success.
private func headline(for state: InvoiceBatchAttributes.ContentState) -> String {
if state.isDone {
if state.failed > 0 && state.current > 0 { return "⚠️ اكتمل مع أخطاء" }
if state.failed > 0 { return "❌ فشلت المعالجة" }
return "✅ تم بنجاح"
}
return state.statusText.isEmpty
? "مُصادَق — جارٍ الرفع..."
: "مُصادَق — \(state.statusText)"
}
private func subtitle(for state: InvoiceBatchAttributes.ContentState, company: String) -> String {
var text = "\(state.current) / \(state.total) فاتورة"
if state.failed > 0 {
text += " — \(state.failed) فاشلة"
}
if !company.isEmpty {
text += " — \(company)"
}
return text
}
// ─── 4. Widget ───────────────────────────────────────
struct InvoiceBatchLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: InvoiceBatchAttributes.self) { context in
@@ -40,15 +71,17 @@ struct InvoiceBatchLiveActivity: Widget {
.foregroundColor(Color(red: 0.831, green: 0.659, blue: 0.263)) // #D4A843
.font(.title2)
VStack(alignment: .leading, spacing: 4) {
Text(context.state.isDone ? "✅ تم الرفع بنجاح" : "مُصادَق — جارٍ الرفع...")
Text(headline(for: context.state))
.font(.caption.bold())
.foregroundColor(.white)
ProgressView(
value: Double(context.state.current),
total: Double(context.state.total)
value: Double(context.state.current + context.state.failed),
total: Double(max(context.state.total, 1))
)
.tint(Color(red: 0.831, green: 0.659, blue: 0.263))
Text("\(context.state.current) / \(context.state.total) فاتورة — \(context.attributes.companyName)")
.tint(context.state.failed > 0
? Color(red: 0.96, green: 0.62, blue: 0.04)
: Color(red: 0.831, green: 0.659, blue: 0.263))
Text(subtitle(for: context.state, company: context.attributes.companyName))
.font(.caption2)
.foregroundColor(.gray)
}
@@ -66,8 +99,10 @@ struct InvoiceBatchLiveActivity: Widget {
.font(.caption.bold()).foregroundColor(.white)
}
DynamicIslandExpandedRegion(.bottom) {
ProgressView(value: Double(context.state.current),
total: Double(context.state.total))
// Divide-by-zero guard: total arrives as 0 if a push lands
// before the first image is registered.
ProgressView(value: Double(context.state.current + context.state.failed),
total: Double(max(context.state.total, 1)))
.tint(Color(red: 0.831, green: 0.659, blue: 0.263))
}
} compactLeading: {
@@ -2,53 +2,15 @@
// MusadaqLiveActivityControl.swift
// MusadaqLiveActivity
//
// Created by Hamza Aleghwairyeen on 07/05/2026.
// Intentionally empty.
//
// This file used to hold the stock Xcode ControlWidget template — a "Start
// Timer" toggle whose `perform()` body was an empty comment. It was not listed
// in MusadaqLiveActivityBundle, so iOS never surfaced it; had it been surfaced,
// it would have shown users a toggle that does nothing.
//
// Musadaq has no Control Center widget. If one is added later, declare it here
// AND add it to MusadaqLiveActivityBundle.
//
import AppIntents
import SwiftUI
import WidgetKit
struct MusadaqLiveActivityControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: "com.example.musadaqApp.MusadaqLiveActivity",
provider: Provider()
) { value in
ControlWidgetToggle(
"Start Timer",
isOn: value,
action: StartTimerIntent()
) { isRunning in
Label(isRunning ? "On" : "Off", systemImage: "timer")
}
}
.displayName("Timer")
.description("A an example control that runs a timer.")
}
}
extension MusadaqLiveActivityControl {
struct Provider: ControlValueProvider {
var previewValue: Bool {
false
}
func currentValue() async throws -> Bool {
let isRunning = true // Check if the timer is running
return isRunning
}
}
}
struct StartTimerIntent: SetValueIntent {
static let title: LocalizedStringResource = "Start a timer"
@Parameter(title: "Timer is running")
var value: Bool
func perform() async throws -> some IntentResult {
// Start / stop the timer based on `value`.
return .result()
}
}
import Foundation
+36 -37
View File
@@ -46,32 +46,32 @@ PODS:
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase/CoreOnly (12.12.0):
- FirebaseCore (~> 12.12.0)
- Firebase/Messaging (12.12.0):
- Firebase/CoreOnly (12.13.0):
- FirebaseCore (~> 12.13.0)
- Firebase/Messaging (12.13.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.12.0)
- firebase_core (4.7.0):
- Firebase/CoreOnly (= 12.12.0)
- FirebaseMessaging (~> 12.13.0)
- firebase_core (4.9.0):
- Firebase/CoreOnly (= 12.13.0)
- Flutter
- firebase_messaging (16.2.0):
- Firebase/Messaging (= 12.12.0)
- firebase_messaging (16.2.2):
- Firebase/Messaging (= 12.13.0)
- firebase_core
- Flutter
- FirebaseCore (12.12.1):
- FirebaseCoreInternal (~> 12.12.0)
- FirebaseCore (12.13.0):
- FirebaseCoreInternal (~> 12.13.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreInternal (12.12.0):
- FirebaseCoreInternal (12.13.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseInstallations (12.12.0):
- FirebaseCore (~> 12.12.0)
- FirebaseInstallations (12.13.0):
- FirebaseCore (~> 12.13.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (12.12.0):
- FirebaseCore (~> 12.12.0)
- FirebaseInstallations (~> 12.12.0)
- FirebaseMessaging (12.13.0):
- FirebaseCore (~> 12.13.0)
- FirebaseInstallations (~> 12.13.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/Environment (~> 8.1)
@@ -84,6 +84,8 @@ PODS:
- Mantle
- SDWebImage
- SDWebImageWebPCoder
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
@@ -141,15 +143,12 @@ PODS:
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- ObjectBox (4.4.1)
- ObjectBox (5.3.0-beta.4)
- objectbox_flutter_libs (0.0.1):
- Flutter
- ObjectBox (= 4.4.1)
- ObjectBox (= 5.3.0-beta.4)
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- printing (1.0.0):
@@ -187,13 +186,13 @@ DEPENDENCIES:
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`)
- flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- freerasp (from `.symlinks/plugins/freerasp/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- printing (from `.symlinks/plugins/printing/ios`)
- record_ios (from `.symlinks/plugins/record_ios/ios`)
@@ -245,6 +244,8 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_image_compress_common:
:path: ".symlinks/plugins/flutter_image_compress_common/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_secure_storage_darwin:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
freerasp:
@@ -257,8 +258,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
printing:
@@ -285,28 +284,28 @@ SPEC CHECKSUMS:
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Firebase: aa154fee4e9b8eac17aa42344988865b3e857d33
firebase_core: 9156a152117c843440b0b990c785aa0259bc5447
firebase_messaging: 0d962ab44ff24ed36deb8fa2ee043c4671858269
FirebaseCore: 86241206e656f5c80c995e370e6c975913b9b284
FirebaseCoreInternal: 7c12fc3011d889085e765e317d7b9fd1cef97af9
FirebaseInstallations: 4e6e162aa4abaaeeeb01dd00179dfc5ad9c2194e
FirebaseMessaging: 341004946fa7ffc741344b20f1b667514fc93e31
Firebase: 7d62445aeabdaea36f7d372f33052fed9a72514f
firebase_core: 0013f886fbd0b4950865551eaab47784424bfdb5
firebase_messaging: b875e4088ddd9ecd1834f6c89f6e0a1ffc7d98f0
FirebaseCore: 58905958aa00a061397a0fd759ae4b55bddb3576
FirebaseCoreInternal: 37bee58388fc6d183f0ab1b32d69ae44f2cf8aad
FirebaseInstallations: 134bde50e477628ded76070efdb12d515d53f948
FirebaseMessaging: 30564b85d2f81a96f9d312bd23acf8186ff092ae
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
freerasp: d77275f774facb901f52e9608e5bd34768728363
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
ObjectBox: eccb95ea2054c39d81dfa2d4ccc5f1e31187228a
objectbox_flutter_libs: ed1510f71602e4a0d3f2a721324e468d066fdbb9
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
printing: 54ff03f28fe9ba3aa93358afb80a8595a071dd07
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
@@ -317,7 +316,7 @@ SPEC CHECKSUMS:
speech_to_text: 3b313d98516d3d0406cea424782ec25470c59d19
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
PODFILE CHECKSUM: a409a572b05f394ce1fca5d08bea69ffac194079
+221
View File
@@ -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) }
}
}
+8
View File
@@ -45,6 +45,14 @@
<string>تطبيق مُصادَق قد يحتاج للوصول إلى الموقع الجغرافي لتحسين تجربة المستخدم وتوفير خدمات مخصصة حسب المنطقة.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<!-- remote-notification is required for the silent data pushes that carry
batch progress. Without it iOS drops content-available payloads while the
app is backgrounded, so progress froze the moment the user left the app. -->
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
<string>fetch</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -6,5 +6,10 @@
<array>
<string>group.com.musadaq.app</string>
</array>
<!-- Required for APNs registration. Without this key the app never receives
an APNs device token, so FirebaseMessaging.getToken() fails on iOS and
push_token was being stored as null for every iPhone. -->
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>