Skip to content

feat(notifications): production fired-event audit-log sweep#14

Open
Outtsett wants to merge 2 commits intomainfrom
feat/notification-fired-sweep
Open

feat(notifications): production fired-event audit-log sweep#14
Outtsett wants to merge 2 commits intomainfrom
feat/notification-fired-sweep

Conversation

@Outtsett
Copy link
Copy Markdown
Owner

@Outtsett Outtsett commented May 4, 2026

Summary

Closes the documented audit-log gap from CHANGELOG "Remaining for shipping: notification fired-event sweep". flutter_local_notifications doesn't expose a fire-time callback, so the audit log was never getting NotificationEventType.fired rows in production — the previous recordFired() was a @visibleForTesting shim with no caller.

What's new

  • NotificationService.sweepFiredEvents({DateTime? now}) — single-pass scan of the audit box. For every scheduled row whose intendedAt has elapsed and that lacks a terminal event (fired/tapped/dismissed/scheduleFailed), append a synthetic fired row dated to the scheduled intendedAt. Drift = 0 by construction for inferred-fire rows. Idempotent, Web no-op, returns the marked count.
  • _NotificationLifecycleObserver in main.dartWidgetsBindingObserver mixin; runs the sweep once after the first frame at startup, plus on every AppLifecycleState.resumed. All calls are unawaited so startup and resume never block on I/O.
  • _AuditGroup helper — encapsulates per-notificationId classification (scheduled-row pointer + hasTerminal flag). 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 a permissionDenied row at id=-1 doesn't poison the sweep.

Why "inferred fire" not real fire

flutter_local_notifications is 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:

  • Android — a NotificationListenerService with the BIND_NOTIFICATION_LISTENER_SERVICE permission (Play Store policy review required, separate runtime grant).
  • iOS — a Notification Service Extension target embedded in the app bundle, signed with its own bundle identifier and entitlements.

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-infosNo issues found!
  • flutter test test/notification_sweep_test.dart --reporter=expanded5/5 passed
  • flutter test --reporter=expanded35 pass, 2 skip (was 30/2 — 5 new sweep tests, zero regressions)
  • flutter build apk --debug — Built in 10.8s

Sequencing notes

This branch is cut from main at the v1.0 commit (6e4896d), so it does not depend on PR #12 or PR #13. After all three merge, the linear history is v1.0 → CI hardening → fired-event sweep. If #12 or #13 lands first, this PR rebases cleanly (no overlap with lib/services/notification_service.dart, lib/main.dart, or test/).

🤖 Generated with Claude Code

Outtsett and others added 2 commits May 4, 2026 10:45
- 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>
Copilot AI review requested due to automatic review settings May 4, 2026 19:05
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 thread lib/main.dart
Comment on lines 79 to +104
@@ -86,6 +100,8 @@ Future<void> main() async {
intentions: intentions,
healthWriteback: healthWriteback,
purchases: purchases,
ads: ads,
onboarding: onboarding,
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants