34 KiB
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
Recommended Project Structure
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 8–10 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
DateTimeinstead ofTZDateTime: Notifications will drift during daylight saving time transitions. Always usetz.TZDateTimefrom thetimezonepackage. - Using
AndroidScheduleMode.exactwithout checking permission:exactAllowWhileIdlerequiresSCHEDULE_EXACT_ALARMorUSE_EXACT_ALARMpermission on Android 12+. For a daily morning notification,inexactAllowWhileIdle(±15 minutes) is sufficient and requires no extra permission. - Relying on
isPermanentlyDeniedon Android: This property works correctly only on iOS. On Android, checkshouldShowRationaleinstead (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
Futurequery (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 byzonedSchedulewithmatchDateTimeComponents.scheduledNotificationRepeatFrequencyparameter: Removed, replaced bymatchDateTimeComponents.flutter_native_timezone: Archived/unmaintained. Useflutter_timezoneinstead.
Open Questions
-
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_notificationszonedSchedulefixes 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.
-
compileSdk override and flutter.compileSdkVersion
- What we know: Current
build.gradle.ktsusescompileSdk = flutter.compileSdkVersion. Flutter 3.41 sets this to 35. flutter_local_notifications v21 requires minimum 35. - What's unclear: Whether
flutter.compileSdkVersionresolves to 35 in this Flutter version. - Recommendation: Run
flutter build apk --debugwith the new dependency to confirm. If the build fails, explicitly setcompileSdk = 35.
- What we know: Current
-
Navigation on notification tap (go_router integration)
- What we know: The
onDidReceiveNotificationResponsecallback 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
GoRouterinstance stored in a top-level variable, or use aGlobalKey<NavigatorState>. The plan should include a wave for navigation wiring.
- What we know: The
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-infosbefore/gsd:verify-work
Wave 0 Gaps
test/core/notifications/notification_service_test.dart— covers NOTF-01 scheduling logictest/core/notifications/notification_settings_notifier_test.dart— covers NOTF-02 persistencelib/core/notifications/notification_service.dart— service stub for testingandroid/app/build.gradle.kts— add desugaring dependencyandroid/app/src/main/AndroidManifest.xml— permissions + receivers- Framework install:
flutter pub add flutter_local_notifications timezone flutter_timezone
Sources
Primary (HIGH confidence)
- flutter_local_notifications pub.dev — version, API, manifest requirements, initialization patterns
- flutter_local_notifications changelog — v19/20/21 breaking changes, named params migration
- timezone pub.dev — TZDateTime usage, initializeTimeZones
- flutter_timezone pub.dev — getLocalTimezone API
- Flutter showTimePicker API — TimeOfDay return type, usage
- Android Notification Permission docs — POST_NOTIFICATIONS runtime permission behavior
Secondary (MEDIUM confidence)
- GitHub Issue #2612 — Android 12+ boot receiver exported=true fix (confirmed by multiple affected developers)
- flutter_local_notifications desugaring issues — isCoreLibraryDesugaringEnabled requirement confirmed
- build.gradle.kts desugaring guide — Kotlin DSL syntax
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)