From cbcb403bb6a07f4d2cd2b3ff3dd52bc8bc0c4c6e Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Tue, 29 Oct 2024 23:38:58 +0100 Subject: [PATCH 1/2] Add on tap notification callback for iOS --- example/lib/screens/home.dart | 7 ++++ example/lib/screens/ring.dart | 36 ++---------------- ios/Classes/SwiftAlarmPlugin.swift | 14 ++++++- .../services/NotificationManager.swift | 5 +++ lib/alarm.dart | 38 ++++++++++++++++++- lib/src/ios_alarm.dart | 5 +++ 6 files changed, 71 insertions(+), 34 deletions(-) diff --git a/example/lib/screens/home.dart b/example/lib/screens/home.dart index 077d93f1..a9f98880 100644 --- a/example/lib/screens/home.dart +++ b/example/lib/screens/home.dart @@ -23,6 +23,7 @@ class _ExampleAlarmHomeScreenState extends State { static StreamSubscription? ringSubscription; static StreamSubscription? updateSubscription; + static StreamSubscription? notificationTapSubscription; @override void initState() { @@ -36,6 +37,11 @@ class _ExampleAlarmHomeScreenState extends State { updateSubscription ??= Alarm.updateStream.stream.listen((_) { loadAlarms(); }); + notificationTapSubscription ??= Alarm.notificationTapStream.stream.listen( + (id) { + alarmPrint('App opened through notification of alarm: $id'); + }, + ); } void loadAlarms() { @@ -83,6 +89,7 @@ class _ExampleAlarmHomeScreenState extends State { void dispose() { ringSubscription?.cancel(); updateSubscription?.cancel(); + notificationTapSubscription?.cancel(); super.dispose(); } diff --git a/example/lib/screens/ring.dart b/example/lib/screens/ring.dart index ee1c97ec..cde6849e 100644 --- a/example/lib/screens/ring.dart +++ b/example/lib/screens/ring.dart @@ -1,39 +1,11 @@ -import 'dart:async'; - import 'package:alarm/alarm.dart'; import 'package:flutter/material.dart'; -class ExampleAlarmRingScreen extends StatefulWidget { +class ExampleAlarmRingScreen extends StatelessWidget { const ExampleAlarmRingScreen({required this.alarmSettings, super.key}); final AlarmSettings alarmSettings; - @override - State createState() => _ExampleAlarmRingScreenState(); -} - -class _ExampleAlarmRingScreenState extends State { - @override - void initState() { - super.initState(); - Timer.periodic(const Duration(seconds: 1), (timer) async { - if (!mounted) { - timer.cancel(); - return; - } - - final isRinging = await Alarm.isRinging(widget.alarmSettings.id); - if (isRinging) { - alarmPrint('Alarm ${widget.alarmSettings.id} is still ringing...'); - return; - } - - alarmPrint('Alarm ${widget.alarmSettings.id} stopped ringing.'); - timer.cancel(); - if (mounted) Navigator.pop(context); - }); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -42,7 +14,7 @@ class _ExampleAlarmRingScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Text( - 'You alarm (${widget.alarmSettings.id}) is ringing...', + 'You alarm (${alarmSettings.id}) is ringing...', style: Theme.of(context).textTheme.titleLarge, ), const Text('🔔', style: TextStyle(fontSize: 50)), @@ -53,7 +25,7 @@ class _ExampleAlarmRingScreenState extends State { onPressed: () { final now = DateTime.now(); Alarm.set( - alarmSettings: widget.alarmSettings.copyWith( + alarmSettings: alarmSettings.copyWith( dateTime: DateTime( now.year, now.month, @@ -73,7 +45,7 @@ class _ExampleAlarmRingScreenState extends State { ), RawMaterialButton( onPressed: () { - Alarm.stop(widget.alarmSettings.id).then((_) { + Alarm.stop(alarmSettings.id).then((_) { if (context.mounted) Navigator.pop(context); }); }, diff --git a/ios/Classes/SwiftAlarmPlugin.swift b/ios/Classes/SwiftAlarmPlugin.swift index 54358e95..b445a144 100644 --- a/ios/Classes/SwiftAlarmPlugin.swift +++ b/ios/Classes/SwiftAlarmPlugin.swift @@ -5,7 +5,7 @@ import AudioToolbox import MediaPlayer import BackgroundTasks -public class SwiftAlarmPlugin: NSObject, FlutterPlugin { +public class SwiftAlarmPlugin: NSObject, FlutterPlugin, FlutterApplicationLifeCycleDelegate { #if targetEnvironment(simulator) private let isDevice = false #else @@ -24,6 +24,7 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { instance.channel = channel instance.registrar = registrar registrar.addMethodCallDelegate(instance, channel: channel) + registrar.addApplicationDelegate(instance) } private var alarms: [Int: AlarmConfiguration] = [:] @@ -40,6 +41,14 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { private var vibratingAlarms: Set = [] + var launchNotification: Int? = nil + + func setLaunchNotification(userInfo: [AnyHashable: Any]) { + if let id = userInfo["id"] as? Int { + self.launchNotification = id + } + } + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "setAlarm": @@ -57,6 +66,9 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { } else { result(self.alarms[id!]?.audioPlayer?.isPlaying ?? false) } + case "getLaunchNotification": + result(launchNotification) + launchNotification = nil case "setWarningNotificationOnKill": guard let args = call.arguments as? [String: Any] else { result(FlutterError(code: "NATIVE_ERR", message: "[SwiftAlarmPlugin] Error: Arguments are not in the expected format for setWarningNotificationOnKill", details: nil)) diff --git a/ios/Classes/services/NotificationManager.swift b/ios/Classes/services/NotificationManager.swift index edd9a2c3..41d7e4bf 100644 --- a/ios/Classes/services/NotificationManager.swift +++ b/ios/Classes/services/NotificationManager.swift @@ -90,6 +90,11 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { handleAction(withIdentifier: response.actionIdentifier, for: response.notification) + + if let id = response.notification.request.content.userInfo["id"] { + SwiftAlarmPlugin.shared.setLaunchNotification(userInfo: ["id": id]) + } + completionHandler() } diff --git a/lib/alarm.dart b/lib/alarm.dart index cb63f2dc..527992b1 100644 --- a/lib/alarm.dart +++ b/lib/alarm.dart @@ -9,6 +9,7 @@ import 'package:alarm/src/ios_alarm.dart'; import 'package:alarm/utils/alarm_exception.dart'; import 'package:alarm/utils/extensions.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_fgbg/flutter_fgbg.dart'; export 'package:alarm/model/alarm_settings.dart'; export 'package:alarm/model/notification_settings.dart'; @@ -24,16 +25,23 @@ class Alarm { /// Whether it's Android device. static bool get android => defaultTargetPlatform == TargetPlatform.android; + /// Stream subscription to listen to foreground/background events. + static late StreamSubscription fgbgSubscription; + /// Stream of the alarm updates. static final updateStream = StreamController(); /// Stream of the ringing status. static final ringStream = StreamController(); + /// Stream of the notification tap. + static final notificationTapStream = StreamController(); + /// Initializes Alarm services. /// /// Also calls [checkAlarm] that will reschedule alarms that were set before - /// app termination. + /// app termination. Also calls [checkOpenOnNotificationTap] to check if the + /// app was brought to foreground by tapping on alarm notification. /// /// Set [showDebugLogs] to `false` to hide all the logs from the plugin. static Future init({bool showDebugLogs = true}) async { @@ -46,6 +54,19 @@ class Alarm { await AlarmStorage.init(); await checkAlarm(); + + fgbgSubscription = FGBGEvents.instance.stream.listen((event) { + if (event == FGBGType.foreground) checkOpenOnNotificationTap(); + }); + } + + /// Checks if the app was opened by tapping on a notification. + static Future checkOpenOnNotificationTap() async { + final id = await Alarm.getLaunchNotification(); + + if (id == null) return; + + notificationTapStream.add(id); } /// Checks if some alarms were set on previous session. @@ -151,6 +172,12 @@ class Alarm { } } + /// Gets the notification that was used to open the app. + static Future getLaunchNotification() async { + if (iOS) return IOSAlarm.getLaunchNotification(); + return null; + } + /// Whether the alarm is ringing. /// /// If no `id` is provided, it checks if any alarm is ringing. @@ -183,4 +210,13 @@ class Alarm { await AlarmStorage.prefs.reload(); updateStream.add(id); } + + /// Disposes the alarm resources if they are no longer needed. + static void dispose() { + AlarmStorage.dispose(); + fgbgSubscription.cancel(); + updateStream.close(); + notificationTapStream.close(); + ringStream.close(); + } } diff --git a/lib/src/ios_alarm.dart b/lib/src/ios_alarm.dart index 5b1b4093..c549a943 100644 --- a/lib/src/ios_alarm.dart +++ b/lib/src/ios_alarm.dart @@ -27,6 +27,11 @@ class IOSAlarm { if (id != null) await Alarm.reload(id); } + /// Calls the native function `getLaunchNotification`. + static Future getLaunchNotification() async { + return methodChannel.invokeMethod('getLaunchNotification'); + } + /// Calls the native function `setAlarm` and listens to alarm ring state. /// /// Also set periodic timer and listens for app state changes to trigger From fe8928c7088bdee78300fc896fc60f0483c7598c Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Wed, 30 Oct 2024 15:15:23 +0100 Subject: [PATCH 2/2] WIP on tap notification callback for Android --- .../gdelataillade/alarm/alarm/AlarmPlugin.kt | 6 ++++++ .../alarm/services/AlarmStorage.kt | 21 ++++++++++++++++++- lib/alarm.dart | 13 ++++++------ lib/src/android_alarm.dart | 5 +++++ 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmPlugin.kt b/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmPlugin.kt index 6c0c2175..0ed1cf4f 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmPlugin.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmPlugin.kt @@ -77,6 +77,12 @@ class AlarmPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { } result.success(isRinging) } + "getLaunchNotification" -> { + val alarmStorage = AlarmStorage(context) + val alarmId = alarmStorage.getAndClearAlarmLaunchId() + + result.success(alarmId ?: null) + } "setWarningNotificationOnKill" -> { val title = call.argument("title") val body = call.argument("body") diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/services/AlarmStorage.kt b/android/src/main/kotlin/com/gdelataillade/alarm/services/AlarmStorage.kt index 7feae5b9..0bb98e01 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/services/AlarmStorage.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/services/AlarmStorage.kt @@ -12,7 +12,7 @@ import io.flutter.Log class AlarmStorage(context: Context) { companion object { - private const val PREFS_NAME = "alarm_prefs" + // Prefix shared with the Flutter side to identify alarm settings in shared preferences. private const val PREFIX = "flutter.__alarm_id__" } @@ -57,4 +57,23 @@ class AlarmStorage(context: Context) { } return alarms } + + fun saveAlarmLaunchId(alarmId: Int) { + val editor = prefs.edit() + editor.putInt("launch_alarm_id", alarmId) + editor.apply() + } + + fun getAndClearAlarmLaunchId(): Int? { + val key = "launch_alarm_id" + val value = prefs.all[key] + return if (value is Int) { + prefs.edit().remove(key).apply() + value + } else { + // TODO: To remove + Log.e("AlarmStorage", "Expected an Int for key $key but found ${value?.javaClass?.simpleName}") + null + } + } } \ No newline at end of file diff --git a/lib/alarm.dart b/lib/alarm.dart index 527992b1..e16b1112 100644 --- a/lib/alarm.dart +++ b/lib/alarm.dart @@ -172,19 +172,18 @@ class Alarm { } } - /// Gets the notification that was used to open the app. - static Future getLaunchNotification() async { - if (iOS) return IOSAlarm.getLaunchNotification(); - return null; - } + /// Gets the notification id that was used to open the app. + static Future getLaunchNotification() => iOS + ? IOSAlarm.getLaunchNotification() + : AndroidAlarm.getLaunchNotification(); /// Whether the alarm is ringing. /// /// If no `id` is provided, it checks if any alarm is ringing. /// If an `id` is provided, it checks if the specific alarm with that `id` /// is ringing. - static Future isRinging([int? id]) async => - iOS ? await IOSAlarm.isRinging(id) : await AndroidAlarm.isRinging(id); + static Future isRinging([int? id]) => + iOS ? IOSAlarm.isRinging(id) : AndroidAlarm.isRinging(id); /// Whether an alarm is set. static bool hasAlarm() => AlarmStorage.hasAlarm(); diff --git a/lib/src/android_alarm.dart b/lib/src/android_alarm.dart index e9c42852..5ebce3c4 100644 --- a/lib/src/android_alarm.dart +++ b/lib/src/android_alarm.dart @@ -44,6 +44,11 @@ class AndroidAlarm { ); } + /// Calls the native function `getLaunchNotification`. + static Future getLaunchNotification() async { + return methodChannel.invokeMethod('getLaunchNotification'); + } + /// Schedules a native alarm with given [settings] with its notification. static Future set(AlarmSettings settings) async { try {