diff --git a/README.md b/README.md index be54d8fb..4c37f3c9 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ ![Pub Popularity](https://img.shields.io/pub/popularity/alarm) [![alarm](https://github.com/gdelataillade/alarm/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/gdelataillade/alarm/actions/workflows/main.yml) +[![style: very good analysis](https://img.shields.io/badge/style-very_good_analysis-B22C89.svg)](https://pub.dev/packages/very_good_analysis) [![GitHub Sponsor](https://img.shields.io/github/sponsors/gdelataillade?label=Sponsor&logo=GitHub)](https://github.com/sponsors/gdelataillade) 🏆 Winner of the [2023 OnePub Community Choice Awards](https://onepub.dev/Competition). diff --git a/analysis_options.yaml b/analysis_options.yaml index a5744c1c..670d9396 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options +include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file diff --git a/example/lib/screens/edit_alarm.dart b/example/lib/screens/edit_alarm.dart index 47daf8e0..3dec94fd 100644 --- a/example/lib/screens/edit_alarm.dart +++ b/example/lib/screens/edit_alarm.dart @@ -1,4 +1,5 @@ import 'package:alarm/alarm.dart'; +import 'package:alarm/model/alarm_settings.dart'; import 'package:flutter/material.dart'; class ExampleAlarmEditScreen extends StatefulWidget { diff --git a/example/lib/screens/home.dart b/example/lib/screens/home.dart index e0e320d9..98b12169 100644 --- a/example/lib/screens/home.dart +++ b/example/lib/screens/home.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:alarm/alarm.dart'; +import 'package:alarm/model/alarm_settings.dart'; import 'package:alarm_example/screens/edit_alarm.dart'; import 'package:alarm_example/screens/ring.dart'; import 'package:alarm_example/screens/shortcut_button.dart'; diff --git a/example/lib/screens/ring.dart b/example/lib/screens/ring.dart index b6e05f37..bd175c62 100644 --- a/example/lib/screens/ring.dart +++ b/example/lib/screens/ring.dart @@ -1,4 +1,5 @@ import 'package:alarm/alarm.dart'; +import 'package:alarm/model/alarm_settings.dart'; import 'package:flutter/material.dart'; class ExampleAlarmRingScreen extends StatelessWidget { diff --git a/example/lib/screens/shortcut_button.dart b/example/lib/screens/shortcut_button.dart index fc83c99d..60b6e202 100644 --- a/example/lib/screens/shortcut_button.dart +++ b/example/lib/screens/shortcut_button.dart @@ -1,4 +1,5 @@ import 'package:alarm/alarm.dart'; +import 'package:alarm/model/alarm_settings.dart'; import 'package:flutter/material.dart'; class ExampleAlarmHomeShortcutButton extends StatefulWidget { diff --git a/ios/Classes/AlarmConfiguration.swift b/ios/Classes/AlarmConfiguration.swift new file mode 100644 index 00000000..b40d29ee --- /dev/null +++ b/ios/Classes/AlarmConfiguration.swift @@ -0,0 +1,23 @@ +import AVFoundation + +class AlarmConfiguration { + let id: Int + let assetAudio: String + let vibrationsEnabled: Bool + let loopAudio: Bool + let fadeDuration: Double + let volume: Float? + var triggerTime: Date? + var audioPlayer: AVAudioPlayer? + var timer: Timer? + var task: DispatchWorkItem? + + init(id: Int, assetAudio: String, vibrationsEnabled: Bool, loopAudio: Bool, fadeDuration: Double, volume: Float?) { + self.id = id + self.assetAudio = assetAudio + self.vibrationsEnabled = vibrationsEnabled + self.loopAudio = loopAudio + self.fadeDuration = fadeDuration + self.volume = volume + } +} \ No newline at end of file diff --git a/ios/Classes/SwiftAlarmPlugin.swift b/ios/Classes/SwiftAlarmPlugin.swift index f2b46c3a..136abc68 100644 --- a/ios/Classes/SwiftAlarmPlugin.swift +++ b/ios/Classes/SwiftAlarmPlugin.swift @@ -24,12 +24,10 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { registrar.addMethodCallDelegate(instance, channel: channel) } - private var audioPlayers: [Int: AVAudioPlayer] = [:] + private var alarms: [Int: AlarmConfiguration] = [:] + private var silentAudioPlayer: AVAudioPlayer? - private var tasksQueue: [Int: DispatchWorkItem] = [:] private let resourceAccessQueue = DispatchQueue(label: "com.gdelataillade.alarm.resourceAccessQueue") - private var timers: [Int: Timer] = [:] - private var triggerTimes: [Int: Date] = [:] private var notifOnKillEnabled: Bool! private var notificationTitleOnKill: String! @@ -42,22 +40,24 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { DispatchQueue.global(qos: .default).async { - if call.method == "setAlarm" { + switch call.method { + case "setAlarm": self.setAlarm(call: call, result: result) - } else if call.method == "stopAlarm" { - if let args = call.arguments as? [String: Any], let id = args["id"] as? Int { - self.stopAlarm(id: id, cancelNotif: true, result: result) - } else { - result(FlutterError.init(code: "NATIVE_ERR", message: "[SwiftAlarmPlugin] Error: id parameter is missing or invalid", details: nil)) + case "stopAlarm": + guard let args = call.arguments as? [String: Any], let id = args["id"] as? Int else { + result(FlutterError(code: "NATIVE_ERR", message: "[SwiftAlarmPlugin] Error: id parameter is missing or invalid", details: nil)) + return } - } else if call.method == "audioCurrentTime" { - let args = call.arguments as! Dictionary - let id = args["id"] as! Int - self.audioCurrentTime(id: id, result: result) - } else { - DispatchQueue.main.sync { - result(FlutterMethodNotImplemented) + self.stopAlarm(id: id, cancelNotif: true, result: result) + case "audioCurrentTime": + guard let args = call.arguments as? [String: Any], let id = args["id"] as? Int else { + result(FlutterError(code: "NATIVE_ERR", message: "[SwiftAlarmPlugin] Error: id parameter is missing or invalid for audioCurrentTime", details: nil)) + return } + self.audioCurrentTime(id: id, result: result) + default: + // Removed unnecessary DispatchQueue.main.sync + result(FlutterMethodNotImplemented) } } } @@ -71,16 +71,34 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { private func setAlarm(call: FlutterMethodCall, result: FlutterResult) { self.mixOtherAudios() - guard let args = call.arguments as? [String: Any] else { - result(FlutterError(code: "NATIVE_ERR", message: "[SwiftAlarmPlugin] Arguments are not in the expected format", details: nil)) + guard let args = call.arguments as? [String: Any], + let id = args["id"] as? Int, + let delayInSeconds = args["delayInSeconds"] as? Double, + 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)) return } - let id = args["id"] as! Int - let delayInSeconds = args["delayInSeconds"] as! Double + var volumeFloat: Float? = nil + if let volumeValue = args["volume"] as? Double { + volumeFloat = Float(volumeValue) + } + + let alarmConfig = AlarmConfiguration( + id: id, + assetAudio: assetAudio, + vibrationsEnabled: vibrationsEnabled, + loopAudio: loopAudio, + fadeDuration: fadeDuration, + volume: volumeFloat + ) + self.alarms[id] = alarmConfig + 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 if let error = error { @@ -92,28 +110,14 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { notifOnKillEnabled = (args["notifOnKillEnabled"] as! Bool) notificationTitleOnKill = (args["notifTitleOnAppKill"] as! String) notificationBodyOnKill = (args["notifDescriptionOnAppKill"] as! String) - if notifOnKillEnabled && !observerAdded { observerAdded = true NotificationCenter.default.addObserver(self, selector: #selector(applicationWillTerminate(_:)), name: UIApplication.willTerminateNotification, object: nil) } - let loopAudio = args["loopAudio"] as! Bool - let fadeDuration = args["fadeDuration"] as! Double - let vibrationsEnabled = args["vibrate"] as! Bool - let volume = args["volume"] as? Double - let assetAudio = args["assetAudio"] as! String - - var volumeFloat: Float? = nil - if let volumeValue = volume { - volumeFloat = Float(volumeValue) - } - - // Attempt to load the audio player for the given asset + // Load audio player with given asset if let audioPlayer = self.loadAudioPlayer(withAsset: assetAudio, forId: id) { safeModifyResources { - self.audioPlayers[id] = audioPlayer - let currentTime = audioPlayer.deviceCurrentTime let time = currentTime + delayInSeconds let dateTime = Date().addingTimeInterval(delayInSeconds) @@ -134,20 +138,14 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { audioPlayer.play(atTime: time + 0.5) - self.triggerTimes[id] = dateTime - self.tasksQueue[id] = DispatchWorkItem(block: { - self.handleAlarmAfterDelay( - id: id, - triggerTime: dateTime, - fadeDuration: fadeDuration, - vibrationsEnabled: vibrationsEnabled, - audioLoop: loopAudio, - volume: volumeFloat - ) + self.alarms[id]?.audioPlayer = audioPlayer + self.alarms[id]?.triggerTime = dateTime + self.alarms[id]?.task = DispatchWorkItem(block: { + self.handleAlarmAfterDelay(id: id) }) DispatchQueue.main.async { - self.timers[id] = Timer.scheduledTimer(timeInterval: delayInSeconds, target: self, selector: #selector(self.executeTask(_:)), userInfo: id, repeats: false) + self.alarms[id]?.timer = Timer.scheduledTimer(timeInterval: delayInSeconds, target: self, selector: #selector(self.executeTask(_:)), userInfo: id, repeats: false) } SwiftAlarmPlugin.scheduleAppRefresh() } @@ -159,26 +157,23 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { } private func loadAudioPlayer(withAsset assetAudio: String, forId id: Int) -> AVAudioPlayer? { - do { - var audioURL: URL - - if assetAudio.hasPrefix("assets/") { - // Load audio from Flutter assets - let filename = registrar.lookupKey(forAsset: assetAudio) - guard let audioPath = Bundle.main.path(forResource: filename, ofType: nil) else { - NSLog("[SwiftAlarmPlugin] Audio file not found: \(assetAudio)") - return nil - } - audioURL = URL(fileURLWithPath: audioPath) - } else { - // Adjusted to support subfolder paths - let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - audioURL = documentsDirectory.appendingPathComponent(assetAudio) + let audioURL: URL + if assetAudio.hasPrefix("assets/") { + // Load audio from assets + let filename = registrar.lookupKey(forAsset: assetAudio) + guard let audioPath = Bundle.main.path(forResource: filename, ofType: nil) else { + NSLog("[SwiftAlarmPlugin] Audio file not found: \(assetAudio)") + return nil } + audioURL = URL(fileURLWithPath: audioPath) + } else { + // Load audio from documents directory + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + audioURL = documentsDirectory.appendingPathComponent(assetAudio) + } - // Create and return the audio player - let audioPlayer = try AVAudioPlayer(contentsOf: audioURL) - return audioPlayer + do { + return try AVAudioPlayer(contentsOf: audioURL) } catch { NSLog("[SwiftAlarmPlugin] Error loading audio player: \(error.localizedDescription)") return nil @@ -186,7 +181,7 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { } @objc func executeTask(_ timer: Timer) { - if let taskId = timer.userInfo as? Int, let task = tasksQueue[taskId] { + if let id = timer.userInfo as? Int, let task = alarms[id]?.task { task.perform() } } @@ -241,9 +236,9 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { } } - private func handleAlarmAfterDelay(id: Int, triggerTime: Date, fadeDuration: Double, vibrationsEnabled: Bool, audioLoop: Bool, volume: Float?) { + private func handleAlarmAfterDelay(id: Int) { safeModifyResources { - guard let audioPlayer = self.audioPlayers[id], let storedTriggerTime = self.triggerTimes[id], triggerTime == storedTriggerTime else { + guard let alarm = self.alarms[id], let audioPlayer = alarm.audioPlayer else { return } @@ -254,22 +249,22 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { audioPlayer.play() } - self.vibrate = vibrationsEnabled + self.vibrate = alarm.vibrationsEnabled self.triggerVibrations() - if !audioLoop { + if !alarm.loopAudio { let audioDuration = audioPlayer.duration DispatchQueue.main.asyncAfter(deadline: .now() + audioDuration) { self.stopAlarm(id: id, cancelNotif: false, result: { _ in }) } } - if let volumeValue = volume { + if let volumeValue = alarm.volume { self.setVolume(volume: volumeValue, enable: true) } - if fadeDuration > 0.0 { - audioPlayer.setVolume(1.0, fadeDuration: fadeDuration) + if alarm.fadeDuration > 0.0 { + audioPlayer.setVolume(1.0, fadeDuration: alarm.fadeDuration) } } } @@ -290,19 +285,12 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { self.setVolume(volume: previousVolume, enable: false) } - // Invalidate and remove the timer if it exists - if let timer = self.timers[id] { - timer.invalidate() - self.timers.removeValue(forKey: id) - } - - // Stop the audio player if it exists, and clean up all related resources - if let audioPlayer = self.audioPlayers[id] { - audioPlayer.stop() - self.audioPlayers.removeValue(forKey: id) - self.triggerTimes.removeValue(forKey: id) - self.tasksQueue[id]?.cancel() - self.tasksQueue.removeValue(forKey: id) + // Clean up all alarm related resources + if let alarm = self.alarms[id] { + alarm.timer?.invalidate() + alarm.task?.cancel() + alarm.audioPlayer?.stop() + self.alarms.removeValue(forKey: id) } } @@ -316,7 +304,7 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { self.mixOtherAudios() safeModifyResources { - if self.audioPlayers.isEmpty { + if self.alarms.isEmpty { self.playSilent = false DispatchQueue.main.async { self.silentAudioPlayer?.stop() @@ -352,7 +340,7 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { } private func audioCurrentTime(id: Int, result: FlutterResult) { - if let audioPlayer = self.audioPlayers[id] { + if let audioPlayer = self.alarms[id]?.audioPlayer { let time = Double(audioPlayer.currentTime) result(time) } else { @@ -367,20 +355,20 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { self.silentAudioPlayer?.play() safeModifyResources { - let ids = Array(self.audioPlayers.keys) + let ids = Array(self.alarms.keys) for id in ids { NSLog("SwiftAlarmPlugin: Background check alarm with id \(id)") - if let audioPlayer = self.audioPlayers[id], let dateTime = self.triggerTimes[id] { + if let audioPlayer = self.alarms[id]?.audioPlayer, let dateTime = self.alarms[id]?.triggerTime { let currentTime = audioPlayer.deviceCurrentTime let time = currentTime + dateTime.timeIntervalSinceNow audioPlayer.play(atTime: time) } - if let delayInSeconds = self.triggerTimes[id]?.timeIntervalSinceNow { + if let alarm = self.alarms[id], let delayInSeconds = alarm.triggerTime?.timeIntervalSinceNow { DispatchQueue.main.async { self.safeModifyResources { - self.timers[id] = Timer.scheduledTimer(timeInterval: delayInSeconds, target: self, selector: #selector(self.executeTask(_:)), userInfo: id, repeats: false) + alarm.timer = Timer.scheduledTimer(timeInterval: delayInSeconds, target: self, selector: #selector(self.executeTask(_:)), userInfo: id, repeats: false) } } } @@ -390,7 +378,7 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { private func stopNotificationOnKillService() { safeModifyResources { - if self.audioPlayers.isEmpty && self.observerAdded { + if self.alarms.isEmpty && self.observerAdded { NotificationCenter.default.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) self.observerAdded = false } @@ -399,17 +387,45 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { // Show notification on app kill @objc func applicationWillTerminate(_ notification: Notification) { + scheduleImmediateNotification() + // scheduleDelayedNotification() + } + + func scheduleImmediateNotification() { let content = UNMutableNotificationContent() content.title = notificationTitleOnKill content.body = notificationBodyOnKill + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 2, repeats: false) - let request = UNNotificationRequest(identifier: "notification on app kill", content: content, trigger: trigger) + let request = UNNotificationRequest(identifier: "notification on app kill immediate", content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) { (error) in if let error = error { - NSLog("SwiftAlarmPlugin: Failed to show notification on kill service => error: \(error.localizedDescription)") + NSLog("SwiftAlarmPlugin: Failed to show immediate notification on app kill => error: \(error.localizedDescription)") } else { - NSLog("SwiftAlarmPlugin: Trigger notification on app kill") + NSLog("SwiftAlarmPlugin: Triggered immediate notification on app kill") + } + } + } + + func scheduleDelayedNotification() { + let baseDelay = 10 // Base delay in seconds + for i in 1...10 { + let delay = TimeInterval(baseDelay * i) + let content = UNMutableNotificationContent() + content.title = "\(notificationTitleOnKill) \(i)" + content.body = "\(notificationBodyOnKill) \(i)" + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay, repeats: false) + let identifier = "notification on app kill delayed \(i)" + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) { (error) in + if let error = error { + NSLog("SwiftAlarmPlugin: Failed to show delayed notification \(i) on app kill => error: \(error.localizedDescription)") + } else { + NSLog("SwiftAlarmPlugin: Triggered delayed notification \(i) on app kill") + } } } } diff --git a/lib/alarm.dart b/lib/alarm.dart index 4e56318f..80ea4ae6 100644 --- a/lib/alarm.dart +++ b/lib/alarm.dart @@ -1,17 +1,19 @@ // ignore_for_file: avoid_print -export 'package:alarm/model/alarm_settings.dart'; import 'dart:async'; import 'package:alarm/model/alarm_settings.dart'; -import 'package:alarm/src/ios_alarm.dart'; +import 'package:alarm/service/alarm_storage.dart'; import 'package:alarm/src/android_alarm.dart'; -import 'package:alarm/service/storage.dart'; +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'; /// Custom print function designed for Alarm plugin. DebugPrintCallback alarmPrint = debugPrintThrottled; +/// Class that handles the alarm. class Alarm { /// Whether it's iOS device. static bool get iOS => defaultTargetPlatform == TargetPlatform.iOS; @@ -31,7 +33,7 @@ class Alarm { static Future init({bool showDebugLogs = true}) async { alarmPrint = (String? message, {int? wrapWidth}) { if (kDebugMode && showDebugLogs) { - print("[Alarm] $message"); + print('[Alarm] $message'); } }; @@ -62,7 +64,7 @@ class Alarm { /// Schedules an alarm with given [alarmSettings] with its notification. /// - /// If you set an alarm for the same [dateTime] as an existing one, + /// If you set an alarm for the same dateTime as an existing one, /// the new alarm will replace the existing one. static Future set({required AlarmSettings alarmSettings}) async { alarmSettingsValidation(alarmSettings); @@ -82,7 +84,7 @@ class Alarm { () => ringStream.add(alarmSettings), ); } else if (android) { - return await AndroidAlarm.set( + return AndroidAlarm.set( alarmSettings, () => ringStream.add(alarmSettings), ); @@ -91,6 +93,7 @@ class Alarm { return false; } + /// Validates [alarmSettings] fields. static void alarmSettingsValidation(AlarmSettings alarmSettings) { if (alarmSettings.id == 0 || alarmSettings.id == -1) { throw AlarmException( @@ -99,12 +102,12 @@ class Alarm { } if (alarmSettings.id > 2147483647) { throw AlarmException( - 'Alarm id cannot be set larger than Int max value (2147483647). Provided: ${alarmSettings.id}', + '''Alarm id cannot be set larger than Int max value (2147483647). Provided: ${alarmSettings.id}''', ); } if (alarmSettings.id < -2147483648) { throw AlarmException( - 'Alarm id cannot be set smaller than Int min value (-2147483648). Provided: ${alarmSettings.id}', + '''Alarm id cannot be set smaller than Int min value (-2147483648). Provided: ${alarmSettings.id}''', ); } if (alarmSettings.volume != null && @@ -115,7 +118,7 @@ class Alarm { } if (alarmSettings.fadeDuration < 0) { throw AlarmException( - 'Fade duration must be positive. Provided: ${alarmSettings.fadeDuration}', + '''Fade duration must be positive. Provided: ${alarmSettings.fadeDuration}''', ); } } @@ -127,7 +130,8 @@ class Alarm { /// /// [title] default value is `Your alarm may not ring` /// - /// [body] default value is `You killed the app. Please reopen so your alarm can ring.` + /// [body] default value is `You killed the app. + /// Please reopen so your alarm can ring.` static Future setNotificationOnAppKillContent( String title, String body, @@ -160,7 +164,7 @@ class Alarm { /// Returns alarm by given id. Returns null if not found. static AlarmSettings? getAlarm(int id) { - List alarms = AlarmStorage.getSavedAlarms(); + final alarms = AlarmStorage.getSavedAlarms(); for (final alarm in alarms) { if (alarm.id == id) return alarm; @@ -173,22 +177,3 @@ class Alarm { /// Returns all the alarms. static List getAlarms() => AlarmStorage.getSavedAlarms(); } - -class AlarmException implements Exception { - final String message; - - const AlarmException(this.message); - - @override - String toString() => message; -} - -extension DateTimeExtension on DateTime { - bool isSameSecond(DateTime other) => - year == other.year && - month == other.month && - day == other.day && - hour == other.hour && - minute == other.minute && - second == other.second; -} diff --git a/lib/model/alarm_settings.dart b/lib/model/alarm_settings.dart index 13af5608..957edd67 100644 --- a/lib/model/alarm_settings.dart +++ b/lib/model/alarm_settings.dart @@ -1,4 +1,45 @@ +import 'package:flutter/widgets.dart'; + +/// [AlarmSettings] is a model that contains all the settings to customize +/// and set an alarm. +@immutable class AlarmSettings { + /// Model that contains all the settings to customize and set an alarm. + /// + /// + /// Note that if you want to show a notification when alarm is triggered, both + /// [notificationTitle] and [notificationBody] must not be null nor empty. + const AlarmSettings({ + required this.id, + required this.dateTime, + required this.assetAudioPath, + required this.notificationTitle, + required this.notificationBody, + this.loopAudio = true, + this.vibrate = true, + this.volume, + this.fadeDuration = 0.0, + this.enableNotificationOnKill = true, + this.androidFullScreenIntent = true, + }); + + /// Constructs an `AlarmSettings` instance from the given JSON data. + factory AlarmSettings.fromJson(Map json) => AlarmSettings( + id: json['id'] as int, + dateTime: DateTime.fromMicrosecondsSinceEpoch(json['dateTime'] as int), + assetAudioPath: json['assetAudioPath'] as String, + loopAudio: json['loopAudio'] as bool, + vibrate: json['vibrate'] as bool? ?? true, + volume: json['volume'] as double?, + fadeDuration: json['fadeDuration'] as double, + notificationTitle: json['notificationTitle'] as String? ?? '', + notificationBody: json['notificationBody'] as String? ?? '', + enableNotificationOnKill: + json['enableNotificationOnKill'] as bool? ?? true, + androidFullScreenIntent: + json['androidFullScreenIntent'] as bool? ?? true, + ); + /// Unique identifier assiocated with the alarm. Cannot be 0 or -1; final int id; @@ -7,15 +48,24 @@ class AlarmSettings { /// Path to audio asset to be used as the alarm ringtone. Accepted formats: /// - /// * **Project asset**: Specifies an asset bundled with your Flutter project. Use this format for assets that are included in your project's `pubspec.yaml` file. + /// * **Project asset**: Specifies an asset bundled with your Flutter project. + /// Use this format for assets that are included in your project's + /// `pubspec.yaml` file. /// Example: `assets/audio.mp3`. - /// * **Absolute file path**: Specifies a direct file system path to the audio file. This format is used for audio files stored outside the Flutter project, such as files saved in the device's internal or external storage. + /// * **Absolute file path**: Specifies a direct file system path to the + /// audio file. This format is used for audio files stored outside the + /// Flutter project, such as files saved in the device's internal + /// or external storage. /// Example: `/path/to/your/audio.mp3`. - /// * **Relative file path**: Specifies a file path relative to a predefined base directory in the app's internal storage. This format is convenient for referring to files that are stored within a specific directory of your app's internal storage without needing to specify the full path. + /// * **Relative file path**: Specifies a file path relative to a predefined + /// base directory in the app's internal storage. This format is convenient + /// for referring to files that are stored within a specific directory of + /// your app's internal storage without needing to specify the full path. /// Example: `Audios/audio.mp3`. /// - /// If you want to use aboslute or relative file path, you must request android storage - /// permission and add the following permission to your `AndroidManifest.xml`: + /// If you want to use aboslute or relative file path, you must request + /// android storage permission and add the following permission to your + /// `AndroidManifest.xml`: /// `android.permission.READ_EXTERNAL_STORAGE` final String assetAudioPath; @@ -30,11 +80,13 @@ class AlarmSettings { /// Specifies the system volume level to be set at the designated [dateTime]. /// - /// Accepts a value between 0 (mute) and 1 (maximum volume). When the alarm is triggered at [dateTime], - /// the system volume adjusts to this specified level. Upon stopping the alarm, the system volume reverts + /// Accepts a value between 0 (mute) and 1 (maximum volume). + /// When the alarm is triggered at [dateTime], the system volume adjusts to + /// this specified level. Upon stopping the alarm, the system volume reverts /// to its prior setting. /// - /// If left unspecified or set to `null`, the current system volume at the time of the alarm will be used. + /// If left unspecified or set to `null`, the current system volume + /// at the time of the alarm will be used. /// Defaults to `null`. final double? volume; @@ -50,14 +102,16 @@ class AlarmSettings { /// Whether to show a notification when application is killed to warn /// the user that the alarms won't ring anymore. Enabled by default. - /// Recommanded for iOS. + /// + /// Not necessary for Android. Recommanded for iOS. final bool enableNotificationOnKill; /// Whether to turn screen on and display full screen notification /// when android alarm notification is triggered. Enabled by default. final bool androidFullScreenIntent; - /// Returns a hash code for this `AlarmSettings` instance using Jenkins hash function. + /// Returns a hash code for this `AlarmSettings` instance using + /// Jenkins hash function. @override int get hashCode { var hash = 0; @@ -77,42 +131,6 @@ class AlarmSettings { return hash; } - /// Model that contains all the settings to customize and set an alarm. - /// - /// - /// Note that if you want to show a notification when alarm is triggered, - /// both [notificationTitle] and [notificationBody] must not be null nor empty. - const AlarmSettings({ - required this.id, - required this.dateTime, - required this.assetAudioPath, - this.loopAudio = true, - this.vibrate = true, - this.volume, - this.fadeDuration = 0.0, - required this.notificationTitle, - required this.notificationBody, - this.enableNotificationOnKill = true, - this.androidFullScreenIntent = true, - }); - - /// Constructs an `AlarmSettings` instance from the given JSON data. - factory AlarmSettings.fromJson(Map json) => AlarmSettings( - id: json['id'] as int, - dateTime: DateTime.fromMicrosecondsSinceEpoch(json['dateTime'] as int), - assetAudioPath: json['assetAudioPath'] as String, - loopAudio: json['loopAudio'] as bool, - vibrate: json['vibrate'] as bool? ?? true, - volume: json['volume'] as double?, - fadeDuration: json['fadeDuration'] as double, - notificationTitle: json['notificationTitle'] as String? ?? '', - notificationBody: json['notificationBody'] as String? ?? '', - enableNotificationOnKill: - json['enableNotificationOnKill'] as bool? ?? true, - androidFullScreenIntent: - json['androidFullScreenIntent'] as bool? ?? true, - ); - /// Creates a copy of `AlarmSettings` but with the given fields replaced with /// the new values. AlarmSettings copyWith({ @@ -163,10 +181,11 @@ class AlarmSettings { /// Returns all the properties of `AlarmSettings` for debug purposes. @override String toString() { - Map json = toJson(); - json['dateTime'] = DateTime.fromMicrosecondsSinceEpoch(json['dateTime']); + final json = toJson(); + json['dateTime'] = + DateTime.fromMicrosecondsSinceEpoch(json['dateTime'] as int); - return "AlarmSettings: ${json.toString()}"; + return 'AlarmSettings: $json'; } /// Compares two AlarmSettings. diff --git a/lib/service/storage.dart b/lib/service/alarm_storage.dart similarity index 72% rename from lib/service/storage.dart rename to lib/service/alarm_storage.dart index cbe1a333..42702952 100644 --- a/lib/service/storage.dart +++ b/lib/service/alarm_storage.dart @@ -1,17 +1,29 @@ import 'dart:convert'; -import 'package:alarm/alarm.dart'; import 'package:alarm/model/alarm_settings.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// Class that handles the local storage of the alarm info. class AlarmStorage { + /// Prefix to be used in local storage to identify alarm info. static const prefix = '__alarm_id__'; + + /// Key to be used in local storage to identify + /// notification on app kill title. static const notificationOnAppKill = 'notificationOnAppKill'; + + /// Key to be used in local storage to identify + /// notification on app kill body. static const notificationOnAppKillTitle = 'notificationOnAppKillTitle'; + + /// Key to be used in local storage to identify + /// notification on app kill body. static const notificationOnAppKillBody = 'notificationOnAppKillBody'; + /// Shared preferences instance. static late SharedPreferences prefs; + /// Initializes shared preferences instance. static Future init() async { prefs = await SharedPreferences.getInstance(); } @@ -24,7 +36,7 @@ class AlarmStorage { ); /// Removes alarm from local storage. - static Future unsaveAlarm(int id) => prefs.remove("$prefix$id"); + static Future unsaveAlarm(int id) => prefs.remove('$prefix$id'); /// Whether at least one alarm is set. static bool hasAlarm() { @@ -46,7 +58,9 @@ class AlarmStorage { for (final key in keys) { if (key.startsWith(prefix)) { final res = prefs.getString(key); - alarms.add(AlarmSettings.fromJson(json.decode(res!))); + alarms.add( + AlarmSettings.fromJson(json.decode(res!) as Map), + ); } } @@ -63,11 +77,11 @@ class AlarmStorage { prefs.setString(notificationOnAppKillBody, body), ]); - /// Returns notification on app kill [title]. + /// Returns notification on app kill [notificationOnAppKillTitle]. static String getNotificationOnAppKillTitle() => prefs.getString(notificationOnAppKillTitle) ?? 'Your alarms may not ring'; - /// Returns notification on app kill [body]. + /// Returns notification on app kill [notificationOnAppKillBody]. static String getNotificationOnAppKillBody() => prefs.getString(notificationOnAppKillBody) ?? 'You killed the app. Please reopen so your alarms can be rescheduled.'; diff --git a/lib/src/android_alarm.dart b/lib/src/android_alarm.dart index 4c648d16..10a78f22 100644 --- a/lib/src/android_alarm.dart +++ b/lib/src/android_alarm.dart @@ -1,22 +1,29 @@ import 'dart:async'; import 'package:alarm/alarm.dart'; -import 'package:alarm/service/storage.dart'; +import 'package:alarm/model/alarm_settings.dart'; +import 'package:alarm/service/alarm_storage.dart'; +import 'package:alarm/utils/alarm_exception.dart'; import 'package:flutter/services.dart'; /// Uses method channel to interact with the native platform. class AndroidAlarm { + /// Method channel for the alarm. static const platform = MethodChannel('com.gdelataillade.alarm/alarm'); + /// Whether there are other alarms set. static bool get hasOtherAlarms => AlarmStorage.getSavedAlarms().length > 1; + /// Initializes the method channel. static Future init() async { platform.setMethodCallHandler(handleMethodCall); } + /// Handles the method call from the native platform. static Future handleMethodCall(MethodCall call) async { try { if (call.method == 'alarmRinging') { - int id = call.arguments['id']; + final arguments = call.arguments as Map; + final id = arguments['id'] as int; final settings = Alarm.getAlarm(id); if (settings != null) Alarm.ringStream.add(settings); } @@ -25,7 +32,7 @@ class AndroidAlarm { } } - /// Schedules a native alarm with given [alarmSettings] with its notification. + /// Schedules a native alarm with given [settings] with its notification. static Future set( AlarmSettings settings, void Function()? onRing, @@ -74,15 +81,15 @@ class AndroidAlarm { /// Sends the message `stop` to the isolate so the audio player /// can stop playing and dispose. static Future stop(int id) async { - final res = await platform.invokeMethod('stopAlarm', {'id': id}); + final res = await platform.invokeMethod('stopAlarm', {'id': id}) as bool; if (res) alarmPrint('Alarm with id $id stopped'); - if (!hasOtherAlarms) stopNotificationOnKillService(); + if (!hasOtherAlarms) await stopNotificationOnKillService(); return res; } /// Checks if the alarm with given [id] is ringing. static Future isRinging(int id) async { - final res = await platform.invokeMethod('isRinging', {'id': id}); + final res = await platform.invokeMethod('isRinging', {'id': id}) as bool; return res; } diff --git a/lib/src/ios_alarm.dart b/lib/src/ios_alarm.dart index 7e779f0c..1620b450 100644 --- a/lib/src/ios_alarm.dart +++ b/lib/src/ios_alarm.dart @@ -1,15 +1,21 @@ import 'dart:async'; import 'package:alarm/alarm.dart'; -import 'package:alarm/service/storage.dart'; +import 'package:alarm/model/alarm_settings.dart'; +import 'package:alarm/service/alarm_storage.dart'; +import 'package:alarm/utils/alarm_exception.dart'; import 'package:flutter/services.dart'; import 'package:flutter_fgbg/flutter_fgbg.dart'; /// Uses method channel to interact with the native platform. class IOSAlarm { + /// Method channel for the alarm. static const methodChannel = MethodChannel('com.gdelataillade/alarm'); + /// Map of alarm timers. static Map timers = {}; + + /// Map of foreground/background subscriptions. static Map?> fgbgSubscriptions = {}; /// Calls the native function `setAlarm` and listens to alarm ring state. @@ -50,12 +56,12 @@ class IOSAlarm { false; alarmPrint( - 'Alarm with id $id scheduled ${res ? 'successfully' : 'failed'} at ${settings.dateTime}', + '''Alarm with id $id scheduled ${res ? 'successfully' : 'failed'} at ${settings.dateTime}''', ); if (!res) return false; } catch (e) { - Alarm.stop(id); + await Alarm.stop(id); throw AlarmException(e.toString()); } @@ -106,7 +112,7 @@ class IOSAlarm { final pos1 = await methodChannel .invokeMethod('audioCurrentTime', {'id': id}) ?? 0.0; - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 100), () {}); final pos2 = await methodChannel .invokeMethod('audioCurrentTime', {'id': id}) ?? 0.0; @@ -136,11 +142,13 @@ class IOSAlarm { }); } + /// Disposes alarm timer. static void disposeTimer(int id) { timers[id]?.cancel(); timers.removeWhere((key, value) => key == id); } + /// Disposes alarm timer and FGBG subscription. static void disposeAlarm(int id) { disposeTimer(id); fgbgSubscriptions[id]?.cancel(); diff --git a/lib/utils/alarm_exception.dart b/lib/utils/alarm_exception.dart new file mode 100644 index 00000000..12d919d6 --- /dev/null +++ b/lib/utils/alarm_exception.dart @@ -0,0 +1,11 @@ +/// Custom exception for the alarm. +class AlarmException implements Exception { + /// Creates an [AlarmException] with the given error [message]. + const AlarmException(this.message); + + /// Exception message. + final String message; + + @override + String toString() => message; +} diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart new file mode 100644 index 00000000..4fff82b1 --- /dev/null +++ b/lib/utils/extensions.dart @@ -0,0 +1,11 @@ +/// Extensions on [DateTime]. +extension DateTimeExtension on DateTime { + /// Whether two [DateTime] are the same second. + bool isSameSecond(DateTime other) => + year == other.year && + month == other.month && + day == other.day && + hour == other.hour && + minute == other.minute && + second == other.second; +} diff --git a/pubspec.yaml b/pubspec.yaml index 683207be..1ee9e615 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^3.0.1 + very_good_analysis: ^5.1.0 flutter: assets: