Skip to content

fix(notifications): make both automatic reminders actually fire - #516

Open
MOHITKOURAV01 wants to merge 1 commit into
ishita2740:mainfrom
MOHITKOURAV01:fix/issue-511-flutter-reminder-scheduling
Open

fix(notifications): make both automatic reminders actually fire#516
MOHITKOURAV01 wants to merge 1 commit into
ishita2740:mainfrom
MOHITKOURAV01:fix/issue-511-flutter-reminder-scheduling

Conversation

@MOHITKOURAV01

Copy link
Copy Markdown
Contributor

Closes #511.

The bug

Two automatic reminders, both on by default (periodPredictionReminders and loggingReminders are defaultValue: true), both rescheduled from main.dart on every launch. Neither one reliably fired.

Every failure was a bare return. Nothing told the user, and the Settings toggle stayed switched on while nothing was registered with the OS.

1. The period reminder gave up as soon as last_period went stale

final predictedDate = lastPeriod.add(Duration(days: cycleLength));
final reminderDate = predictedDate.subtract(Duration(days: daysBefore));
if (reminderDate.isBefore(now)) return;

One anchor, one cycle. Once now passed that date, reminderDate was permanently in the past and this returned without scheduling — on that launch and every launch after it.

The anchor only moves when the user logs a period. So the failure mode is exactly inverted from what a reminder is for: it worked for the woman already logging diligently, and switched itself off for the woman who had stopped — who is the one the nudge exists to reach. Log a period on 1 August, don't open the app, and from 31 August onward you are never reminded again.

2. The "daily" logging reminder fires at most once, usually zero times

The doc comment said "Schedule a daily reminder". The body scheduled a one-shot:

  • Not daily. zonedSchedule repeats only with matchDateTimeComponents: DateTimeComponents.time. Without it this is one notification at one instant.
  • Skipped entirely after 19:00, because scheduledDate is today at 19:00 and that is already past. Evening is when people check a tracker.
  • The "already logged today" check was frozen at scheduling time. Launch at 08:00, log at 09:00, still nudged at 19:00 to do what you already did — and if you had logged before launch, no reminder was scheduled for any later day either.

3. Nothing was ever delivered on iOS

Every NotificationDetails in the file was NotificationDetails(android: ...) with no iOS: entry, so the plugin had nothing to present. Compounding it, init() passes requestAlertPermission: false / requestBadgePermission: false / requestSoundPermission: false, and requestPermissions() only asked Permission.notification via permission_handler — which does not obtain the plugin's alert permission on iOS. So the permission was never granted and the details were never provided.

4. scheduleAllAutomaticNotifications only ever added

if (!periodPredictionReminders && !loggingReminders) return;
if (periodPredictionReminders) { ... }
if (loggingReminders) { ... }

With one toggle on and one off, the early return is skipped and the disabled reminder's already-registered notification is left in place. Settings cancels on the off-switch, but this launch-time path never reconciled — so a reminder turned off kept being delivered.

5. A channel called "Test Alerts"

AndroidNotificationDetails('test_channel', 'Test Alerts', ...). Android channel names are user-visible in system notification settings.

The change

lib/services/reminder_schedule.dart — the arithmetic, as pure functions

Pure functions of (anchor, now). None of this was testable before; it was all inline in a service that talks to the plugin.

planPeriodReminder walks forward one cycle at a time from the anchor until the reminder date is ahead of now, so a stale anchor produces a reminder rather than silence. It stops at 90 days past the anchor — roughly three cycles — and returns PeriodReminderKind.logPeriod:

Let's get your cycle back on track
It's been a while since you logged a period, so we can't predict the next one. Open Rhythma and log your last period.

That cap is the point of the three-valued return. Projecting a fourth cycle from an anchor that old produces a confident-looking prediction built on nothing, which is the failure #487 describes on the calendar. Saying "we've lost track" is honest and actionable; scheduling nothing was neither.

The nudge is scheduled for tomorrow morning, not this instant — the app is being launched right now, so a notification this second would fire over the screen the user is already looking at.

Two more cases that used to be silence:

  • An anchor in the future (wrong clock, mistyped date) nudges rather than projecting, since projecting from it pushes the reminder further out.
  • An implausible cycle_length is clamped to the population default, using the same 15–60 day band as the backend's prediction_service.MIN_PLAUSIBLE_CYCLE_DAYS / MAX_PLAUSIBLE_CYCLE_DAYS. A profile carrying 400 must not produce a reminder in the next century.

nextDailyOccurrence returns today's time if it is still ahead and tomorrow's otherwise. There is no case in which "that time has gone, so schedule nothing" is what a user asked for by leaving the toggle on.

NotificationService

  • Uses the planner; schedulePeriodPredictionReminder returns the PeriodReminderKind so a caller — and a test — can tell the three outcomes apart, instead of watching a method return void whether it did anything or not.
  • matchDateTimeComponents: DateTimeComponents.time on the logging reminder.
  • The "already logged today" check no longer gates scheduling, for the reason above.
  • One _details() helper, and every notification now carries DarwinNotificationDetails.
  • requestPermissions() goes through resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>() on iOS and falls back to permission_handler elsewhere.
  • scheduleAllAutomaticNotifications cancels a disabled reminder instead of skipping it.
  • test_channel / "Test Alerts" → rhythma_reminders_channel / a localized name.

Localization

The notification text was hard-coded English. It is composed outside the widget tree, so there is no BuildContext for AppLocalizations.of() — but the generated lookupAppLocalizations(Locale) takes a locale directly, and the chosen language is already in settings. Seven keys added across the ARBs and the committed generated Dart, translated in the eight locales that carry translations.

The lookup is wrapped: an unsupported code stored by an older build, or a settings box that is not open on some path, falls back to English rather than throwing — a crash on a background scheduling pass would take the reminder down with it, which is the class of bug this PR is about.

Tests

test/services/reminder_schedule_test.dart — 22 tests. The ones that state the issue:

  • rolls forward when the first projected cycle has already passed — a June anchor, projected across three cycles to September.
  • an anchor older than the projection horizon asks her to log — a year-old anchor, shouldSchedule is still true.
  • rolls to tomorrow when today's time has gone — the 21:00 launch that used to get nothing.

Plus the boundaries: a reminder date that passed earlier today, a zero and a negative lead time, the configurable horizon, an anchor in the future, month-boundary rollover, and the cycle-length clamping in both directions.

Notes for review

I could not run these. No Flutter or Dart toolchain on this machine, so flutter test and flutter analyze have not been run against this branch. Same caveat as #515. flutter analyze is red on main regardless (#492, with #493 open against it).

The riskiest line is the iOS permission request:

_notificationsPlugin.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()

I believe that class and requestPermissions({alert, badge, sound}) are correct for flutter_local_notifications: ^22.0.1, but it is the one API here I could not verify by compiling. Worth a look from anyone with the SDK to hand.

Other things to push back on:

Both reminders were on by default, rescheduled from `main.dart` on every
launch, and neither reliably fired. Every failure was a bare `return`, so
the toggle in Settings stayed switched on while nothing was registered
with the OS.

**The period reminder gave up as soon as `last_period` went stale.** It
projected exactly one cycle from the stored anchor:

    final predictedDate = lastPeriod.add(Duration(days: cycleLength));
    final reminderDate = predictedDate.subtract(Duration(days: daysBefore));
    if (reminderDate.isBefore(now)) return;

Once `now` passed that date it was permanently in the past, on that
launch and every launch after. The anchor only moves when a period is
logged — so the reminder worked for the woman already logging diligently
and switched itself off for the woman who had stopped, who is the one the
nudge exists to reach.

It now projects forward cycle by cycle until the reminder date is ahead,
with a 90-day cap measured from the anchor. Past that cap the anchor is
not evidence about this month, so instead of a confident date built on
nothing (the failure ishita2740#487 describes on the calendar) it schedules "log
your last period" — honest and actionable, where scheduling nothing was
neither.

**The "daily" logging reminder was a one-shot.** No
`matchDateTimeComponents`, so the OS never repeated it, and an early
return for anyone opening the app after 19:00 — which is when people
check a tracker. It is now genuinely daily and rolls to tomorrow when
today's time has gone. The "already logged today" check no longer gates
scheduling: it was evaluated once at launch, so logging at 09:00 still
produced a 19:00 nudge, and logging before launch produced no reminder
for any later day either.

**Nothing was ever delivered on iOS.** Every `NotificationDetails` was
`android:`-only, and `requestPermissions()` only asked
`permission_handler`, which does not obtain the plugin's alert
permission there. All four now carry `DarwinNotificationDetails`, and
the iOS request goes through the plugin's own resolver.

**`scheduleAllAutomaticNotifications` only ever added.** With one toggle
on and one off, the early return was skipped and the disabled reminder's
already-registered notification was left in place — so a reminder
switched off in Settings kept arriving. It now reconciles both ways.

The user-visible channel called "Test Alerts" is renamed; Android channel
names show in system notification settings.

The arithmetic moves to `services/reminder_schedule.dart` as pure
functions of `(anchor, now)`, so it can be tested without the plugin, a
device or the wall clock — none of it was testable before. Notification
text is now localized through `lookupAppLocalizations` rather than
hard-coded English, since the strings are composed outside the widget
tree where there is no BuildContext.

Fixes ishita2740#511
@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

@MOHITKOURAV01 is attempting to deploy a commit to the ishita2740's projects Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant