feat(notifications): production fired-event audit-log sweep#14
Open
feat(notifications): production fired-event audit-log sweep#14
Conversation
- Onboarding flow + Selector-based first-launch routing in main.dart - 5 new screens: add_habit, analytics, onboarding, premium, settings - AdService (banner-only, --dart-define gated, premium-aware) - OnboardingService (settings-box backed, no new HiveType) - 5 new test files (30 pass, 2 documented skips) - Android: AGP/Gradle/desugaring + USE_EXACT_ALARM + minSdk 26 - iOS: Info.plist privacy strings + Time Sensitive Notifications hint - pubspec: add google_mobile_ads ^5.3.1; description -> Invisible Habit Builder Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
flutter_local_notifications doesn't expose a fire-time callback, so the audit log was missing NotificationEventType.fired rows in production — recordFired() existed only as a @VisibleForTesting shim with no caller. This wires the inferred-fire path that closes the audit log gap flagged in CHANGELOG.md "Remaining for shipping". - NotificationService.sweepFiredEvents({DateTime? now}) — single-pass scan of the audit box; appends a fired row for every scheduled row whose intendedAt has elapsed and lacks a terminal event (fired / tapped / dismissed / scheduleFailed). The synthetic fired.at is dated to the scheduled intendedAt — drift = 0 for inferred-fire rows by construction. Idempotent; Web no-op; returns marked count. - _NotificationLifecycleObserver in main.dart — mixes in WidgetsBindingObserver, runs the sweep once post-first-frame at startup and on every AppLifecycleState.resumed transition. Unawaited so startup and resume never block on I/O. - recordFired() removed (dead @VisibleForTesting shim, no callers). - _AuditGroup helper class encapsulates the per-notificationId classification (scheduled-row pointer + hasTerminal flag) used by the sweep loop. - 5 new unit tests in test/notification_sweep_test.dart cover: marks eligible rows exactly once, idempotency, skips future-dated, skips already-terminal across all 4 terminal types, and the mixed-corpus regression where a permissionDenied row at id=-1 doesn't poison the sweep. A real OS-level fire callback (Android NotificationListenerService, iOS Notification Service Extension) remains the v1.1 path to truthful sub-minute drift measurement. Verified locally: - flutter analyze --fatal-infos: No issues found! - flutter test: 35 pass, 2 skip (was 30/2 — 5 new sweep tests, no regressions) - flutter build apk --debug: Built (10.8s) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a production-safe way to backfill missing NotificationEventType.fired audit rows by inferring “fired” when a scheduled notification’s intended time has elapsed without any terminal event, and wires the sweep to run on app startup and resume. The diff also includes broader v1.0 UI/service wiring (onboarding routing, premium/paywall + banner ads, settings/analytics/add-habit screens) plus accompanying tests and platform configuration updates.
Changes:
- Add
NotificationService.sweepFiredEvents()and a lifecycle observer to run the sweep after first frame and on every resume. - Add onboarding routing/service and several new screens (home updates, analytics, add-habit, premium, settings) with associated unit/widget tests.
- Add AdMob banner support (dependency + Android/iOS manifest/plist wiring) and update docs/changelog/package metadata.
Reviewed changes
Copilot reviewed 22 out of 23 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
lib/services/notification_service.dart |
Adds sweepFiredEvents() + _AuditGroup accumulator for inferred fired-event audit rows |
lib/main.dart |
Wires sweep via _NotificationLifecycleObserver; adds onboarding routing + AdService/OnboardingService providers and entitlement bridge |
test/notification_sweep_test.dart |
Unit tests covering sweep eligibility, idempotency, and terminal-event exclusion |
lib/services/onboarding_service.dart |
Hive-settings-box-backed onboarding completion flag + notifier |
test/onboarding_routing_test.dart |
Widget test for onboarding routing selector behavior (one test skipped) |
lib/services/ad_service.dart |
Banner-only AdMob service with configuration/entitlement gating |
test/ad_service_gating_test.dart |
Pure-Dart tests for AdService gating + ChangeNotifier behavior |
lib/screens/home_screen.dart |
Adds navigation actions, banner slot, free-tier cap routing, and foreground reschedule sweep |
lib/screens/add_habit_screen.dart |
New habit create/edit form + reminder scheduling + free-tier cap enforcement |
test/add_habit_screen_test.dart |
Widget tests for validators and trimmed identity submission behavior |
lib/screens/analytics_screen.dart |
Heatmap analytics UI with premium gating and IF-THEN bottom sheet |
test/analytics_screen_test.dart |
Widget test for analytics empty-state (currently skipped) |
lib/screens/onboarding_screen.dart |
4-page onboarding flow with notifications/health permission prompts |
lib/screens/premium_screen.dart |
Premium paywall UI with purchase/restore flows |
lib/screens/settings_screen.dart |
Settings UI including export/wipe and multiple service hooks |
test/graceful_degradation_test.dart |
Boots full app with empty --dart-define config to verify no-crash path |
pubspec.yaml |
Updates description and adds google_mobile_ads dependency |
pubspec.lock |
Locks google_mobile_ads and updates SDK minimums recorded in lockfile |
android/app/src/main/AndroidManifest.xml |
Adds notification/ad/health permissions + AdMob app id meta-data + app label |
android/app/build.gradle.kts |
Adds manifest placeholder injection for AdMob app id |
ios/Runner/Info.plist |
Updates display name and adds notification/health usage strings + AdMob identifiers + SKAdNetworkItems |
CHANGELOG.md |
Documents fired-event sweep addition/removal |
CLAUDE.md |
Updates project state table and workflow notes |
Comment on lines
+356
to
+369
| void add(NotificationDelivery row) { | ||
| switch (row.event) { | ||
| case NotificationEventType.scheduled: | ||
| scheduled = row; | ||
| case NotificationEventType.fired: | ||
| case NotificationEventType.tapped: | ||
| case NotificationEventType.dismissed: | ||
| case NotificationEventType.scheduleFailed: | ||
| hasTerminal = true; | ||
| case NotificationEventType.permissionDenied: | ||
| // Permission events aren't tied to a specific notificationId | ||
| // (they're recorded with id=-1) and never block the sweep. | ||
| break; | ||
| } |
Comment on lines
+266
to
+275
| /// Wired to the [WidgetsBindingObserver] in `main.dart`: runs once on app | ||
| /// startup and on every `AppLifecycleState.resumed` transition. On Web | ||
| /// this is a no-op that returns 0 (no native notification surface). | ||
| /// | ||
| /// **Idempotent.** Running twice in succession produces at most one | ||
| /// inferred `fired` row per notification. | ||
| /// | ||
| /// Returns the number of new `fired` rows appended. | ||
| Future<int> sweepFiredEvents({DateTime? now}) async { | ||
| if (kIsWeb) return 0; |
Comment on lines
+1
to
+42
| import 'dart:io' show Platform; | ||
|
|
||
| import 'package:flutter/foundation.dart'; | ||
| import 'package:google_mobile_ads/google_mobile_ads.dart'; | ||
|
|
||
| /// Banner-only ad service for the free tier. | ||
| /// | ||
| /// Mirrors [PurchaseService] graceful-degradation pattern: when the | ||
| /// `--dart-define` ad-unit IDs are unset (dev/CI/test), [isConfigured] | ||
| /// returns false and [createBanner] returns null — the home screen's | ||
| /// banner slot then collapses to `SizedBox.shrink()`. | ||
| /// | ||
| /// Premium entitlement is forwarded in via [setAdsRemoved] from the | ||
| /// `_PremiumEntitlementBridge` in `main.dart`. | ||
| /// | ||
| /// No interstitials and no rewarded video for v1.0 per docs/BUILD_PLAN.md | ||
| /// reward-system rules (interstitials are word-of-mouth killers for habit | ||
| /// trackers; rewarded video is a v1.1 opt-in micro-unlock). | ||
| class AdService extends ChangeNotifier { | ||
| AdService({ | ||
| String? bannerUnitIdAndroid, | ||
| String? bannerUnitIdIos, | ||
| }) : _bannerUnitIdAndroid = bannerUnitIdAndroid ?? | ||
| const String.fromEnvironment('ADMOB_BANNER_ID_ANDROID'), | ||
| _bannerUnitIdIos = bannerUnitIdIos ?? | ||
| const String.fromEnvironment('ADMOB_BANNER_ID_IOS'); | ||
|
|
||
| final String _bannerUnitIdAndroid; | ||
| final String _bannerUnitIdIos; | ||
|
|
||
| bool _initialized = false; | ||
| bool _adsRemoved = false; | ||
|
|
||
| /// True only when (a) we're on a supported platform (iOS / Android) and | ||
| /// (b) the platform-appropriate banner unit ID was injected via | ||
| /// `--dart-define=ADMOB_BANNER_ID_{ANDROID,IOS}=ca-app-pub-XXX/XXX`. | ||
| bool get isConfigured { | ||
| if (kIsWeb) return false; | ||
| if (Platform.isAndroid) return _bannerUnitIdAndroid.isNotEmpty; | ||
| if (Platform.isIOS) return _bannerUnitIdIos.isNotEmpty; | ||
| return false; | ||
| } |
Comment on lines
+1
to
+6
| import 'dart:convert'; | ||
| import 'dart:io'; | ||
|
|
||
| import 'package:flutter/material.dart'; | ||
| import 'package:path_provider/path_provider.dart'; | ||
| import 'package:provider/provider.dart'; |
Comment on lines
+444
to
+451
| return Consumer<AdService>( | ||
| builder: (context, ads, _) { | ||
| if (ads.adsRemoved || _banner == null) return const SizedBox.shrink(); | ||
| return SizedBox( | ||
| width: _banner!.size.width.toDouble(), | ||
| height: _banner!.size.height.toDouble(), | ||
| child: AdWidget(ad: _banner!), | ||
| ); |
Comment on lines
+38
to
+54
| // Foreground re-arm sweep — workaround for `flutter_local_notifications` | ||
| // 19.x not auto-rearming on RECEIVE_BOOT_COMPLETED. We re-schedule every | ||
| // active habit's next occurrence on app open so reminders survive reboots. | ||
| WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| if (!mounted) return; | ||
| _rescheduleAllUpcoming(); | ||
| }); | ||
| } | ||
|
|
||
| Future<void> _rescheduleAllUpcoming() async { | ||
| final provider = context.read<HabitProvider>(); | ||
| final notifications = context.read<NotificationService>(); | ||
| final now = DateTime.now(); | ||
| for (final habit in provider.activeHabits) { | ||
| final when = _nextOccurrence(habit, now); | ||
| await notifications.scheduleHabitReminder(habit: habit, when: when); | ||
| } |
Comment on lines
+57
to
+69
| // Resolve / persist the dropdown selection across rebuilds. | ||
| Habit selected; | ||
| if (_selected != null && habits.contains(_selected)) { | ||
| selected = _selected!; | ||
| } else if (widget.initialHabitId != null) { | ||
| selected = habits.firstWhere( | ||
| (h) => h.id == widget.initialHabitId, | ||
| orElse: () => habits.first, | ||
| ); | ||
| } else { | ||
| selected = habits.first; | ||
| } | ||
| _selected = selected; |
Comment on lines
+31
to
+55
| switch (outcome) { | ||
| case PurchaseOutcome.success: | ||
| ScaffoldMessenger.of(context).showSnackBar( | ||
| const SnackBar(content: Text('Premium unlocked.')), | ||
| ); | ||
| await Future<void>.delayed(const Duration(milliseconds: 600)); | ||
| if (!mounted) return; | ||
| Navigator.pop(context); | ||
| case PurchaseOutcome.userCancelled: | ||
| // Silent — user dismissed the StoreKit / Play Billing sheet. | ||
| break; | ||
| case PurchaseOutcome.error: | ||
| ScaffoldMessenger.of(context).showSnackBar( | ||
| const SnackBar(content: Text('Purchase failed. Try again.')), | ||
| ); | ||
| case PurchaseOutcome.notConfigured: | ||
| // Shouldn't reach here — buy button is hidden when !isConfigured. | ||
| debugPrint( | ||
| '[PremiumScreen] purchaseLifetime returned notConfigured ' | ||
| 'despite isConfigured=true; check PurchaseService state.', | ||
| ); | ||
| ScaffoldMessenger.of(context).showSnackBar( | ||
| const SnackBar(content: Text('Purchases unavailable')), | ||
| ); | ||
| } |
Comment on lines
79
to
+104
| @@ -86,6 +100,8 @@ Future<void> main() async { | |||
| intentions: intentions, | |||
| healthWriteback: healthWriteback, | |||
| purchases: purchases, | |||
| ads: ads, | |||
| onboarding: onboarding, | |||
This was referenced May 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the documented audit-log gap from CHANGELOG "Remaining for shipping: notification fired-event sweep".
flutter_local_notificationsdoesn't expose a fire-time callback, so the audit log was never gettingNotificationEventType.firedrows in production — the previousrecordFired()was a@visibleForTestingshim with no caller.What's new
NotificationService.sweepFiredEvents({DateTime? now})— single-pass scan of the audit box. For everyscheduledrow whoseintendedAthas elapsed and that lacks a terminal event (fired/tapped/dismissed/scheduleFailed), append a syntheticfiredrow dated to the scheduledintendedAt. Drift = 0 by construction for inferred-fire rows. Idempotent, Web no-op, returns the marked count._NotificationLifecycleObserverinmain.dart—WidgetsBindingObservermixin; runs the sweep once after the first frame at startup, plus on everyAppLifecycleState.resumed. All calls areunawaitedso startup and resume never block on I/O._AuditGrouphelper — encapsulates per-notificationIdclassification (scheduled-row pointer +hasTerminalflag). Keeps the sweep loop linear in audit rows.recordFired()removed — dead shim, no callers.test/notification_sweep_test.dart— 5 unit tests against a real Hive box covering: marks every eligible row exactly once, idempotency on re-run, skips future-dated rows, skips already-terminal rows across all 4 terminal types, and the mixed-corpus regression where apermissionDeniedrow atid=-1doesn't poison the sweep.Why "inferred fire" not real fire
flutter_local_notificationsis a thin Dart wrapper over the OS notification system. Neither Android nor iOS exposes a synchronous fire-time callback to the host app. Real fire-time observation requires:NotificationListenerServicewith theBIND_NOTIFICATION_LISTENER_SERVICEpermission (Play Store policy review required, separate runtime grant).Both are v1.1 work. For v1.0, inferring fire from "scheduled time elapsed AND the schedule didn't error" is the most truthful approximation possible. The drift metric (intended vs actual) reads as zero for inferred rows; once real OS callbacks land, drift becomes a real number for the post-extension subset of the audit log and the inferred-fire pre-extension subset is preserved as historical baseline.
Test plan
Verified locally (Windows / Flutter 3.41.5 / Dart 3.11.5):
flutter analyze --fatal-infos—No issues found!flutter test test/notification_sweep_test.dart --reporter=expanded— 5/5 passedflutter test --reporter=expanded— 35 pass, 2 skip (was 30/2 — 5 new sweep tests, zero regressions)flutter build apk --debug— Built in 10.8sSequencing notes
This branch is cut from
mainat the v1.0 commit (6e4896d), so it does not depend on PR #12 or PR #13. After all three merge, the linear history isv1.0 → CI hardening → fired-event sweep. If #12 or #13 lands first, this PR rebases cleanly (no overlap withlib/services/notification_service.dart,lib/main.dart, ortest/).🤖 Generated with Claude Code