# 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:**
```bash
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:**
```dart
// 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:**
```dart
// 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):**
```dart
// 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:**
```dart
// 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`:
```kotlin
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:
```dart
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
```xml
<!-- 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
```kotlin
// 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
```dart
// 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)
```dart
// 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+)
```dart
// Source: flutter_local_notifications pub.dev documentation
final androidPlugin = plugin
    .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
final granted = await androidPlugin?.requestNotificationsPermission() ?? false;
```

### TimeOfDay Persistence in SharedPreferences
```dart
// 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)
```dart
// 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)
- [flutter_local_notifications pub.dev](https://pub.dev/packages/flutter_local_notifications) — version, API, manifest requirements, initialization patterns
- [flutter_local_notifications changelog](https://pub.dev/packages/flutter_local_notifications/changelog) — v19/20/21 breaking changes, named params migration
- [timezone pub.dev](https://pub.dev/packages/timezone) — TZDateTime usage, initializeTimeZones
- [flutter_timezone pub.dev](https://pub.dev/packages/flutter_timezone) — getLocalTimezone API
- [Flutter showTimePicker API](https://api.flutter.dev/flutter/material/showTimePicker.html) — TimeOfDay return type, usage
- [Android Notification Permission docs](https://developer.android.com/develop/ui/views/notifications/notification-permission) — POST_NOTIFICATIONS runtime permission behavior

### Secondary (MEDIUM confidence)
- [GitHub Issue #2612](https://github.com/MaikuB/flutter_local_notifications/issues/2612) — Android 12+ boot receiver exported=true fix (confirmed by multiple affected developers)
- [flutter_local_notifications desugaring issues](https://github.com/MaikuB/flutter_local_notifications/issues/2286) — isCoreLibraryDesugaringEnabled requirement confirmed
- [build.gradle.kts desugaring guide](https://medium.com/@janviflutterwork/%EF%B8%8F-fixing-core-library-desugaring-error-in-flutter-when-using-flutter-local-notifications-c15ba5f69394) — 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)
