Files
HouseHoldKeaper/.planning/milestones/v1.0-phases/04-notifications/04-RESEARCH.md
2026-03-16 20:12:01 +01:00

34 KiB
Raw Blame History

Phase 4: Notifications - Research

Researched: 2026-03-16 Domain: Flutter local notifications, Android permission handling, scheduled alarms Confidence: HIGH

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • Notification timing: User-configurable time via time picker in Settings, stored in SharedPreferences. Default time: 07:00. Notifications disabled by default on fresh install.
  • Skip on zero-task days: No notification fires when there are no tasks due (overdue + today).
  • Notification content: Body shows task count with conditional overdue split — "5 Aufgaben fällig (2 überfällig)" when overdue > 0, "5 Aufgaben fällig" when no overdue. All text from ARB localization files.
  • Tap action: Tapping the notification opens the daily plan (Home tab).
  • Permission flow: Request when user toggles notifications ON in Settings (Android 13+ / API 33+). On denial, toggle reverts to OFF. On re-enable after prior denial, detect permanently denied state and guide user to system notification settings.
  • Android 12 and below: Same opt-in flow — user must enable in Settings. Consistent UX across all API levels.
  • RECEIVE_BOOT_COMPLETED: Notifications reschedule after device reboot if enabled.
  • Settings UI: New "Benachrichtigungen" section between Darstellung and Über. SwitchListTile for enable/disable. When enabled, time picker row appears below (progressive disclosure). When disabled, time picker row is hidden. showTimePicker() for time selection. Section header styled identically to existing "Darstellung" and "Über" headers.

Claude's Discretion

  • Notification title (app name vs contextual like "Dein Tagesplan")
  • Permission denial UX (inline hint vs dialog — pick best approach)
  • SwitchListTile + time picker row layout details (progressive disclosure animation, spacing)
  • Notification channel configuration (importance, sound, vibration)
  • Exact notification icon
  • Boot receiver implementation approach

Deferred Ideas (OUT OF SCOPE)

None — discussion stayed within phase scope </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
NOTF-01 User receives a daily summary notification showing today's task count at a configurable time flutter_local_notifications zonedSchedule with matchDateTimeComponents: DateTimeComponents.time handles daily recurring delivery; timezone + flutter_timezone for accurate local-time scheduling; one-shot Drift query counts overdue + today tasks at notification fire time
NOTF-02 User can enable/disable notifications in settings NotificationSettingsNotifier (AsyncNotifier pattern following ThemeNotifier) persists enabled bool + TimeOfDay hour/minute in SharedPreferences; SwitchListTile with progressive disclosure of time picker row in SettingsScreen
</phase_requirements>

Summary

Phase 4 implements a daily summary notification using flutter_local_notifications (v21.0.0), the standard Flutter package for local notifications. The notification fires once per day at a user-configured time, queries the Drift database for task counts, and delivers the result as an Android notification. The Settings screen gains a "Benachrichtigungen" section with a toggle and time picker that follows the existing ThemeNotifier/SharedPreferences pattern.

The primary complexity is the Android setup: build.gradle.kts requires core library desugaring and a minimum compileSdk of 35. The AndroidManifest.xml needs three additions — POST_NOTIFICATIONS permission, RECEIVE_BOOT_COMPLETED permission, and boot receiver registration. A known Android 12+ bug requires setting android:exported="true" on the ScheduledNotificationBootReceiver despite the official docs saying false. Permission handling for Android 13+ (API 33+) uses the built-in requestNotificationsPermission() method on AndroidFlutterLocalNotificationsPlugin; detecting permanently denied state on Android requires checking shouldShowRequestRationale since isPermanentlyDenied is iOS-only.

Primary recommendation: Use flutter_local_notifications: ^21.0.0 + timezone: ^0.9.4 + flutter_timezone: ^1.0.8. Create a NotificationService (a plain Dart class, not a provider) initialized at app start, and a NotificationSettingsNotifier (AsyncNotifier with @Riverpod(keepAlive: true)) that mirrors the ThemeNotifier pattern. Reschedule from the notifier on every settings change and from a ScheduledNotificationBootReceiver on reboot.


Standard Stack

Core

Library Version Purpose Why Standard
flutter_local_notifications ^21.0.0 Schedule and deliver local notifications on Android De facto standard; the only actively maintained Flutter local notification plugin
timezone ^0.9.4 TZ-aware TZDateTime for zonedSchedule Required by flutter_local_notifications; prevents DST drift
flutter_timezone ^1.0.8 Get device's local IANA timezone string Bridges device OS timezone to timezone package; flutter_native_timezone was archived, this is the maintained fork

Supporting

Library Version Purpose When to Use
permission_handler ^11.3.0 Check shouldShowRequestRationale for Android permanently-denied detection Needed to differentiate first-deny from permanent-deny on Android (built-in API doesn't expose this in Dart layer cleanly)

Note on permission_handler: flutter_local_notifications v21 exposes requestNotificationsPermission() on the Android implementation class directly — that covers the initial request. permission_handler is only needed to query shouldShowRationale for the permanently-denied detection path. Evaluate whether the complexity is worth it; if the UX for permanently-denied is simply "open app settings" via openAppSettings(), permission_handler can be replaced with AppSettings.openAppSettings() from app_settings or a direct openAppSettings() call from permission_handler.

Alternatives Considered

Instead of Could Use Tradeoff
flutter_local_notifications awesome_notifications awesome_notifications has richer features but heavier setup; flutter_local_notifications is simpler for a single daily notification
flutter_timezone device_timezone Both are maintained forks of flutter_native_timezone; flutter_timezone has more pub.dev likes and wider adoption

Installation:

flutter pub add flutter_local_notifications timezone flutter_timezone
# If using permission_handler for shouldShowRationale:
flutter pub add permission_handler

Architecture Patterns

lib/
├── core/
│   └── notifications/
│       ├── notification_service.dart        # FlutterLocalNotificationsPlugin wrapper
│       └── notification_settings_notifier.dart  # AsyncNotifier (keepAlive: true)
├── features/
│   └── settings/
│       └── presentation/
│           └── settings_screen.dart         # Modified — add Benachrichtigungen section
└── l10n/
    └── app_de.arb                           # Modified — add 810 notification strings
android/
└── app/
    ├── build.gradle.kts                     # Modified — desugaring + compileSdk 35
    └── src/main/
        └── AndroidManifest.xml              # Modified — permissions + receivers

Pattern 1: NotificationService (plain Dart class)

What: A plain class (not a Riverpod provider) wrapping FlutterLocalNotificationsPlugin. Initialized once at app startup. Exposes initialize(), scheduleDailyNotification(TimeOfDay time, String title, String body), cancelAll(). When to use: Notification scheduling is a side effect, not reactive state. Keep it outside Riverpod to avoid lifecycle issues with background callbacks. Example:

// lib/core/notifications/notification_service.dart
// Source: flutter_local_notifications pub.dev documentation
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:flutter/material.dart' show TimeOfDay;

class NotificationService {
  static final NotificationService _instance = NotificationService._internal();
  factory NotificationService() => _instance;
  NotificationService._internal();

  final _plugin = FlutterLocalNotificationsPlugin();

  Future<void> initialize() async {
    const android = AndroidInitializationSettings('@mipmap/ic_launcher');
    const settings = InitializationSettings(android: android);
    await _plugin.initialize(
      settings,
      onDidReceiveNotificationResponse: _onTap,
    );
  }

  Future<bool> requestPermission() async {
    final android = _plugin
        .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
    return await android?.requestNotificationsPermission() ?? false;
  }

  Future<void> scheduleDailyNotification({
    required TimeOfDay time,
    required String title,
    required String body,
  }) async {
    await _plugin.cancelAll();
    final scheduledDate = _nextInstanceOf(time);
    const details = NotificationDetails(
      android: AndroidNotificationDetails(
        'daily_summary',
        'Tägliche Zusammenfassung',
        channelDescription: 'Tägliche Aufgaben-Erinnerung',
        importance: Importance.defaultImportance,
        priority: Priority.defaultPriority,
      ),
    );
    await _plugin.zonedSchedule(
      0,
      title: title,
      body: body,
      scheduledDate: scheduledDate,
      details,
      androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
      matchDateTimeComponents: DateTimeComponents.time,
    );
  }

  Future<void> cancelAll() => _plugin.cancelAll();

  tz.TZDateTime _nextInstanceOf(TimeOfDay time) {
    final now = tz.TZDateTime.now(tz.local);
    var scheduled = tz.TZDateTime(
      tz.local, now.year, now.month, now.day, time.hour, time.minute,
    );
    if (scheduled.isBefore(now)) {
      scheduled = scheduled.add(const Duration(days: 1));
    }
    return scheduled;
  }

  static void _onTap(NotificationResponse response) {
    // Navigation to Home tab — handled via global navigator key or go_router
  }
}

Pattern 2: NotificationSettingsNotifier (AsyncNotifier, keepAlive)

What: An AsyncNotifier with @Riverpod(keepAlive: true) that persists notificationsEnabled + notificationHour + notificationMinute in SharedPreferences. Mirrors ThemeNotifier pattern exactly. When to use: Settings state that must survive widget disposal. keepAlive: true prevents destruction on tab switch. Example:

// lib/core/notifications/notification_settings_notifier.dart
// Source: ThemeNotifier pattern in lib/core/theme/theme_provider.dart
import 'package:flutter/material.dart' show TimeOfDay;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';

part 'notification_settings_notifier.g.dart';

class NotificationSettings {
  final bool enabled;
  final TimeOfDay time;
  const NotificationSettings({required this.enabled, required this.time});
}

@Riverpod(keepAlive: true)
class NotificationSettingsNotifier extends _$NotificationSettingsNotifier {
  static const _enabledKey = 'notifications_enabled';
  static const _hourKey = 'notifications_hour';
  static const _minuteKey = 'notifications_minute';

  @override
  NotificationSettings build() {
    _load();
    return const NotificationSettings(
      enabled: false,
      time: TimeOfDay(hour: 7, minute: 0),
    );
  }

  Future<void> _load() async {
    final prefs = await SharedPreferences.getInstance();
    final enabled = prefs.getBool(_enabledKey) ?? false;
    final hour = prefs.getInt(_hourKey) ?? 7;
    final minute = prefs.getInt(_minuteKey) ?? 0;
    state = NotificationSettings(enabled: enabled, time: TimeOfDay(hour: hour, minute: minute));
  }

  Future<void> setEnabled(bool enabled) async {
    state = NotificationSettings(enabled: enabled, time: state.time);
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool(_enabledKey, enabled);
    // Caller reschedules or cancels via NotificationService
  }

  Future<void> setTime(TimeOfDay time) async {
    state = NotificationSettings(enabled: state.enabled, time: time);
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt(_hourKey, time.hour);
    await prefs.setInt(_minuteKey, time.minute);
    // Caller reschedules via NotificationService
  }
}

Pattern 3: Task Count Query (one-shot, not stream)

What: Notification fires at a system alarm — Flutter is not running. At notification time, the notification body was already computed at schedule time. The pattern is: when user enables or changes the time, compute the body immediately (for today's count) and reschedule. The daily content is "today's overdue + today's count" computed at the time of scheduling.

Alternative: Schedule a fixed title/body like "Schau nach, was heute ansteht" and let the tap open the app. This avoids the complexity of dynamic content that may be stale. This is the recommended approach since computing counts at scheduling time means the 07:00 count reflects yesterday's data if scheduled at 22:00.

Recommended approach: Schedule with a generic body ("Schau rein, was heute ansteht") or schedule at device startup via boot receiver with a fresh count query. Given CONTEXT.md requires the count in the body, the most practical implementation is to compute it at schedule time during boot receiver execution and when the user enables the notification.

Example — one-shot Drift query (no stream needed):

// Add to DailyPlanDao
Future<int> getTodayAndOverdueTaskCount({DateTime? today}) async {
  final now = today ?? DateTime.now();
  final todayDate = DateTime(now.year, now.month, now.day);
  final result = await (selectOnly(tasks)
    ..addColumns([tasks.id.count()])
    ..where(tasks.nextDueDate.isSmallerOrEqualValue(
        todayDate.add(const Duration(days: 1))))
  ).getSingle();
  return result.read(tasks.id.count()) ?? 0;
}

Pattern 4: Settings Screen — Progressive Disclosure

What: Add a "Benachrichtigungen" section between existing sections using AnimatedSize or Visibility for the time picker row. When to use: Toggle is OFF → time picker row is hidden. Toggle is ON → time picker row animates in. Example:

// In SettingsScreen.build(), between Darstellung and Über sections:
const Divider(indent: 16, endIndent: 16, height: 32),

Padding(
  padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
  child: Text(
    l10n.settingsSectionNotifications,
    style: theme.textTheme.titleMedium?.copyWith(
      color: theme.colorScheme.primary,
    ),
  ),
),
SwitchListTile(
  title: Text(l10n.notificationsEnabledLabel),
  value: settings.enabled,
  onChanged: (value) => _onToggle(ref, context, value),
),
AnimatedSize(
  duration: const Duration(milliseconds: 200),
  child: settings.enabled
    ? ListTile(
        title: Text(l10n.notificationsTimeLabel),
        trailing: Text(settings.time.format(context)),
        onTap: () => _pickTime(ref, context, settings.time),
      )
    : const SizedBox.shrink(),
),

const Divider(indent: 16, endIndent: 16, height: 32),

Anti-Patterns to Avoid

  • Scheduling with DateTime instead of TZDateTime: Notifications will drift during daylight saving time transitions. Always use tz.TZDateTime from the timezone package.
  • Using AndroidScheduleMode.exact without checking permission: exactAllowWhileIdle requires SCHEDULE_EXACT_ALARM or USE_EXACT_ALARM permission on Android 12+. For a daily morning notification, inexactAllowWhileIdle (±15 minutes) is sufficient and requires no extra permission.
  • Relying on isPermanentlyDenied on Android: This property works correctly only on iOS. On Android, check shouldShowRationale instead (if it returns false after a denial, the user has selected "Never ask again").
  • Not calling cancelAll() before rescheduling: If the user changes the notification time, failing to cancel the old scheduled notification results in duplicate fires.
  • Stream provider for notification task count: Streams stay open unnecessarily. Use a one-shot Future query (getSingle() / get()) to count tasks when scheduling.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Local notification scheduling Custom AlarmManager bridge flutter_local_notifications Plugin handles exact alarms, boot rescheduling, channel creation, Android version compat
Timezone-aware scheduling Manual UTC offset arithmetic timezone + flutter_timezone IANA database covers DST transitions; manual offsets fail on DST change days
Permission UI on Android Custom permission dialog flow flutter_local_notifications requestNotificationsPermission() Plugin wraps ActivityCompat.requestPermissions correctly
Time picker dialog Custom time input widget Flutter showTimePicker() Material 3 standard, handles locale, accessibility, theme automatically
Persistent settings Custom file storage SharedPreferences (already in project) Pattern already established by ThemeNotifier

Key insight: The hard problems in Android notifications (exact alarm permissions, boot completion rescheduling, channel compatibility, notification action intents) are all solved by flutter_local_notifications. Any attempt to implement these at a lower level would duplicate thousands of lines of tested Java/Kotlin code.


Common Pitfalls

Pitfall 1: ScheduledNotificationBootReceiver Not Firing on Android 12+

What goes wrong: After device reboot, scheduled notifications are not restored. The boot receiver is never invoked. Why it happens: Android 12 introduced stricter component exporting rules. Receivers with <intent-filter> for system broadcasts must be android:exported="true", but the official plugin docs (and the plugin's own merged manifest) may declare exported="false". How to avoid: Explicitly override in AndroidManifest.xml with android:exported="true" on ScheduledNotificationBootReceiver. The override in your app's manifest takes precedence over the plugin's merged manifest entry. Warning signs: Boot test passes on Android 11 emulator but fails on Android 12+ physical device.

Pitfall 2: Core Library Desugaring Not Enabled

What goes wrong: Build fails with: Dependency ':flutter_local_notifications' requires core library desugaring to be enabled for :app Why it happens: flutter_local_notifications v10+ uses Java 8 java.time APIs that require desugaring for older Android versions. Flutter does not enable this by default. How to avoid: Add to android/app/build.gradle.kts:

android {
    compileOptions {
        isCoreLibraryDesugaringEnabled = true
    }
}
dependencies {
    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

Warning signs: Clean build works on first flutter pub get but fails on first full build.

Pitfall 3: compileSdk Must Be 35+

What goes wrong: Build fails or plugin features are unavailable. Why it happens: flutter_local_notifications v21 bumped compileSdk requirement to 35 (Android 15). How to avoid: In android/app/build.gradle.kts, change compileSdk = flutter.compileSdkVersion to compileSdk = 35 (or higher). The current project uses flutter.compileSdkVersion which may be lower. Warning signs: Gradle sync error mentioning minimum SDK version.

Pitfall 4: Timezone Not Initialized Before First Notification

What goes wrong: zonedSchedule throws or schedules at wrong time. Why it happens: tz.initializeTimeZones() must be called before any TZDateTime usage. tz.setLocalLocation() must be called with the device's actual timezone (obtained via FlutterTimezone.getLocalTimezone()). How to avoid: In main() before runApp(), call:

tz.initializeTimeZones();
final timeZoneName = await FlutterTimezone.getLocalTimezone();
tz.setLocalLocation(tz.getLocation(timeZoneName));

Warning signs: Notification fires at wrong time, or app crashes on first notification schedule attempt.

Pitfall 5: Permission Toggle Reverts — Race Condition

What goes wrong: User taps toggle ON, permission dialog appears, user grants, but toggle is already back to OFF because the async permission check resolved late. Why it happens: If the toggle updates optimistically before the permission result returns, the revert logic can fire incorrectly. How to avoid: Only update enabled = true in the notifier AFTER permission is confirmed granted. Keep toggle at current state during the permission dialog. Warning signs: User grants permission but has to tap toggle a second time.

Pitfall 6: Notification Body Stale on Zero-Task Days

What goes wrong: Notification body says "3 Aufgaben fällig" but there are actually 0 tasks (all were completed yesterday). Why it happens: The notification body is computed at schedule time, but the alarm fires 24 hours later when the task list may have changed. How to avoid (CONTEXT.md decision): The skip-on-zero-tasks requirement means the notification service must check the count at boot time and reschedule dynamically. One clean approach: use a generic body at schedule time ("Schau nach, was heute ansteht"), and only show the specific count in a "just-in-time" approach — or accept that the count reflects the state at last schedule time. Discuss with project owner which trade-off is acceptable. Given the CONTEXT.md requirement for a count in the body, the recommended approach is to always reschedule at midnight or app open with fresh count. Warning signs: Users report inaccurate task counts in notifications.


Code Examples

Verified patterns from official sources:

AndroidManifest.xml — Complete Addition

<!-- Source: flutter_local_notifications pub.dev documentation + Android 12+ fix -->
<manifest ...>
    <!-- Permissions (inside <manifest>, outside <application>) -->
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>

    <application ...>
        <!-- Notification receivers (inside <application>) -->
        <receiver
            android:exported="false"
            android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
        <!-- exported="true" required for Android 12+ boot rescheduling -->
        <receiver
            android:exported="true"
            android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED"/>
                <action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
                <action android:name="android.intent.action.QUICKBOOT_POWERON" />
                <action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
            </intent-filter>
        </receiver>
    </application>
</manifest>

build.gradle.kts — Required Changes

// Source: flutter_local_notifications pub.dev documentation
android {
    compileSdk = 35  // Explicit minimum; override flutter.compileSdkVersion if < 35

    compileOptions {
        isCoreLibraryDesugaringEnabled = true
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    kotlinOptions {
        jvmTarget = JavaVersion.VERSION_17.toString()
    }
}

dependencies {
    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

main.dart — Timezone and Notification Initialization

// Source: flutter_timezone and timezone pub.dev documentation
import 'package:timezone/data/latest_all.dart' as tz;
import 'package:timezone/timezone.dart' as tz;
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:household_keeper/core/notifications/notification_service.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // Timezone initialization (required before any zonedSchedule)
  tz.initializeTimeZones();
  final timeZoneName = await FlutterTimezone.getLocalTimezone();
  tz.setLocalLocation(tz.getLocation(timeZoneName));
  // Notification plugin initialization
  await NotificationService().initialize();
  runApp(const ProviderScope(child: App()));
}

Daily Notification Scheduling (v21 named parameters)

// Source: flutter_local_notifications pub.dev documentation v21
await plugin.zonedSchedule(
  0,
  title: 'Dein Tagesplan',
  body: '5 Aufgaben fällig',
  scheduledDate: _nextInstanceOf(const TimeOfDay(hour: 7, minute: 0)),
  const NotificationDetails(
    android: AndroidNotificationDetails(
      'daily_summary',
      'Tägliche Zusammenfassung',
      channelDescription: 'Tägliche Aufgaben-Erinnerung',
      importance: Importance.defaultImportance,
      priority: Priority.defaultPriority,
    ),
  ),
  androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
  matchDateTimeComponents: DateTimeComponents.time,  // Makes it repeat daily
);

Permission Request (Android 13+ / API 33+)

// Source: flutter_local_notifications pub.dev documentation
final androidPlugin = plugin
    .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
final granted = await androidPlugin?.requestNotificationsPermission() ?? false;

TimeOfDay Persistence in SharedPreferences

// Source: Flutter/Dart standard pattern
// Save
await prefs.setInt('notifications_hour', time.hour);
await prefs.setInt('notifications_minute', time.minute);
// Load
final hour = prefs.getInt('notifications_hour') ?? 7;
final minute = prefs.getInt('notifications_minute') ?? 0;
final time = TimeOfDay(hour: hour, minute: minute);

showTimePicker Call Pattern (Material 3)

// Source: Flutter Material documentation
final picked = await showTimePicker(
  context: context,
  initialTime: currentTime,
  initialEntryMode: TimePickerEntryMode.dial,
);
if (picked != null) {
  await ref.read(notificationSettingsNotifierProvider.notifier).setTime(picked);
  // Reschedule notification with new time
}

State of the Art

Old Approach Current Approach When Changed Impact
showDailyAtTime() zonedSchedule() with matchDateTimeComponents: DateTimeComponents.time flutter_local_notifications v2.0 Old method removed; new approach required for DST correctness
Positional params in zonedSchedule Named params (title:, body:, scheduledDate:) flutter_local_notifications v20.0 Breaking change — all call sites must use named params
flutter_native_timezone flutter_timezone 2023 (original archived) Direct replacement; same API
SCHEDULE_EXACT_ALARM for daily summaries AndroidScheduleMode.inexactAllowWhileIdle Android 12/14 permission changes Exact alarms require user-granted permission; inexact is sufficient for morning summaries
java.util.Date alarm scheduling Core library desugaring + java.time flutter_local_notifications v10 Requires isCoreLibraryDesugaringEnabled = true in build.gradle.kts

Deprecated/outdated:

  • showDailyAtTime() / showWeeklyAtDayAndTime(): Removed in flutter_local_notifications v2.0. Replaced by zonedSchedule with matchDateTimeComponents.
  • scheduledNotificationRepeatFrequency parameter: Removed, replaced by matchDateTimeComponents.
  • flutter_native_timezone: Archived/unmaintained. Use flutter_timezone instead.

Open Questions

  1. Notification body: static vs dynamic content

    • What we know: CONTEXT.md requires "5 Aufgaben fällig (2 überfällig)" format in the body
    • What's unclear: flutter_local_notifications zonedSchedule fixes the body at schedule time. The count computed at 07:00 yesterday reflects yesterday's completion state. Dynamic content requires either: (a) schedule with generic body + rely on tap to open app for current state; (b) reschedule nightly at midnight after task state changes; (c) accept potential stale count.
    • Recommendation: Reschedule the notification whenever the user completes a task (from the home screen provider), and always at app startup. This keeps the count reasonably fresh. Document the trade-off in the plan.
  2. compileSdk override and flutter.compileSdkVersion

    • What we know: Current build.gradle.kts uses compileSdk = flutter.compileSdkVersion. Flutter 3.41 sets this to 35. flutter_local_notifications v21 requires minimum 35.
    • What's unclear: Whether flutter.compileSdkVersion resolves to 35 in this Flutter version.
    • Recommendation: Run flutter build apk --debug with the new dependency to confirm. If the build fails, explicitly set compileSdk = 35.
  3. Navigation on notification tap (go_router integration)

    • What we know: The onDidReceiveNotificationResponse callback fires when the user taps the notification. The app must navigate to the Home tab.
    • What's unclear: How to access go_router from a static callback without a BuildContext.
    • Recommendation: Use a global GoRouter instance stored in a top-level variable, or use a GlobalKey<NavigatorState>. The plan should include a wave for navigation wiring.

Validation Architecture

Test Framework

Property Value
Framework flutter_test (built-in)
Config file none — uses flutter test runner
Quick run command flutter test test/core/notifications/ -x
Full suite command flutter test

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
NOTF-01 Daily notification scheduled at configured time with correct body unit flutter test test/core/notifications/notification_service_test.dart -x Wave 0
NOTF-01 Zero-task day skips notification unit flutter test test/core/notifications/notification_service_test.dart -x Wave 0
NOTF-01 Notification rescheduled after settings change unit flutter test test/core/notifications/notification_settings_notifier_test.dart -x Wave 0
NOTF-02 Toggle enables/disables notification unit flutter test test/core/notifications/notification_settings_notifier_test.dart -x Wave 0
NOTF-02 Time persisted across restarts (SharedPreferences) unit flutter test test/core/notifications/notification_settings_notifier_test.dart -x Wave 0
NOTF-01 Boot receiver manifest entry present (exported=true) manual Manual inspection of AndroidManifest.xml N/A
NOTF-01 POST_NOTIFICATIONS permission requested on toggle-on manual Run on Android 13+ device/emulator N/A

Note: flutter_local_notifications dispatches to native Android — actual notification delivery cannot be unit tested. Tests should use a mock/fake FlutterLocalNotificationsPlugin to verify that the service calls the right methods with the right arguments.

Sampling Rate

  • Per task commit: flutter test test/core/notifications/ -x
  • Per wave merge: flutter test
  • Phase gate: Full suite green + dart analyze --fatal-infos before /gsd:verify-work

Wave 0 Gaps

  • test/core/notifications/notification_service_test.dart — covers NOTF-01 scheduling logic
  • test/core/notifications/notification_settings_notifier_test.dart — covers NOTF-02 persistence
  • lib/core/notifications/notification_service.dart — service stub for testing
  • android/app/build.gradle.kts — add desugaring dependency
  • android/app/src/main/AndroidManifest.xml — permissions + receivers
  • Framework install: flutter pub add flutter_local_notifications timezone flutter_timezone

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • WebSearch results on notification body stale-count trade-off — design decision not formally documented, community-derived recommendation

Metadata

Confidence breakdown:

  • Standard stack: HIGH — pub.dev official pages verified, versions confirmed current as of 2026-03-16
  • Architecture patterns: HIGH — based on existing project patterns (ThemeNotifier) + official plugin API
  • Pitfalls: HIGH for desugaring/compileSdk/boot-receiver (confirmed by official changelog + GitHub issues); MEDIUM for stale body content (design trade-off, not a bug)
  • Android manifest: HIGH — official docs + confirmed Android 12+ workaround

Research date: 2026-03-16 Valid until: 2026-06-16 (flutter_local_notifications moves fast; verify version before starting)