From 97f64db984363671b0297ee3c7280f0cb19261a0 Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Mon, 15 Jul 2024 16:02:19 +0200 Subject: [PATCH] Add iOS notification action buttons --- example/ios/Podfile.lock | 4 +- example/ios/Runner.xcodeproj/project.pbxproj | 18 +++++ example/lib/screens/edit_alarm.dart | 7 ++ example/lib/screens/shortcut_button.dart | 4 +- example/pubspec.lock | 20 ++---- ios/Classes/SwiftAlarmPlugin.swift | 36 +++++++--- .../models/NotificationActionSettings.swift | 19 ++++++ .../services/NotificationManager.swift | 65 +++++++++++++++++-- lib/src/ios_alarm.dart | 2 + 9 files changed, 144 insertions(+), 31 deletions(-) create mode 100644 ios/Classes/models/NotificationActionSettings.swift diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 2ff0110d..ee190cad 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -33,8 +33,8 @@ SPEC CHECKSUMS: alarm: 6c1f6a9688f94cd6bf8f104c67cc26e78c9d8d13 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_fgbg: 31c0d1140a131daea2d342121808f6aa0dcd879d - permission_handler_apple: 036b856153a2b1f61f21030ff725f3e6fece2b78 - shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 1ebb8375..364c0688 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -139,6 +139,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 38B35C95769EE88AAB27208F /* [CP] Embed Pods Frameworks */, + C0B6B89F73C4E7451B5A6278 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -268,6 +269,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + C0B6B89F73C4E7451B5A6278 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/example/lib/screens/edit_alarm.dart b/example/lib/screens/edit_alarm.dart index 7950d802..721c3506 100644 --- a/example/lib/screens/edit_alarm.dart +++ b/example/lib/screens/edit_alarm.dart @@ -98,6 +98,13 @@ class _ExampleAlarmEditScreenState extends State { notificationTitle: 'Alarm example', notificationBody: 'Your alarm ($id) is ringing', enableNotificationOnKill: Platform.isIOS, + notificationActionSettings: const NotificationActionSettings( + hasStopButton: true, + hasSnoozeButton: false, + stopButtonText: 'STOP !', + snoozeButtonText: 'SNOOZE !', + snoozeDurationInSeconds: 5, + ), ); return alarmSettings; } diff --git a/example/lib/screens/shortcut_button.dart b/example/lib/screens/shortcut_button.dart index a8e8ca46..2941985c 100644 --- a/example/lib/screens/shortcut_button.dart +++ b/example/lib/screens/shortcut_button.dart @@ -21,7 +21,7 @@ class _ExampleAlarmHomeShortcutButtonState bool showMenu = false; Future onPressButton(int delayInHours) async { - var dateTime = DateTime.now().add(Duration(hours: delayInHours)); + var dateTime = DateTime.now().add(Duration(seconds: 3)); double? volume; if (delayInHours != 0) { @@ -42,7 +42,7 @@ class _ExampleAlarmHomeShortcutButtonState enableNotificationOnKill: Platform.isIOS, notificationActionSettings: const NotificationActionSettings( hasStopButton: true, - hasSnoozeButton: true, + hasSnoozeButton: false, stopButtonText: 'STOP !', snoozeButtonText: 'SNOOZE !', snoozeDurationInSeconds: 5, diff --git a/example/pubspec.lock b/example/pubspec.lock index a75d0d36..589e3178 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -179,10 +179,10 @@ packages: dependency: transitive description: name: path_provider_windows - sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" permission_handler: dependency: "direct main" description: @@ -195,10 +195,10 @@ packages: dependency: transitive description: name: permission_handler_android - sha256: "8bb852cd759488893805c3161d0b2b5db55db52f773dbb014420b304055ba2c5" + sha256: b29a799ca03be9f999aa6c39f7de5209482d638e6f857f6b93b0875c618b7e54 url: "https://pub.dev" source: hosted - version: "12.0.6" + version: "12.0.7" permission_handler_apple: dependency: transitive description: @@ -235,10 +235,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" plugin_platform_interface: dependency: transitive description: @@ -388,14 +388,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.1" - win32: - dependency: transitive - description: - name: win32 - sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4 - url: "https://pub.dev" - source: hosted - version: "5.5.1" xdg_directories: dependency: transitive description: diff --git a/ios/Classes/SwiftAlarmPlugin.swift b/ios/Classes/SwiftAlarmPlugin.swift index 78803b72..be1cc536 100644 --- a/ios/Classes/SwiftAlarmPlugin.swift +++ b/ios/Classes/SwiftAlarmPlugin.swift @@ -13,12 +13,12 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { #endif private var registrar: FlutterPluginRegistrar! - static let sharedInstance = SwiftAlarmPlugin() + static let shared = SwiftAlarmPlugin() static let backgroundTaskIdentifier: String = "com.gdelataillade.fetch" public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "com.gdelataillade/alarm", binaryMessenger: registrar.messenger()) - let instance = SwiftAlarmPlugin() + let instance = SwiftAlarmPlugin.shared instance.registrar = registrar registrar.addMethodCallDelegate(instance, channel: channel) @@ -62,6 +62,18 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { } } + func stopAlarmFromNotification(id: Int) { + safeModifyResources { + self.stopAlarm(id: id, cancelNotif: true, result: { _ in }) + } + } + + func snoozeAlarmFromNotification(id: Int, snoozeDurationInSeconds: Int) { + safeModifyResources { + self.stopAlarm(id: id, cancelNotif: true, result: { _ in }) + } + } + func safeModifyResources(_ modificationBlock: @escaping () -> Void) { resourceAccessQueue.async { modificationBlock() @@ -77,11 +89,19 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { let loopAudio = args["loopAudio"] as? Bool, let fadeDuration = args["fadeDuration"] as? Double, let vibrationsEnabled = args["vibrate"] as? Bool, - let assetAudio = args["assetAudio"] as? String else { - result(FlutterError(code: "NATIVE_ERR", message: "[SwiftAlarmPlugin] Arguments are not in the expected format: \(call.arguments)", details: nil)) + let assetAudio = args["assetAudio"] as? String, + let _ = args["volume"] as? Float, + let actionSettingsDict = args["notificationActionSettings"] as? [String: Any] else { + let argumentsDescription = "\(call.arguments ?? "nil")" + result(FlutterError(code: "NATIVE_ERR", message: "[SwiftAlarmPlugin] Arguments are not in the expected format: \(argumentsDescription)", details: nil)) return } + // Since fromJson does not return an optional, directly use it without guard let + let actionSettings = NotificationActionSettings.fromJson(json: actionSettingsDict) + + NSLog("SwiftAlarmPlugin: NotificationActionSettings: \(actionSettings)") + var volumeFloat: Float? = nil if let volumeValue = args["volume"] as? Double { volumeFloat = Float(volumeValue) @@ -100,7 +120,7 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { let notificationTitle = args["notificationTitle"] as? String let notificationBody = args["notificationBody"] as? String if let title = notificationTitle, let body = notificationBody, delayInSeconds >= 1.0 { - NotificationManager.shared.scheduleNotification(id: String(id), delayInSeconds: Int(floor(delayInSeconds)), title: title, body: body) { error in + NotificationManager.shared.scheduleNotification(id: id, delayInSeconds: Int(floor(delayInSeconds)), title: title, body: body, actionSettings: actionSettings) { error in if let error = error { NSLog("[SwiftAlarmPlugin] Error scheduling notification: \(error.localizedDescription)") } @@ -272,7 +292,7 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { private func stopAlarm(id: Int, cancelNotif: Bool, result: FlutterResult) { if cancelNotif { - NotificationManager.shared.cancelNotification(id: String(id)) + NotificationManager.shared.cancelNotification(id: id) } self.mixOtherAudios() @@ -457,7 +477,7 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { if #available(iOS 13.0, *) { BGTaskScheduler.shared.register(forTaskWithIdentifier: backgroundTaskIdentifier, using: nil) { task in self.scheduleAppRefresh() - sharedInstance.backgroundFetch() + shared.backgroundFetch() task.setTaskCompleted(success: true) } } else { @@ -489,4 +509,4 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { NSLog("SwiftAlarmPlugin: BGTaskScheduler not available for your version of iOS lower than 13.0") } } -} \ No newline at end of file +} diff --git a/ios/Classes/models/NotificationActionSettings.swift b/ios/Classes/models/NotificationActionSettings.swift new file mode 100644 index 00000000..ff920e1e --- /dev/null +++ b/ios/Classes/models/NotificationActionSettings.swift @@ -0,0 +1,19 @@ +import Foundation + +struct NotificationActionSettings { + var hasStopButton: Bool = false + var hasSnoozeButton: Bool = false + var stopButtonText: String = "Stop" + var snoozeButtonText: String = "Snooze" + var snoozeDurationInSeconds: Int = 9 * 60 + + static func fromJson(json: [String: Any]) -> NotificationActionSettings { + return NotificationActionSettings( + hasStopButton: json["hasStopButton"] as? Bool ?? false, + hasSnoozeButton: json["hasSnoozeButton"] as? Bool ?? false, + stopButtonText: json["stopButtonText"] as? String ?? "Stop", + snoozeButtonText: json["snoozeButtonText"] as? String ?? "Snooze", + snoozeDurationInSeconds: json["snoozeDurationInSeconds"] as? Int ?? 9 * 60 + ) + } +} \ No newline at end of file diff --git a/ios/Classes/services/NotificationManager.swift b/ios/Classes/services/NotificationManager.swift index 7ae8b61b..7161962a 100644 --- a/ios/Classes/services/NotificationManager.swift +++ b/ios/Classes/services/NotificationManager.swift @@ -1,26 +1,51 @@ import Foundation import UserNotifications -class NotificationManager { +class NotificationManager: NSObject, UNUserNotificationCenterDelegate { static let shared = NotificationManager() - private init() {} // Private initializer to ensure singleton usage + private override init() { + super.init() + } + + private func setupNotificationActions(hasStopButton: Bool, hasSnoozeButton: Bool, stopButtonText: String, snoozeButtonText: String) { + var actions: [UNNotificationAction] = [] + + if hasStopButton { + let stopAction = UNNotificationAction(identifier: "STOP_ACTION", title: stopButtonText, options: [.destructive]) + actions.append(stopAction) + } + + if hasSnoozeButton { + let snoozeAction = UNNotificationAction(identifier: "SNOOZE_ACTION", title: snoozeButtonText, options: []) + actions.append(snoozeAction) + } + + let category = UNNotificationCategory(identifier: "ALARM_CATEGORY", actions: actions, intentIdentifiers: [], options: []) + + UNUserNotificationCenter.current().setNotificationCategories([category]) + UNUserNotificationCenter.current().delegate = self + } func requestAuthorization(completion: @escaping (Bool, Error?) -> Void) { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge], completionHandler: completion) } - func scheduleNotification(id: String, delayInSeconds: Int, title: String, body: String, completion: @escaping (Error?) -> Void) { + func scheduleNotification(id: Int, delayInSeconds: Int, title: String, body: String, actionSettings: NotificationActionSettings, completion: @escaping (Error?) -> Void) { requestAuthorization { granted, error in guard granted, error == nil else { completion(error) return } + self.setupNotificationActions(hasStopButton: actionSettings.hasStopButton, hasSnoozeButton: actionSettings.hasSnoozeButton, stopButtonText: actionSettings.stopButtonText, snoozeButtonText: actionSettings.snoozeButtonText) + let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = nil + content.categoryIdentifier = "ALARM_CATEGORY" + content.userInfo = ["id": id, "snoozeDurationInSeconds": actionSettings.snoozeDurationInSeconds] // Include the id as an Integer in userInfo let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(delayInSeconds), repeats: false) let request = UNNotificationRequest(identifier: "alarm-\(id)", content: content, trigger: trigger) @@ -29,7 +54,37 @@ class NotificationManager { } } - func cancelNotification(id: String) { + func cancelNotification(id: Int) { UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ["alarm-\(id)"]) } -} + + func handleAction(withIdentifier identifier: String?, for notification: UNNotification) { + guard let identifier = identifier else { return } + guard let id = notification.request.content.userInfo["id"] as? Int else { return } + + switch identifier { + case "STOP_ACTION": + NSLog("Stop action triggered for notification: \(notification.request.identifier)") + SwiftAlarmPlugin.shared.stopAlarmFromNotification(id: id) + + case "SNOOZE_ACTION": + guard let snoozeDurationInSeconds = notification.request.content.userInfo["snoozeDurationInSeconds"] as? Int else { return } + NSLog("Snooze action triggered for notification: \(notification.request.identifier)") + SwiftAlarmPlugin.shared.snoozeAlarmFromNotification(id: id, snoozeDurationInSeconds: snoozeDurationInSeconds) + + default: + break + } + } + + // MARK: - UNUserNotificationCenterDelegate + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + handleAction(withIdentifier: response.actionIdentifier, for: response.notification) + completionHandler() + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.alert, .sound]) + } +} \ No newline at end of file diff --git a/lib/src/ios_alarm.dart b/lib/src/ios_alarm.dart index 0384504c..fe4a9f26 100644 --- a/lib/src/ios_alarm.dart +++ b/lib/src/ios_alarm.dart @@ -50,6 +50,8 @@ class IOSAlarm { AlarmStorage.getNotificationOnAppKillTitle(), 'notifDescriptionOnAppKill': AlarmStorage.getNotificationOnAppKillBody(), + 'notificationSettings': + settings.notificationActionSettings.toJson(), }, ) ?? false;