Refactor and redesign repeating notifications
Update: See the below comment for a different API that more clearly defines what is supported on the different systems
I'm writing up the usage guide now (see #2477), and quickly found that scheduling repeating notifications is complicated. I'm sure this is at least partially historical, but users have three methods to choose from:
zonedSchedule(), with a non-nullmatchDateTimeComponentsperiodicallyShow(), with a givenRepeatIntervalperiodicallyShowWithDuration(), with a givenDuration
Fundamentally, there are four ways to show a scheduled notification:
- show once
- show then repeat given certain conditions, like date, day of week, etc
- show then repeat on a given interval
The differences between the methods
From what I can tell, the main difference between zonedSchedule() and periodicallyShow() is that periodicallyShow() starts now, whereas zonedSchedule() takes a TZDateTime parameter. It's true that UNTimeIntervalNotificationTrigger doesn't take in a date/time to start the timer and starts immediately. So there's no way to say "send a notification every 30 minutes, starting in 2 hours from now".
To get around this, we can use an approach similar to Android, where we schedule a background task for the given date and time, which then schedules the first UNTimeIntervalNotificationTrigger.
The only difference between periodicallyShow() and periodicallyShowWithDuration() is the ability to pass an arbitrary duration, but Android, iOS, and MacOS support an arbitrary duration, so we should just always allow an arbitrary duration, preferring Duration(days: 1) to RepeatInterval.daily. In fact, on Android and Darwin, the enum is converted to milliseconds, and on Linux and Windows periodic notifications are not supported at all.
New proposal
sealed class NotificationRepeatDetails { }
class NotificationRepeatInterval extends NotificationRepeatDetails {
Duration duration;
}
class NotificationRepeatDate extends NotificationRepeatDetails {
DateTimeComponents components;
}
void schedule({
required int id,
required TZDateTime scheduleDateTime,
String? title,
String? body,
String? payload,
NotificationDetails? details,
NotificationRepeatDetails? repeatsOn,
);
Since this will be a breaking change anyway, I renamed some parameters. I removed uiLocalNotificationDateInterpretation since it only applies to devices running iOS 10 or earlier, but Flutter hasn't supported those versions since Flutter 3.13, and this plugin only supports Flutter ^3.19. I also think we should move androidScheduleMode to be an optional parameter on AndroidNotificationDetails(), defaulting to something reasonable like .inexact, but I can see an argument not to since it doesn't apply to show().
Examples
final location = getLocation(...);
final now = TZDateTime.now(location);
// Schedule a notification tomorrow at 8 pm
plugin.schedule(
id: 0,
scheduledDateTime: now.copyWith(hour: 20, minute: 00),
// title, body, details
);
// Schedule a repeating reminder, every day at noon
plugin.schedule(
id: 1,
scheduledDateTime: now.copyWith(hour: 12, minute: 00),
repeatsOn: NotificationRepeatDate(DateTimeComponents.time),
// title, body, details
);
// Schedule a reminder every 30 minutes to take a break, starting in 30 minutes from now
plugin.schedule(
id: 2,
scheduledDateTime: now.add(Duration(minutes: 30)),
repeatsOn: NotificationRepeatInterval(Duration(minutes: 30)),
// title, body, details
);
@MaikuB What are your thoughts on implementing this for v20? It is breaking but it would significantly simplify the API, favoring one method over three, and rid us of a lot of unused code. Again, I'd be happy to contribute a PR
Seems like, really, no platform supports everything:
- iOS/MacOS support repeating notifications starting now
- Android, Windows, and Linux do not support scheduled or repeating notifications at all
On Android, we already use background tasks and alarms, so we probably need to do so on all platforms:
- Android: https://developer.android.com/develop/background-work/services/alarms/schedule
- iOS/MacOS: https://developer.apple.com/documentation/uikit/using-background-tasks-to-update-your-app
- Windows: https://learn.microsoft.com/en-us/windows/uwp/launch-resume/create-and-register-a-winmain-background-task
- Linux: Can't find anything remotely native, so maybe spawning a thread and sleeping?
Responding to the points in #2503:
The feature was never done and requests on this had been closed as Apple doesn't support it
That leaves us in a bit of an awkward position -- offering features that Android doesn't natively support, but not offering them on Darwin because... Apple doesn't support them. Not to mention Linux and Windows (see above). At this point, Android should probably stick around as-is for historical reasons, and other platforms can utilize package:workmanager for more complex needs.
Another thing to note is there is already a work manager plugin that makes use of background tasks. I've seen the community leverage this with this plugin already. My preference would be to keep the separation that way
What's the current approach for asymmetric features across platforms, sounds like it's something just generally avoided?
So just to review:
- Android supports everything but has all the nuances and subtleties that come with it
- iOS/MacOS supports
zonedSchedule()but can't offset itsperiodicallyShow() - Windows: supports scheduled notifications but not repeats
- Linux: No support for scheduled or repeating notifications
I realized the key to making a cleaner API is to embrace the differences between platforms instead of trying to cover them up. After playing around a bit, I came up with this new API I'm really happy with:
New API
class NotificationSchedule {
AndroidSchedule? android;
DarwinSchedule? ios;
DarwinSchedule? macos;
WindowsSchedule? windows;
// Linux and Web are not supported
}
sealed class AndroidSchedule { }
sealed class DarwinSchedule { }
sealed class WindowsSchedule { }
class ScheduleOnce implements AndroidSchedule, DarwinSchedule, WindowsSchedule {
TZDateTime date;
}
class ScheduleRepeating implements AndroidSchedule, DarwinSchedule {
TZDateTime date;
DateTimeComponents repeatsOn;
}
class PeriodicScheule implements AndroidSchedule, DarwinSchedule {
Duration interval;
}
class PeriodicScheduleWithStart implements AndroidSchedule {
Duration interval;
TZDateTime start;
}
void schedule({
required int id,
required NotificationSchedule schedule,
String? title,
String? body,
String? payload,
NotificationDetails? details,
);
And I still think androidScheduleMode should be in AndroidNotificationDetails, since it can't apply to any other platform, and means having only one definition instead of in all the schedules that Android supports.
Several benefits here:
- more flexibility in the future how we treat each platform
- makes it clear that Linux and Web are not supported at all
- allows differences between all platforms
- specifically, allows a difference between iOS and MacOS, which is nice because mobile and desktop apps are different
- makes it impossible to pass args that aren't supported for a platform
- no more reading the docs to find out what is and isn't supported -- the code tells you
Updated examples
final location = getLocation(...);
final now = TZDateTime.now(location);
// Schedule a notification tomorrow at 8 pm
final tomorrowAt8 = ScheduleOnce(date: now.copyWith(hour: 20, minute: 00));
plugin.schedule(
id: 0,
schedule: NotificationSchedule(
android: tomorrowAt8,
ios: tomorrowAt8,
macos: tomorrowAt8,
windows: tomorrowAt8,
),
// title, body, details
);
// Schedule a repeating reminder, every day at noon
final repeatAtNoon = ScheduleRepeating(
date: now.copyWith(hour: 12, minute: 00),
repeatsOn: DateTimeComponents.time,
);
plugin.schedule(
id: 1,
schedule: NotificationSchedule(
android: repeatAtNoon,
ios: repeatAtNoon,
macos: repeatAtNoon,
// Trying to pass `windows: repeatAtNoon` is a compile-time error
windows: ScheduleOnce(date: now.copyWith(hour: 12, minute: 00)),
),
// title, body, details
);
// Schedule a reminder to take a break. Differs by platform.
plugin.schedule(
id: 2,
schedule: NotificationSchedule(
// Android: every 30 minutes, starting 30 minutes from now
android: PeriodicScheduleWithStart(
start: now.add(Duration(minutes: 30)),
interval: Duration(minutes: 30),
),
// Darwin: Every hour, starting from now
ios: PeriodicSchedule(interval: Duration(hours: 1)),
macos: PeriodicSchedule(interval: Duration(hours: 1)),
// Windows: In 1 hour from now
windows: ScheduleOnce(date: now.add(Duration(hours: 1)),
);
repeatsOn: NotificationRepeatInterval(Duration(minutes: 30)),
// title, body, details
);
@MaikuB What do you think? I think it would be good in the long-term to replace zonedSchedule(), periodicallyShow(), and periodicallyShowWithDuration() with this new schedule() function. I'd also be happy to make the PR.
That leaves us in a bit of an awkward position -- offering features that Android doesn't natively support, but not offering them on Darwin because... Apple doesn't support them.
Sentence here seems a bit off. I suspect you were trying to convey that there a features available on Android but not be enabled as Apple doesn't support them. If so, something to important here to remember here as developers pick Flutter to build apps across multiple platforms with the intent of having feature parity. It would be very unlikely that an app would go ahead and make a feature to do with notifications only available on, say, Android only (note: this is factoring that Flutter apps are more likely targeting only Android and iOS). Typically libraries like this plugin are built more to support cross-platform development. When it comes to cross-platform development (i.e. including outside the Flutter ecosystem), apps/developers that may have specialised use cases would go down the path of writing the code to interact with the native APIs. Part of what I'm getting at here is being conscious of building for edge cases and being careful of going down the rabbit hole of trying support the feature available for every platform. Might not be applicable in this case
Regarding the proposal itself, I'd need to think about it some more but some initial thoughts and info to share
- different methods were provided to help improve discoverability of the available functionality. It's more easier to find the availability functionality by finding the available methods compared to different (sub)classes that support. The aim of making things more discoverable was also while some platform-specific parameters like the Android schedule mode is on the "forefront"
- this may be a naming issue but part of what you shared lends itself more to being a method (e.g.
ScheduleOnce) as classes are named through use of nouns whilst methods use verbs. In this case, "schedule once" is an action
Sentence here seems a bit off. I suspect you were trying to convey that there a features available on Android but not be enabled as Apple doesn't support them.
Actually, what I was getting at -- and please correct me if I'm wrong because I'm still a bit confused -- is that Android doesn't seem to have any feature that says "schedule a notification for this time". Android supports alarms, which aren't user-visible notifications but rather trigger background work, and the plugin uses those alarms to show a notification. So really, the Android implementation schedules background work which then shows the actual notification.
Which is convenient, but it's inconsistent to then say "iOS doesn't support scheduled notifications with start offsets, only scheduled background work", because that's exactly the case with Android as well. The difference here is that iOS supports some scheduled notifications, just not with a start offset, and including a start offset means scheduling background work. So the plugin could schedule a background task with a start offset and use that to show a notification like it does on Android. Obviously, that's more code to maintain, and I'm not necessarily advocating that we should take that path, just mentioning the inconsistency between platforms.
Part of what I'm getting at here is being conscious of building for edge cases and being careful of going down the rabbit hole of trying support the feature available for every platform
Agreed. While it would be possible to switch the entire plugin to use background tasks on Darwin, I think sticking as close to the official APIs as possible makes maintenance easier, and the plugin was already built to do so. I don't really think this one use-case is enough of an argument to switch implementations completely. And the rest of my comment/proposal is more about separating out features between platforms to convey this better.
different methods were provided to help improve discoverability of the available functionality.
Right, but then consider that these methods advertise functionality that is just not available on certain platforms:
zonedSchedule- doesn't work at all on Linux
matchDateTimeComponentsis not supported on Windows
periodicallyShow()andperiodicallyShowWithDuration()- aren't supported on Linux or Windows.
- https://github.com/MaikuB/flutter_local_notifications/pull/2503 adds a
repeatStartTimeparameter, which will only be supported on Android
periodicallyShow()is made obsolete withperiodicallyShowWithDuration()- In all three methods,
androidScheduleModeis only supported on Android, but it's a parameter to the method directly, whereasDarwinNotificationDetails.interruptionLevel, which encodes the same information for iOS/MacOS, is relegated to thedetailsfield. - #2466 will support none of these as far as I know
I'd say if a method needs per-platform configuration, then it should accept that as a parameter, rather than declaring different methods that have wildly different behaviors on different platforms. Admittedly, this is more of a concern with platforms besides Android/iOS, and that may not be a priority, but linux + windows + web = half the platforms this plugin will support.
It's more easier to find the availability functionality by finding the available methods compared to different (sub)classes that support.
Agreed that discoverability is important, so these would have to be documented, which shouldn't be so bad. I'm thinking a "which options are supported on which platforms" table on the schedule() method, and a short This option is only supported on XXX on each subclass.
this may be a naming issue but part of what you shared lends itself more to being a method
I'm open to workshopping the names. I originally had them like PeriodicNotification and ScheduledNotification. I changed to these last-minute for brevity and clarity, but if you have alternatives I'm happy to work with those.
Was a bit confused about the removal of uiLocalNotificationDateInterpretation, thanks for clarifying!
@Levi-Lesches Sorry been meaning to come back to reply to this. I could give a more detailed answer if desired but will hold off given it's been a while and there's problem with the latest idea. It results in a god method that breaks single responsibility. Not to say I'm expert but to give you some background, the reason why there are different methods has been so each one had different responsibilities and tried to name them as best as I could to reflect what they're for
zonedSchedule(): schedule a notification with platform-specific details at the specified date/time relative to the given timezone. Note: originally there was aschedule()method sozonedwas use as a prefix.periodicallyShow(): periodically show a notification with platform-specific details based on a predefined intervalperiodicallyShowWithDuration(): periodically show a notification with platform-specific details based on a duration-based interval. This in hindsight could have potentially been done by changingperiodicallyShow()to handle duration as well. Though from what I recall, one of the reasons for separate function was this only supported on specific OS versions due to limitations on iOS at the time
I do remember you asking about refactoring and your proposal does this but I'd say it has gone too far and can allow antipatterns to crop up in the codebases of those using the plugin. The change now allows for semantics that translate to "schedule a notification with with platform-specific details and have it scheduled with platform-specific schedules" (e.g. based on an interval on Android, based on a date/time on iOS etc). That an "and" can be put in that highlights the breakage of single responsibility principle. Besides that, developers could end up writing code with inconsistent behaviour on each platform. I know this would have been done by them by choice but it would be better and more responsible to not enable this kind of behaviour