chore: archive v1.0 phase directories to milestones/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 20:12:01 +01:00
parent 1a1a10c9ea
commit 8c72403c85
42 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,339 @@
---
phase: 04-notifications
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- pubspec.yaml
- android/app/build.gradle.kts
- android/app/src/main/AndroidManifest.xml
- lib/main.dart
- lib/core/notifications/notification_service.dart
- lib/core/notifications/notification_settings_notifier.dart
- lib/core/notifications/notification_settings_notifier.g.dart
- lib/features/home/data/daily_plan_dao.dart
- lib/features/home/data/daily_plan_dao.g.dart
- lib/l10n/app_de.arb
- test/core/notifications/notification_service_test.dart
- test/core/notifications/notification_settings_notifier_test.dart
autonomous: true
requirements:
- NOTF-01
- NOTF-02
must_haves:
truths:
- "NotificationService can schedule a daily notification at a given TimeOfDay"
- "NotificationService can cancel all scheduled notifications"
- "NotificationService can request POST_NOTIFICATIONS permission"
- "NotificationSettingsNotifier persists enabled boolean and TimeOfDay to SharedPreferences"
- "NotificationSettingsNotifier loads persisted values on build"
- "DailyPlanDao can return a one-shot count of overdue + today tasks"
- "Timezone is initialized before any notification scheduling"
- "Android build compiles with core library desugaring enabled"
- "AndroidManifest has POST_NOTIFICATIONS permission, RECEIVE_BOOT_COMPLETED permission, and boot receiver"
artifacts:
- path: "lib/core/notifications/notification_service.dart"
provides: "Singleton wrapper around FlutterLocalNotificationsPlugin"
exports: ["NotificationService"]
- path: "lib/core/notifications/notification_settings_notifier.dart"
provides: "Riverpod notifier for notification enabled + time"
exports: ["NotificationSettings", "NotificationSettingsNotifier"]
- path: "test/core/notifications/notification_service_test.dart"
provides: "Unit tests for scheduling, cancel, permission"
- path: "test/core/notifications/notification_settings_notifier_test.dart"
provides: "Unit tests for persistence and state management"
key_links:
- from: "lib/core/notifications/notification_service.dart"
to: "flutter_local_notifications"
via: "FlutterLocalNotificationsPlugin"
pattern: "FlutterLocalNotificationsPlugin"
- from: "lib/core/notifications/notification_settings_notifier.dart"
to: "shared_preferences"
via: "SharedPreferences persistence"
pattern: "SharedPreferences\\.getInstance"
- from: "lib/main.dart"
to: "lib/core/notifications/notification_service.dart"
via: "timezone init + service initialize"
pattern: "NotificationService.*initialize"
---
<objective>
Install notification packages, configure Android build system, create the NotificationService singleton and NotificationSettingsNotifier provider, add one-shot DAO query for task counts, initialize timezone in main.dart, add ARB strings, and write unit tests.
Purpose: Establish the complete notification infrastructure so Plan 02 can wire it into the Settings UI.
Output: Working notification service and settings notifier with full test coverage. Android build configuration complete.
</objective>
<execution_context>
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
@/home/jlmak/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-notifications/04-CONTEXT.md
@.planning/phases/04-notifications/04-RESEARCH.md
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
From lib/core/theme/theme_provider.dart (pattern to follow for notifier):
```dart
@riverpod
class ThemeNotifier extends _$ThemeNotifier {
@override
ThemeMode build() {
_loadPersistedThemeMode();
return ThemeMode.system; // sync default, then async load overrides
}
Future<void> setThemeMode(ThemeMode mode) async { ... }
}
```
From lib/features/home/data/daily_plan_dao.dart (DAO to extend):
```dart
@DriftAccessor(tables: [Tasks, Rooms, TaskCompletions])
class DailyPlanDao extends DatabaseAccessor<AppDatabase> with _$DailyPlanDaoMixin {
DailyPlanDao(super.attachedDatabase);
Stream<List<TaskWithRoom>> watchAllTasksWithRoomName() { ... }
Stream<int> watchCompletionsToday({DateTime? today}) { ... }
}
```
From lib/main.dart (entry point to modify):
```dart
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const ProviderScope(child: App()));
}
```
From lib/core/router/router.dart (top-level GoRouter for notification tap):
```dart
final router = GoRouter(
initialLocation: '/',
routes: [ StatefulShellRoute.indexedStack(...) ],
);
```
From lib/l10n/app_de.arb (localization file, 92 existing keys):
Last key: "dailyPlanNoTasks": "Noch keine Aufgaben angelegt"
From android/app/build.gradle.kts:
```kotlin
android {
compileSdk = flutter.compileSdkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
```
From android/app/src/main/AndroidManifest.xml:
No notification-related entries exist yet. Only standard Flutter activity + meta-data.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Android config, packages, NotificationService, timezone init, DAO query, ARB strings</name>
<files>
pubspec.yaml,
android/app/build.gradle.kts,
android/app/src/main/AndroidManifest.xml,
lib/main.dart,
lib/core/notifications/notification_service.dart,
lib/features/home/data/daily_plan_dao.dart,
lib/features/home/data/daily_plan_dao.g.dart,
lib/l10n/app_de.arb
</files>
<action>
1. **Add packages** to pubspec.yaml dependencies:
- `flutter_local_notifications: ^21.0.0`
- `timezone: ^0.9.4`
- `flutter_timezone: ^1.0.8`
Run `flutter pub get`.
2. **Configure Android build** in `android/app/build.gradle.kts`:
- Set `compileSdk = 35` (explicit, replacing `flutter.compileSdkVersion`)
- Add `isCoreLibraryDesugaringEnabled = true` inside `compileOptions`
- Add `coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")` in `dependencies` block
3. **Configure AndroidManifest.xml** — add inside `<manifest>` (outside `<application>`):
- `<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>`
- `<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>`
Add inside `<application>`:
- `ScheduledNotificationReceiver` with `android:exported="false"`
- `ScheduledNotificationBootReceiver` with `android:exported="true"` and intent-filter for BOOT_COMPLETED, MY_PACKAGE_REPLACED, QUICKBOOT_POWERON, HTC QUICKBOOT_POWERON
Use the exact XML from RESEARCH.md Pattern section.
4. **Create NotificationService** at `lib/core/notifications/notification_service.dart`:
- Singleton pattern (factory constructor + static `_instance`)
- `final _plugin = FlutterLocalNotificationsPlugin();`
- `Future<void> initialize()`: AndroidInitializationSettings with `@mipmap/ic_launcher`, call `_plugin.initialize(settings, onDidReceiveNotificationResponse: _onTap)`
- `Future<bool> requestPermission()`: resolve Android implementation, call `requestNotificationsPermission()`, return `granted ?? false`
- `Future<void> scheduleDailyNotification({required TimeOfDay time, required String title, required String body})`:
- Call `_plugin.cancelAll()` first
- Compute `_nextInstanceOf(time)` as TZDateTime
- AndroidNotificationDetails: channelId `'daily_summary'`, channelName `'Tagliche Zusammenfassung'`, channelDescription `'Tagliche Aufgaben-Erinnerung'`, importance default, priority default
- Call `_plugin.zonedSchedule(0, title: title, body: body, scheduledDate: scheduledDate, details, androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, matchDateTimeComponents: DateTimeComponents.time)`
- `Future<void> cancelAll()`: delegates to `_plugin.cancelAll()`
- `tz.TZDateTime _nextInstanceOf(TimeOfDay time)`: compute next occurrence (today if in future, tomorrow otherwise)
- `static void _onTap(NotificationResponse response)`: no-op for now (Plan 02 wires navigation)
5. **Add one-shot DAO query** to `lib/features/home/data/daily_plan_dao.dart`:
```dart
/// One-shot count of overdue + today tasks (for notification body).
Future<int> getOverdueAndTodayTaskCount({DateTime? today}) async {
final now = today ?? DateTime.now();
final endOfToday = DateTime(now.year, now.month, now.day + 1);
final result = await (selectOnly(tasks)
..addColumns([tasks.id.count()])
..where(tasks.nextDueDate.isSmallerThanValue(endOfToday)))
.getSingle();
return result.read(tasks.id.count()) ?? 0;
}
/// One-shot count of overdue tasks only (for notification body split).
Future<int> getOverdueTaskCount({DateTime? today}) async {
final now = today ?? DateTime.now();
final startOfToday = DateTime(now.year, now.month, now.day);
final result = await (selectOnly(tasks)
..addColumns([tasks.id.count()])
..where(tasks.nextDueDate.isSmallerThanValue(startOfToday)))
.getSingle();
return result.read(tasks.id.count()) ?? 0;
}
```
Run `dart run build_runner build --delete-conflicting-outputs` to regenerate DAO.
6. **Initialize timezone in main.dart** — before `NotificationService().initialize()`:
```dart
import 'package:timezone/data/latest_all.dart' as tz;
import 'package:timezone/timezone.dart' as tz;
import 'package:flutter_timezone/flutter_timezone.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
tz.initializeTimeZones();
final timeZoneName = await FlutterTimezone.getLocalTimezone();
tz.setLocalLocation(tz.getLocation(timeZoneName));
await NotificationService().initialize();
runApp(const ProviderScope(child: App()));
}
```
7. **Add ARB strings** to `lib/l10n/app_de.arb`:
- `settingsSectionNotifications`: "Benachrichtigungen"
- `notificationsEnabledLabel`: "Tägliche Erinnerung"
- `notificationsTimeLabel`: "Uhrzeit"
- `notificationsPermissionDeniedHint`: "Benachrichtigungen sind in den Systemeinstellungen deaktiviert. Tippe hier, um sie zu aktivieren."
- `notificationTitle`: "Dein Tagesplan"
- `notificationBody`: "{count} Aufgaben fällig" with `@notificationBody` placeholder `count: int`
- `notificationBodyWithOverdue`: "{count} Aufgaben fällig ({overdue} überfällig)" with `@notificationBodyWithOverdue` placeholders `count: int, overdue: int`
8. Run `flutter pub get` and `flutter test` to confirm no regressions (expect 72 existing tests to pass).
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter test</automated>
</verify>
<done>
- flutter_local_notifications, timezone, flutter_timezone in pubspec.yaml
- build.gradle.kts has compileSdk=35, desugaring enabled, desugar_jdk_libs dependency
- AndroidManifest has POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED, both receivers
- NotificationService exists with initialize, requestPermission, scheduleDailyNotification, cancelAll
- DailyPlanDao has getOverdueAndTodayTaskCount and getOverdueTaskCount one-shot queries
- main.dart initializes timezone and notification service before runApp
- ARB file has 7 new notification-related keys
- All 72 existing tests still pass
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: NotificationSettingsNotifier and unit tests</name>
<files>
lib/core/notifications/notification_settings_notifier.dart,
lib/core/notifications/notification_settings_notifier.g.dart,
test/core/notifications/notification_settings_notifier_test.dart,
test/core/notifications/notification_service_test.dart
</files>
<behavior>
- Test: NotificationSettingsNotifier build() returns default state (enabled=false, time=07:00)
- Test: setEnabled(true) updates state.enabled to true and persists to SharedPreferences
- Test: setEnabled(false) updates state.enabled to false and persists to SharedPreferences
- Test: setTime(TimeOfDay(hour: 9, minute: 30)) updates state.time and persists hour+minute to SharedPreferences
- Test: After _load() with existing prefs (enabled=true, hour=8, minute=15), state reflects persisted values
- Test: NotificationService._nextInstanceOf returns today if time is in the future
- Test: NotificationService._nextInstanceOf returns tomorrow if time has passed
</behavior>
<action>
1. **Create NotificationSettingsNotifier** at `lib/core/notifications/notification_settings_notifier.dart`:
- Use `@Riverpod(keepAlive: true)` annotation (NOT plain `@riverpod`) to survive tab switches
- `class NotificationSettings { final bool enabled; final TimeOfDay time; const NotificationSettings({required this.enabled, required this.time}); }`
- `class NotificationSettingsNotifier extends _$NotificationSettingsNotifier`
- `build()` returns `NotificationSettings(enabled: false, time: TimeOfDay(hour: 7, minute: 0))` synchronously, then calls `_load()` which async reads SharedPreferences and updates state
- `Future<void> setEnabled(bool enabled)`: update state, persist `notifications_enabled` bool
- `Future<void> setTime(TimeOfDay time)`: update state, persist `notifications_hour` int and `notifications_minute` int
- Follow exact pattern from ThemeNotifier: sync return default, async load overrides state
- Add `part 'notification_settings_notifier.g.dart';`
2. Run `dart run build_runner build --delete-conflicting-outputs` to generate `.g.dart`.
3. **Write tests** at `test/core/notifications/notification_settings_notifier_test.dart`:
- Use `SharedPreferences.setMockInitialValues({})` for clean state
- Use `SharedPreferences.setMockInitialValues({'notifications_enabled': true, 'notifications_hour': 8, 'notifications_minute': 15})` for pre-existing state
- Create a `ProviderContainer` with the notifier, verify default state, call `setEnabled`/`setTime`, verify state updates and SharedPreferences values
4. **Write tests** at `test/core/notifications/notification_service_test.dart`:
- Test `_nextInstanceOf` logic by extracting it to a `@visibleForTesting` static method or by testing `scheduleDailyNotification` with a mock plugin
- Since `FlutterLocalNotificationsPlugin` dispatches to native and cannot be truly unit-tested, focus tests on:
a. `_nextInstanceOf` returns correct TZDateTime (make it a package-private or `@visibleForTesting` method)
b. Verify the service can be instantiated (singleton pattern)
- Initialize timezone in test setUp: `tz.initializeTimeZones(); tz.setLocalLocation(tz.getLocation('Europe/Berlin'));`
5. Run `flutter test test/core/notifications/` to confirm new tests pass.
6. Run `flutter test` to confirm all tests pass (72 existing + new).
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter test test/core/notifications/</automated>
</verify>
<done>
- NotificationSettingsNotifier with @Riverpod(keepAlive: true) created and generated
- NotificationSettings data class with enabled + time fields
- SharedPreferences persistence for enabled, hour, minute
- Unit tests for default state, setEnabled, setTime, persistence load
- Unit tests for _nextInstanceOf timezone logic
- All tests pass including existing 72
</done>
</task>
</tasks>
<verification>
- `flutter test` — all tests pass (72 existing + new notification tests)
- `dart analyze --fatal-infos` — no warnings or errors
- `grep -r "flutter_local_notifications" pubspec.yaml` — package present
- `grep -r "isCoreLibraryDesugaringEnabled" android/app/build.gradle.kts` — desugaring enabled
- `grep -r "POST_NOTIFICATIONS" android/app/src/main/AndroidManifest.xml` — permission present
- `grep -r "RECEIVE_BOOT_COMPLETED" android/app/src/main/AndroidManifest.xml` — permission present
- `grep -r "ScheduledNotificationBootReceiver" android/app/src/main/AndroidManifest.xml` — receiver present
</verification>
<success_criteria>
- NotificationService singleton with initialize, requestPermission, scheduleDailyNotification, cancelAll
- NotificationSettingsNotifier persists enabled + time to SharedPreferences
- DailyPlanDao has one-shot overdue+today count queries
- Android build configured for flutter_local_notifications v21
- Timezone initialized in main.dart
- All tests pass, dart analyze clean
</success_criteria>
<output>
After completion, create `.planning/phases/04-notifications/04-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,184 @@
---
phase: 04-notifications
plan: 01
subsystem: notifications
tags: [flutter_local_notifications, timezone, flutter_timezone, shared_preferences, riverpod, android, drift]
# Dependency graph
requires:
- phase: 03-daily-plan
provides: DailyPlanDao with tasks/rooms database access
- phase: 01-foundation
provides: SharedPreferences pattern via ThemeNotifier
provides:
- NotificationService singleton wrapping FlutterLocalNotificationsPlugin
- NotificationSettingsNotifier persisting enabled + TimeOfDay to SharedPreferences
- DailyPlanDao one-shot queries for overdue and today task counts
- Android build configured for flutter_local_notifications v21
- Timezone initialization in main.dart
- 7 notification ARB strings for German locale
affects: [04-02-settings-ui, future notification scheduling]
# Tech tracking
tech-stack:
added: [flutter_local_notifications ^21.0.0, timezone ^0.11.0, flutter_timezone ^1.0.8]
patterns: [singleton service for native plugin wrapper, @Riverpod(keepAlive) notifier with sync default + async load override]
key-files:
created:
- lib/core/notifications/notification_service.dart
- lib/core/notifications/notification_settings_notifier.dart
- lib/core/notifications/notification_settings_notifier.g.dart
- test/core/notifications/notification_service_test.dart
- test/core/notifications/notification_settings_notifier_test.dart
modified:
- pubspec.yaml
- android/app/build.gradle.kts
- android/app/src/main/AndroidManifest.xml
- lib/main.dart
- lib/features/home/data/daily_plan_dao.dart
- lib/features/home/data/daily_plan_dao.g.dart
- lib/l10n/app_de.arb
key-decisions:
- "timezone constraint upgraded to ^0.11.0 — flutter_local_notifications v21 requires ^0.11.0, plan specified ^0.9.4"
- "flutter_local_notifications v21 uses named parameters in initialize() and zonedSchedule() — updated from positional parameter style in RESEARCH.md examples"
- "Generated Riverpod 3 provider named notificationSettingsProvider (not notificationSettingsNotifierProvider) — consistent with existing themeProvider naming convention"
- "nextInstanceOf exposed as @visibleForTesting public method (not private _nextInstanceOf) to enable unit testing without mocking"
- "Test helper makeContainer() awaits Future.delayed(Duration.zero) to let initial async _load() complete before mutating state assertions"
patterns-established:
- "Plain Dart singleton for native plugin wrapper: NotificationService uses factory constructor + static _instance, initialized once at app startup outside Riverpod"
- "Sync default + async load pattern: @Riverpod(keepAlive: true) returns const default synchronously in build(), async _load() overrides state after SharedPreferences hydration"
- "TDD with async state: test helper function awaits initial async load before running mutation tests to avoid race conditions"
requirements-completed: [NOTF-01, NOTF-02]
# Metrics
duration: 9min
completed: 2026-03-16
---
# Phase 4 Plan 1: Notification Infrastructure Summary
**flutter_local_notifications v21 singleton service with TZ-aware scheduling, Riverpod keepAlive settings notifier persisting to SharedPreferences, Android desugaring config, and DailyPlanDao one-shot task count queries**
## Performance
- **Duration:** 9 min
- **Started:** 2026-03-16T13:48:28Z
- **Completed:** 2026-03-16T13:57:42Z
- **Tasks:** 2
- **Files modified:** 11
## Accomplishments
- Android build fully configured for flutter_local_notifications v21: compileSdk=35, core library desugaring enabled, permissions and receivers in AndroidManifest
- NotificationService singleton wrapping FlutterLocalNotificationsPlugin with initialize, requestPermission, scheduleDailyNotification, cancelAll, and @visibleForTesting nextInstanceOf
- NotificationSettingsNotifier with @Riverpod(keepAlive: true) persisting enabled/time to SharedPreferences, following ThemeNotifier pattern
- DailyPlanDao extended with getOverdueAndTodayTaskCount and getOverdueTaskCount one-shot Future queries
- Timezone initialization chain in main.dart: initializeTimeZones → getLocalTimezone → setLocalLocation → NotificationService.initialize
- 7 German ARB strings for notification UI and content
- 12 new unit tests (5 service, 7 notifier) plus all 72 existing tests passing (84 total)
## Task Commits
Each task was committed atomically:
1. **Task 1: Android config, packages, NotificationService, timezone init, DAO query, ARB strings** - `8787671` (feat)
2. **Task 2 RED: Failing tests for NotificationSettingsNotifier and NotificationService** - `0f6789b` (test)
3. **Task 2 GREEN: NotificationSettingsNotifier implementation + fixed tests** - `4f72eac` (feat)
**Plan metadata:** (docs commit — see final commit hash below)
_Note: TDD task 2 has separate test (RED) and implementation (GREEN) commits per TDD protocol_
## Files Created/Modified
- `lib/core/notifications/notification_service.dart` - Singleton wrapping FlutterLocalNotificationsPlugin; scheduleDailyNotification uses zonedSchedule with TZDateTime
- `lib/core/notifications/notification_settings_notifier.dart` - @Riverpod(keepAlive: true) notifier; NotificationSettings data class with enabled + time
- `lib/core/notifications/notification_settings_notifier.g.dart` - Riverpod code gen; provider named notificationSettingsProvider
- `test/core/notifications/notification_service_test.dart` - Unit tests for singleton pattern and nextInstanceOf TZ logic
- `test/core/notifications/notification_settings_notifier_test.dart` - Unit tests for default state, setEnabled, setTime, and persistence loading
- `pubspec.yaml` - Added flutter_local_notifications ^21.0.0, timezone ^0.11.0, flutter_timezone ^1.0.8
- `android/app/build.gradle.kts` - compileSdk=35, isCoreLibraryDesugaringEnabled=true, desugar_jdk_libs:2.1.4 dependency
- `android/app/src/main/AndroidManifest.xml` - POST_NOTIFICATIONS + RECEIVE_BOOT_COMPLETED permissions, ScheduledNotificationReceiver + ScheduledNotificationBootReceiver
- `lib/main.dart` - async main with timezone init chain and NotificationService.initialize()
- `lib/features/home/data/daily_plan_dao.dart` - Added getOverdueAndTodayTaskCount and getOverdueTaskCount one-shot queries
- `lib/l10n/app_de.arb` - 7 new keys: settingsSectionNotifications, notificationsEnabledLabel, notificationsTimeLabel, notificationsPermissionDeniedHint, notificationTitle, notificationBody, notificationBodyWithOverdue
## Decisions Made
- **timezone version upgraded to ^0.11.0**: Plan specified ^0.9.4, but flutter_local_notifications v21 requires ^0.11.0. Auto-fixed (Rule 3 — blocking).
- **v21 named parameter API**: RESEARCH.md examples used old positional parameter style. v21 uses `settings:`, `id:`, `scheduledDate:`, `notificationDetails:` named params. Fixed to match actual API.
- **Riverpod 3 naming convention**: Generated provider is `notificationSettingsProvider` not `notificationSettingsNotifierProvider`, consistent with existing `themeProvider` decision from Phase 1.
- **nextInstanceOf public @visibleForTesting**: Made public with annotation instead of private `_nextInstanceOf` to enable unit testing without native dispatch mocking.
- **makeContainer() async helper**: Test helper awaits `Future.delayed(Duration.zero)` after first read to let the async `_load()` from `build()` complete before mutation tests run, preventing race conditions.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] timezone package version constraint incompatible**
- **Found during:** Task 1 (flutter pub get)
- **Issue:** Plan specified `timezone: ^0.9.4` but flutter_local_notifications v21 depends on `timezone: ^0.11.0` — pub solve failed immediately
- **Fix:** Updated constraint to `^0.11.0` in pubspec.yaml
- **Files modified:** pubspec.yaml
- **Verification:** `flutter pub get` resolved successfully
- **Committed in:** 8787671
**2. [Rule 1 - Bug] flutter_local_notifications v21 uses named parameters**
- **Found during:** Task 2 (first test run against NotificationService)
- **Issue:** RESEARCH.md pattern and plan used positional parameters for `_plugin.initialize()` and `_plugin.zonedSchedule()`. flutter_local_notifications v21 changed to named parameters — compile error "Too many positional arguments"
- **Fix:** Updated NotificationService to use `settings:`, `id:`, `scheduledDate:`, `notificationDetails:`, `androidScheduleMode:` named params
- **Files modified:** lib/core/notifications/notification_service.dart
- **Verification:** `dart analyze` clean, tests pass
- **Committed in:** 4f72eac
**3. [Rule 1 - Bug] Riverpod 3 generated provider name is notificationSettingsProvider**
- **Found during:** Task 2 (test compilation)
- **Issue:** Tests referenced `notificationSettingsNotifierProvider` but Riverpod 3 code gen for `NotificationSettingsNotifier` produces `notificationSettingsProvider` — consistent with existing pattern
- **Fix:** Updated all test references to use `notificationSettingsProvider`
- **Files modified:** test/core/notifications/notification_settings_notifier_test.dart
- **Verification:** Tests compile and pass
- **Committed in:** 4f72eac
**4. [Rule 1 - Bug] Async _load() race condition in tests**
- **Found during:** Task 2 (setTime test failure)
- **Issue:** `setTime(9:30)` persisted correctly but state read back as `(9:00)` because the async `_load()` from `build()` ran after `setTime`, resetting state to SharedPreferences defaults (hour=7, minute=0 since prefs were empty)
- **Fix:** Added `makeContainer()` async helper that awaits `Future.delayed(Duration.zero)` to let initial `_load()` complete before mutations
- **Files modified:** test/core/notifications/notification_settings_notifier_test.dart
- **Verification:** All 7 notifier tests pass consistently
- **Committed in:** 4f72eac
---
**Total deviations:** 4 auto-fixed (1 blocking dependency, 3 bugs from API mismatch/race condition)
**Impact on plan:** All auto-fixes were necessary for correctness. No scope creep.
## Issues Encountered
- flutter_local_notifications v21 breaking changes (named params, compileSdk requirement) were not fully reflected in RESEARCH.md examples — all caught and fixed during compilation/test runs.
## User Setup Required
None — no external service configuration required.
## Next Phase Readiness
- NotificationService and NotificationSettingsNotifier fully implemented and tested
- Plan 02 can immediately wire notificationSettingsProvider into SettingsScreen
- notificationSettingsProvider (notificationSettings.dart) exports are ready for import
- ScheduledNotificationBootReceiver is registered and exported=true for Android 12+
- Timezone is initialized at app start — no further setup needed for Plan 02
---
*Phase: 04-notifications*
*Completed: 2026-03-16*
## Self-Check: PASSED
- lib/core/notifications/notification_service.dart: FOUND
- lib/core/notifications/notification_settings_notifier.dart: FOUND
- lib/core/notifications/notification_settings_notifier.g.dart: FOUND
- test/core/notifications/notification_service_test.dart: FOUND
- test/core/notifications/notification_settings_notifier_test.dart: FOUND
- .planning/phases/04-notifications/04-01-SUMMARY.md: FOUND
- commit 8787671: FOUND
- commit 0f6789b: FOUND
- commit 4f72eac: FOUND

View File

@@ -0,0 +1,317 @@
---
phase: 04-notifications
plan: 02
type: execute
wave: 2
depends_on:
- 04-01
files_modified:
- lib/features/settings/presentation/settings_screen.dart
- lib/core/router/router.dart
- lib/core/notifications/notification_service.dart
- test/features/settings/settings_screen_test.dart
autonomous: true
requirements:
- NOTF-01
- NOTF-02
must_haves:
truths:
- "Settings screen shows a Benachrichtigungen section between Darstellung and Uber"
- "SwitchListTile toggles notification enabled/disabled"
- "When toggle is ON, time picker row appears below with progressive disclosure animation"
- "When toggle is OFF, time picker row is hidden"
- "Tapping time row opens Material 3 showTimePicker dialog"
- "Toggling ON requests POST_NOTIFICATIONS permission on Android 13+"
- "If permission denied, toggle reverts to OFF"
- "If permanently denied, user is guided to system notification settings"
- "When enabled + time set, daily notification is scheduled with correct body from DAO query"
- "Skip notification scheduling when task count is 0"
- "Notification body shows overdue count only when overdue > 0"
- "Tapping notification navigates to Home tab"
artifacts:
- path: "lib/features/settings/presentation/settings_screen.dart"
provides: "Benachrichtigungen section with toggle and time picker"
contains: "SwitchListTile"
- path: "test/features/settings/settings_screen_test.dart"
provides: "Widget tests for notification settings UI"
key_links:
- from: "lib/features/settings/presentation/settings_screen.dart"
to: "lib/core/notifications/notification_settings_notifier.dart"
via: "ref.watch(notificationSettingsNotifierProvider)"
pattern: "notificationSettingsNotifierProvider"
- from: "lib/features/settings/presentation/settings_screen.dart"
to: "lib/core/notifications/notification_service.dart"
via: "NotificationService().scheduleDailyNotification"
pattern: "NotificationService.*schedule"
- from: "lib/core/router/router.dart"
to: "lib/core/notifications/notification_service.dart"
via: "notification tap navigates to /"
pattern: "router\\.go\\('/'\\)"
---
<objective>
Wire the notification infrastructure into the Settings UI with permission flow, add notification scheduling on toggle/time change, implement notification tap navigation, and write widget tests.
Purpose: Complete the user-facing notification feature — users can enable notifications, pick a time, and receive daily task summaries.
Output: Fully functional notification settings with permission handling, scheduling, and navigation.
</objective>
<execution_context>
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
@/home/jlmak/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-notifications/04-CONTEXT.md
@.planning/phases/04-notifications/04-RESEARCH.md
@.planning/phases/04-notifications/04-01-SUMMARY.md
<interfaces>
<!-- Contracts from Plan 01 that this plan consumes -->
From lib/core/notifications/notification_service.dart (created in Plan 01):
```dart
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
Future<void> initialize() async { ... }
Future<bool> requestPermission() async { ... }
Future<void> scheduleDailyNotification({
required TimeOfDay time,
required String title,
required String body,
}) async { ... }
Future<void> cancelAll() async { ... }
}
```
From lib/core/notifications/notification_settings_notifier.dart (created in Plan 01):
```dart
class NotificationSettings {
final bool enabled;
final TimeOfDay time;
const NotificationSettings({required this.enabled, required this.time});
}
@Riverpod(keepAlive: true)
class NotificationSettingsNotifier extends _$NotificationSettingsNotifier {
NotificationSettings build() { ... }
Future<void> setEnabled(bool enabled) async { ... }
Future<void> setTime(TimeOfDay time) async { ... }
}
// Generated provider: notificationSettingsNotifierProvider
```
From lib/features/home/data/daily_plan_dao.dart (extended in Plan 01):
```dart
Future<int> getOverdueAndTodayTaskCount({DateTime? today}) async { ... }
Future<int> getOverdueTaskCount({DateTime? today}) async { ... }
```
From lib/features/settings/presentation/settings_screen.dart (existing):
```dart
class SettingsScreen extends ConsumerWidget {
// ListView with: Darstellung section, Divider, Uber section
}
```
From lib/core/router/router.dart (existing):
```dart
final router = GoRouter(initialLocation: '/', routes: [ ... ]);
```
From lib/l10n/app_de.arb (notification strings from Plan 01):
- settingsSectionNotifications, notificationsEnabledLabel, notificationsTimeLabel
- notificationsPermissionDeniedHint
- notificationTitle, notificationBody, notificationBodyWithOverdue
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Settings UI with Benachrichtigungen section, permission flow, and notification scheduling</name>
<files>
lib/features/settings/presentation/settings_screen.dart,
lib/core/router/router.dart,
lib/core/notifications/notification_service.dart
</files>
<action>
1. **Modify SettingsScreen** (`lib/features/settings/presentation/settings_screen.dart`):
- Change from `ConsumerWidget` to `ConsumerStatefulWidget` (needed for async permission/scheduling logic in callbacks)
- Add `ref.watch(notificationSettingsNotifierProvider)` to get current `NotificationSettings`
- Insert new section BETWEEN the Darstellung Divider and the Uber section header:
```
const Divider(indent: 16, endIndent: 16, height: 32),
// Section 2: Notifications (Benachrichtigungen)
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: notificationSettings.enabled,
onChanged: (value) => _onNotificationToggle(value),
),
// Progressive disclosure: time picker only when enabled
AnimatedSize(
duration: const Duration(milliseconds: 200),
child: notificationSettings.enabled
? ListTile(
title: Text(l10n.notificationsTimeLabel),
trailing: Text(notificationSettings.time.format(context)),
onTap: () => _onPickTime(),
)
: const SizedBox.shrink(),
),
const Divider(indent: 16, endIndent: 16, height: 32),
// Section 3: About (Uber) — existing code, unchanged
```
2. **Implement `_onNotificationToggle(bool value)`**:
- If `value == true` (enabling):
a. Call `NotificationService().requestPermission()` — await result
b. If `granted == false`: check if permanently denied. On Android, this means `shouldShowRequestRationale` returns false after denial. Since we don't have `permission_handler`, use a simpler approach: if `requestPermission()` returns false, show a SnackBar with `l10n.notificationsPermissionDeniedHint` and an action that calls `openAppSettings()` (import `flutter_local_notifications` for this, or use `AppSettings.openAppSettings()`). Actually, the simplest approach: if permission denied, show a SnackBar with the hint text. Do NOT change the toggle to ON. Return early.
c. If `granted == true`: call `ref.read(notificationSettingsNotifierProvider.notifier).setEnabled(true)`, then schedule notification via `_scheduleNotification()`
- If `value == false` (disabling):
a. Call `ref.read(notificationSettingsNotifierProvider.notifier).setEnabled(false)`
b. Call `NotificationService().cancelAll()`
3. **Implement `_scheduleNotification()`** helper:
- Get the database instance from the Riverpod container: `ref.read(appDatabaseProvider)` (or access DailyPlanDao directly — check how other screens access the database and follow that pattern)
- Query `DailyPlanDao(db).getOverdueAndTodayTaskCount()` for total count
- Query `DailyPlanDao(db).getOverdueTaskCount()` for overdue count
- If total count == 0: call `NotificationService().cancelAll()` and return (skip-on-zero per CONTEXT.md)
- Build notification body:
- If overdue > 0: use `l10n.notificationBodyWithOverdue(total, overdue)`
- If overdue == 0: use `l10n.notificationBody(total)`
- Title: `l10n.notificationTitle` (which is "Dein Tagesplan")
- Call `NotificationService().scheduleDailyNotification(time: settings.time, title: title, body: body)`
4. **Implement `_onPickTime()`**:
- Call `showTimePicker(context: context, initialTime: currentSettings.time, initialEntryMode: TimePickerEntryMode.dial)`
- If picked is not null: call `ref.read(notificationSettingsNotifierProvider.notifier).setTime(picked)`, then call `_scheduleNotification()` to reschedule with new time
5. **Wire notification tap navigation** in `lib/core/notifications/notification_service.dart`:
- Update `_onTap` to use the top-level `router` instance from `lib/core/router/router.dart`:
```dart
import 'package:household_keeper/core/router/router.dart';
static void _onTap(NotificationResponse response) {
router.go('/');
}
```
- This works because `router` is a top-level `final` in router.dart, accessible without BuildContext.
6. **Handle permanently denied state** (Claude's discretion):
- Use a simple approach: if `requestPermission()` returns false AND the toggle was tapped:
- First denial: just show SnackBar with hint text
- Track denial in SharedPreferences (`notifications_permission_denied_once` bool)
- If previously denied and denied again: show SnackBar with action button "Einstellungen offnen" that navigates to system notification settings via `AndroidFlutterLocalNotificationsPlugin`'s `openNotificationSettings()` method or Android intent
- Alternatively (simpler): always show the same SnackBar with the hint text on denial. If the user taps it, attempt to open system settings. This avoids tracking denial state.
- Pick the simpler approach: SnackBar with `notificationsPermissionDeniedHint` text. No tracking needed. The SnackBar message already says "Tippe hier, um sie zu aktivieren" — make the SnackBar action open app notification settings.
7. Run `dart analyze --fatal-infos` to ensure no warnings.
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && dart analyze --fatal-infos</automated>
</verify>
<done>
- Settings screen shows Benachrichtigungen section between Darstellung and Uber
- SwitchListTile toggles notification on/off
- Time picker row with AnimatedSize progressive disclosure
- showTimePicker dialog on time row tap
- Permission requested on toggle ON (Android 13+)
- Toggle reverts to OFF on permission denial with SnackBar hint
- Notification scheduled with task count body on enable/time change
- Skip scheduling on zero-task days
- Notification body includes overdue split when overdue > 0
- Tapping notification navigates to Home tab via router.go('/')
- dart analyze clean
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Widget tests for notification settings UI</name>
<files>
test/features/settings/settings_screen_test.dart
</files>
<behavior>
- Test: Settings screen renders Benachrichtigungen section header
- Test: SwitchListTile displays with label "Tagliche Erinnerung" and defaults to OFF
- Test: When notificationSettings.enabled is true, time picker ListTile is visible
- Test: When notificationSettings.enabled is false, time picker ListTile is not visible
- Test: Time picker displays formatted time (e.g. "07:00")
</behavior>
<action>
1. **Create or extend** `test/features/settings/settings_screen_test.dart`:
- Check if file exists; if so, extend it. If not, create it.
- Use provider overrides to inject mock NotificationSettingsNotifier state:
- Override `notificationSettingsNotifierProvider` with a mock/override that returns known state
- Also override `themeProvider` to provide ThemeMode.system (to avoid SharedPreferences issues in tests)
2. **Write widget tests**:
a. "renders Benachrichtigungen section header":
- Pump `SettingsScreen` wrapped in `MaterialApp` with localization delegates + `ProviderScope` with overrides
- Verify `find.text('Benachrichtigungen')` finds one widget
b. "notification toggle defaults to OFF":
- Override notifier with `enabled: false`
- Verify `SwitchListTile` value is false
c. "time picker visible when enabled":
- Override notifier with `enabled: true, time: TimeOfDay(hour: 9, minute: 30)`
- Verify `find.text('09:30')` (or formatted equivalent) finds one widget
- Verify `find.text('Uhrzeit')` finds one widget
d. "time picker hidden when disabled":
- Override notifier with `enabled: false`
- Verify `find.text('Uhrzeit')` finds nothing
3. Run `flutter test test/features/settings/` to confirm tests pass.
4. Run `flutter test` to confirm full suite passes.
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter test test/features/settings/</automated>
</verify>
<done>
- Widget tests exist for notification settings section rendering
- Tests cover: section header present, toggle default OFF, time picker visibility on/off, time display
- All tests pass including full suite
</done>
</task>
</tasks>
<verification>
- `flutter test` — all tests pass (existing + new notification + settings tests)
- `dart analyze --fatal-infos` — no warnings or errors
- Settings screen has Benachrichtigungen section with toggle and conditional time picker
- Permission flow correctly handles grant, deny, and permanently denied
- Notification schedules/cancels based on toggle and time changes
- Notification tap opens Home tab
</verification>
<success_criteria>
- User can toggle notifications on/off from Settings
- Time picker appears only when notifications are enabled
- Permission requested contextually on toggle ON
- Denied permission reverts toggle with helpful SnackBar
- Notification scheduled with task count body (or skipped on zero tasks)
- Notification tap navigates to daily plan
- Widget tests cover key UI states
</success_criteria>
<output>
After completion, create `.planning/phases/04-notifications/04-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,146 @@
---
phase: 04-notifications
plan: 02
subsystem: ui
tags: [flutter, riverpod, flutter_local_notifications, settings, permissions, widget-tests]
# Dependency graph
requires:
- phase: 04-notifications/04-01
provides: NotificationService singleton, NotificationSettingsNotifier, DailyPlanDao task count queries, ARB strings
- phase: 01-foundation
provides: themeProvider pattern, ProviderScope test pattern
provides:
- SettingsScreen with Benachrichtigungen section (SwitchListTile + AnimatedSize time picker)
- Permission flow: request on toggle ON, revert on denial with SnackBar hint
- Notification scheduling with task/overdue counts from DailyPlanDao
- Notification tap navigation via router.go('/') in NotificationService._onTap
- Widget tests for notification settings UI states
affects: [end-to-end notification flow complete]
# Tech tracking
tech-stack:
added: []
patterns:
- ConsumerStatefulWidget for screens requiring async callbacks with BuildContext
- AnimatedSize for progressive disclosure of conditional UI sections
- overrideWithValue for Riverpod provider isolation in widget tests
key-files:
created:
- test/features/settings/settings_screen_test.dart
modified:
- lib/features/settings/presentation/settings_screen.dart
- lib/core/notifications/notification_service.dart
- lib/l10n/app_localizations.dart
- lib/l10n/app_localizations_de.dart
key-decisions:
- "openNotificationSettings() not available in flutter_local_notifications v21 — simplified SnackBar to informational only (no action button)"
- "ConsumerStatefulWidget chosen over ConsumerWidget for async callback isolation and mounted checks"
- "notificationSettingsProvider (Riverpod 3 name, not notificationSettingsNotifierProvider) used throughout"
patterns-established:
- "ConsumerStatefulWidget pattern: async permission/scheduling callbacks use mounted guards after every await"
- "TDD with pre-existing implementation: write tests to document expected behavior, verify pass, commit as feat (not separate test/feat commits)"
requirements-completed: [NOTF-01, NOTF-02]
# Metrics
duration: 5min
completed: 2026-03-16
---
# Phase 4 Plan 2: Notification Settings UI Summary
**ConsumerStatefulWidget SettingsScreen with Benachrichtigungen section, Android permission flow, DailyPlanDao-driven scheduling, notification tap navigation, and 5 widget tests**
## Performance
- **Duration:** 5 min
- **Started:** 2026-03-16T14:02:25Z
- **Completed:** 2026-03-16T14:07:58Z
- **Tasks:** 2
- **Files modified:** 5
## Accomplishments
- SettingsScreen rewritten as ConsumerStatefulWidget with Benachrichtigungen section inserted between Darstellung and Uber
- SwitchListTile with permission request on toggle ON: `requestNotificationsPermission()` called before state update; toggle stays OFF on denial with SnackBar
- AnimatedSize progressive disclosure: time picker row only appears when `notificationSettings.enabled` is true
- `_scheduleNotification()` queries DailyPlanDao for total/overdue counts; skips scheduling when total==0; builds conditional body with overdue split when overdue > 0
- `_onPickTime()` opens Material 3 showTimePicker dialog and reschedules on selection
- `NotificationService._onTap` wired to `router.go('/')` for notification tap navigation to Home tab
- AppLocalizations regenerated with 7 notification strings from Plan 01 ARB file
- 5 new widget tests: section header, toggle default OFF, time picker visible/hidden, formatted time display — all 89 tests pass
## Task Commits
Each task was committed atomically:
1. **Task 1: Settings UI with Benachrichtigungen section, permission flow, and notification scheduling** - `0103dde` (feat)
2. **Task 2: Widget tests for notification settings UI** - `77de7cd` (feat)
**Plan metadata:** (docs commit — see final commit hash below)
## Files Created/Modified
- `lib/features/settings/presentation/settings_screen.dart` - ConsumerStatefulWidget with Benachrichtigungen section, permission flow, scheduling, and time picker
- `lib/core/notifications/notification_service.dart` - Added router import and `router.go('/')` in `_onTap`
- `lib/l10n/app_localizations.dart` - Regenerated abstract class with 7 new notification string declarations
- `lib/l10n/app_localizations_de.dart` - Regenerated German implementations for 7 new notification strings
- `test/features/settings/settings_screen_test.dart` - 5 widget tests covering notification UI states
## Decisions Made
- **openNotificationSettings() unavailable**: `AndroidFlutterLocalNotificationsPlugin` in v21.0.0 does not expose `openNotificationSettings()`. Simplified to informational SnackBar without action button. The ARB hint text already guides users to system settings manually.
- **ConsumerStatefulWidget**: Chosen over ConsumerWidget because `_onNotificationToggle` and `_scheduleNotification` are async and require `mounted` checks after each `await` — this is only safe in `State`.
- **notificationSettingsProvider naming**: Used `notificationSettingsProvider` (Riverpod 3 convention established in Plan 01), not `notificationSettingsNotifierProvider` as referenced in the plan interfaces section.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] openNotificationSettings() does not exist on AndroidFlutterLocalNotificationsPlugin v21**
- **Found during:** Task 1 (dart analyze after implementation)
- **Issue:** Plan specified using `androidPlugin?.openNotificationSettings()` in the SnackBar action, but this method does not exist in flutter_local_notifications v21.0.0
- **Fix:** Removed the action button from the SnackBar — simplified to an informational SnackBar showing `notificationsPermissionDeniedHint` text only. The plan explicitly offered "Pick the simpler approach: SnackBar with hint text" as an option.
- **Files modified:** lib/features/settings/presentation/settings_screen.dart
- **Verification:** dart analyze clean, no errors
- **Committed in:** 0103dde
**2. [Rule 1 - Bug] AppLocalizations missing notification string getters (stale generated files)**
- **Found during:** Task 1 (dart analyze)
- **Issue:** `app_localizations.dart` and `app_localizations_de.dart` were not updated after Plan 01 added 7 strings to `app_de.arb`. The generated files were stale.
- **Fix:** Ran `flutter gen-l10n` to regenerate localization files from ARB
- **Files modified:** lib/l10n/app_localizations.dart, lib/l10n/app_localizations_de.dart
- **Verification:** dart analyze clean after regeneration
- **Committed in:** 0103dde
---
**Total deviations:** 2 auto-fixed (2 bugs — API mismatch and stale generated files)
**Impact on plan:** Both auto-fixes were necessary. The SnackBar simplification is explicitly offered as the preferred option in the plan. The localization regeneration is a missing step from Plan 01 that Plan 02 needed.
## Issues Encountered
- flutter_local_notifications v21 API surface for `AndroidFlutterLocalNotificationsPlugin` does not include `openNotificationSettings()` — the plan referenced a method that was either added later or never existed in this version. Simplified to informational SnackBar per plan's own "simpler approach" option.
## User Setup Required
None — no external service configuration required.
## Next Phase Readiness
- Phase 4 (Notifications) is fully complete: infrastructure (Plan 01) + Settings UI (Plan 02)
- All 89 tests passing, dart analyze clean
- Notification feature end-to-end: toggle ON/OFF, permission request, time picker, daily scheduling, tap navigation to Home
---
*Phase: 04-notifications*
*Completed: 2026-03-16*
## Self-Check: PASSED
- lib/features/settings/presentation/settings_screen.dart: FOUND
- lib/core/notifications/notification_service.dart: FOUND
- test/features/settings/settings_screen_test.dart: FOUND
- .planning/phases/04-notifications/04-02-SUMMARY.md: FOUND
- commit 0103dde: FOUND
- commit 77de7cd: FOUND

View File

@@ -0,0 +1,96 @@
---
phase: 04-notifications
plan: 03
type: execute
wave: 3
depends_on:
- 04-02
files_modified: []
autonomous: true
requirements:
- NOTF-01
- NOTF-02
must_haves:
truths:
- "dart analyze --fatal-infos passes with zero issues"
- "All tests pass (72 existing + new notification tests)"
- "NOTF-01 artifacts exist: NotificationService, DAO queries, AndroidManifest permissions, timezone init"
- "NOTF-02 artifacts exist: NotificationSettingsNotifier, Settings UI section, toggle, time picker"
- "Phase 4 requirements are fully addressed"
artifacts: []
key_links: []
---
<objective>
Verify all Phase 4 notification work is complete, tests pass, and code is clean before marking the phase done.
Purpose: Quality gate ensuring NOTF-01 and NOTF-02 are fully implemented before phase completion.
Output: Verification confirmation with test counts and analysis results.
</objective>
<execution_context>
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
@/home/jlmak/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-notifications/04-CONTEXT.md
@.planning/phases/04-notifications/04-01-SUMMARY.md
@.planning/phases/04-notifications/04-02-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Run full verification suite</name>
<files></files>
<action>
1. Run `dart analyze --fatal-infos` — must produce zero issues.
2. Run `flutter test` — must produce zero failures. Record total test count.
3. Verify NOTF-01 requirements by checking file existence and content:
- `lib/core/notifications/notification_service.dart` exists with `scheduleDailyNotification`, `requestPermission`, `cancelAll`
- `lib/features/home/data/daily_plan_dao.dart` has `getOverdueAndTodayTaskCount` and `getOverdueTaskCount`
- `android/app/src/main/AndroidManifest.xml` has `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, `ScheduledNotificationBootReceiver` with `exported="true"`
- `android/app/build.gradle.kts` has `isCoreLibraryDesugaringEnabled = true` and `compileSdk = 35`
- `lib/main.dart` has timezone initialization and `NotificationService().initialize()`
4. Verify NOTF-02 requirements by checking:
- `lib/core/notifications/notification_settings_notifier.dart` exists with `setEnabled`, `setTime`
- `lib/features/settings/presentation/settings_screen.dart` has `SwitchListTile` and `AnimatedSize` for notification section
- `lib/l10n/app_de.arb` has notification-related keys (`settingsSectionNotifications`, `notificationsEnabledLabel`, etc.)
5. Verify notification tap navigation:
- `lib/core/notifications/notification_service.dart` `_onTap` references `router.go('/')`
6. If any issues found, fix them. If all checks pass, record results.
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && dart analyze --fatal-infos && flutter test</automated>
</verify>
<done>
- dart analyze: zero issues
- flutter test: all tests pass (72 existing + new notification/settings tests)
- NOTF-01: NotificationService, DAO queries, Android config, timezone init all confirmed present and functional
- NOTF-02: NotificationSettingsNotifier, Settings UI section, toggle, time picker all confirmed present and functional
- Phase 4 verification gate passed
</done>
</task>
</tasks>
<verification>
- `dart analyze --fatal-infos` — zero issues
- `flutter test` — all tests pass
- All NOTF-01 and NOTF-02 artifacts exist and are correctly wired
</verification>
<success_criteria>
- Phase 4 code is clean (no analysis warnings)
- All tests pass
- Both requirements (NOTF-01, NOTF-02) have their artifacts present and correctly implemented
</success_criteria>
<output>
After completion, create `.planning/phases/04-notifications/04-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,98 @@
---
phase: 04-notifications
plan: 03
subsystem: testing
tags: [flutter, dart-analyze, flutter-test, verification]
# Dependency graph
requires:
- phase: 04-notifications/04-01
provides: NotificationService, NotificationSettingsNotifier, DailyPlanDao queries, Android config, timezone init, ARB strings
- phase: 04-notifications/04-02
provides: SettingsScreen Benachrichtigungen section, permission flow, scheduling integration, widget tests
provides:
- Phase 4 verification gate passed: dart analyze clean, 89/89 tests pass
- All NOTF-01 artifacts confirmed present and functional
- All NOTF-02 artifacts confirmed present and functional
affects: [phase completion]
# Tech tracking
tech-stack:
added: []
patterns: []
key-files:
created: []
modified: []
key-decisions:
- "Phase 4 verification gate passed: dart analyze --fatal-infos zero issues, 89/89 tests passing (72 original + 12 notification unit + 5 notification settings widget)"
patterns-established: []
requirements-completed: [NOTF-01, NOTF-02]
# Metrics
duration: 2min
completed: 2026-03-16
---
# Phase 4 Plan 3: Phase 4 Verification Gate Summary
**dart analyze --fatal-infos zero issues and 89/89 tests passing confirming NOTF-01 (NotificationService, DailyPlanDao queries, Android config, timezone init) and NOTF-02 (NotificationSettingsNotifier, SettingsScreen Benachrichtigungen section, SwitchListTile, AnimatedSize time picker) fully implemented**
## Performance
- **Duration:** 2 min
- **Started:** 2026-03-16T14:10:51Z
- **Completed:** 2026-03-16T14:12:07Z
- **Tasks:** 1
- **Files modified:** 0
## Accomplishments
- `dart analyze --fatal-infos` passed with zero issues
- `flutter test` passed: 89/89 tests (72 pre-Phase-4 + 12 notification unit tests + 5 notification settings widget tests)
- NOTF-01 artifacts confirmed: `NotificationService` with `scheduleDailyNotification`, `requestPermission`, `cancelAll`; `DailyPlanDao` with `getOverdueAndTodayTaskCount` and `getOverdueTaskCount`; AndroidManifest with `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, `ScheduledNotificationBootReceiver exported="true"`; `build.gradle.kts` with `isCoreLibraryDesugaringEnabled = true` and `compileSdk = 35`; `main.dart` with timezone init chain and `NotificationService().initialize()`
- NOTF-02 artifacts confirmed: `NotificationSettingsNotifier` with `setEnabled` and `setTime`; `SettingsScreen` with `SwitchListTile` and `AnimatedSize` notification section; `app_de.arb` with all 7 notification keys (`settingsSectionNotifications`, `notificationsEnabledLabel`, `notificationsTimeLabel`, `notificationsPermissionDeniedHint`, `notificationTitle`, `notificationBody`, `notificationBodyWithOverdue`)
- Notification tap navigation confirmed: `_onTap` calls `router.go('/')`
## Task Commits
No source code commits required — verification-only task.
**Plan metadata:** (docs commit — see final commit hash below)
## Files Created/Modified
None — pure verification gate.
## Decisions Made
None - followed plan as specified. All artifacts were already present from Plans 01 and 02.
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None — all checks passed on first run.
## User Setup Required
None — no external service configuration required.
## Next Phase Readiness
- Phase 4 (Notifications) is fully complete and verified
- All 4 phases of the v1.0 milestone are complete
- 89 tests passing, zero analysis issues
- App delivers on core value: users see what needs doing today, mark it done, get daily reminders, trust recurring scheduling
---
*Phase: 04-notifications*
*Completed: 2026-03-16*
## Self-Check: PASSED
- lib/core/notifications/notification_service.dart: FOUND
- lib/core/notifications/notification_settings_notifier.dart: FOUND
- lib/features/settings/presentation/settings_screen.dart: FOUND
- .planning/phases/04-notifications/04-03-SUMMARY.md: FOUND

View File

@@ -0,0 +1,97 @@
# Phase 4: Notifications - Context
**Gathered:** 2026-03-16
**Status:** Ready for planning
<domain>
## Phase Boundary
Users receive a daily summary notification reminding them of today's task count, and can control notification behavior from settings. Delivers: daily summary notification with configurable time, enable/disable toggle in Settings, Android POST_NOTIFICATIONS permission handling (API 33+), RECEIVE_BOOT_COMPLETED rescheduling, and graceful degradation when permission is denied.
Requirements: NOTF-01, NOTF-02
</domain>
<decisions>
## Implementation Decisions
### Notification timing
- **User-configurable time** via time picker in Settings, stored in SharedPreferences
- **Default time: 07:00** — early morning, user sees notification when they wake up
- **Notifications disabled by default** on fresh install — user explicitly opts in from Settings
- **Skip notification on zero-task days** — no notification fires when there are no tasks due (overdue + today). Only notifies when there's something to do
### Notification content
- **Body shows task count with conditional overdue split**: when overdue tasks exist, body reads e.g. "5 Aufgaben fällig (2 überfällig)". When no overdue, just "5 Aufgaben fällig"
- **Overdue count only shown when > 0** — cleaner on days with no overdue tasks
- **Tapping the notification opens the daily plan (Home tab)** — direct path to action
- All notification text from ARB localization files
### Permission flow
- **Permission requested when user toggles notifications ON** in Settings (Android 13+ / API 33+). Natural flow: user explicitly wants notifications, so the request is contextual
- **On permission denial**: Claude's discretion on UX (inline hint vs dialog), but toggle reverts to OFF
- **On re-enable after prior denial**: app detects permanently denied state and guides user to system notification settings (not just re-requesting)
- **Android 12 and below**: same opt-in flow — user must enable in Settings even though no runtime permission is needed. Consistent UX across all API levels
- **RECEIVE_BOOT_COMPLETED**: notifications reschedule after device reboot if enabled
### Settings UI layout
- **New "Benachrichtigungen" section** between Darstellung and Über — follows the existing grouped section pattern
- **Toggle + time picker**: SwitchListTile for enable/disable. When enabled, time picker row appears below (progressive disclosure). When disabled, time picker row is hidden
- **Material 3 time picker dialog** (`showTimePicker()`) for selecting notification time — consistent with the app's M3 design language
- **Section header** styled identically to existing "Darstellung" and "Über" headers (primary-colored titleMedium)
### 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
</decisions>
<specifics>
## Specific Ideas
- Notification should respect the calm aesthetic — informative, not alarming. Even with overdue count, the tone should be helpful not stressful
- The Settings section should feel like a natural extension of the existing screen — same section header style, same spacing, same widget patterns
- Skip-on-zero-tasks means the notification is genuinely useful every time it appears — no noise on free days
- Permission flow should feel seamless: toggle ON → permission dialog → if granted, schedule immediately. User shouldn't need to toggle twice
</specifics>
<code_context>
## Existing Code Insights
### Reusable Assets
- `DailyPlanDao` (`lib/features/home/data/daily_plan_dao.dart`): Has `watchAllTasksWithRoomName()` stream query — notification service needs a similar one-shot query for task count (overdue + today)
- `ThemeProvider` (`lib/core/theme/theme_provider.dart`): AsyncNotifier with SharedPreferences persistence — template for notification settings provider (enabled boolean + TimeOfDay)
- `SettingsScreen` (`lib/features/settings/presentation/settings_screen.dart`): ListView with grouped sections and Dividers — notification section slots in between Darstellung and Über
- `app_de.arb` (`lib/l10n/app_de.arb`): 92 existing localization keys — needs notification-related strings (toggle label, time label, permission hint, notification body templates)
### Established Patterns
- **Riverpod 3 code generation**: `@riverpod` annotation + `.g.dart` files. Functional providers for reads, class-based AsyncNotifier for mutations
- **SharedPreferences for user settings**: ThemeProvider uses `SharedPreferences` with `ref.onDispose` — same pattern for notification preferences
- **Manual StreamProvider**: Used for drift queries that hit riverpod_generator type issues — may apply to notification-related queries
- **ARB localization**: All UI strings from `AppLocalizations.of(context)` — notification strings follow same pattern
- **Material 3 theming**: All colors via `Theme.of(context).colorScheme`
### Integration Points
- Settings screen: new section added to existing `SettingsScreen` widget between Darstellung and Über sections
- Notification service queries same task data as `DailyPlanDao` (tasks table with nextDueDate)
- AndroidManifest.xml: needs POST_NOTIFICATIONS permission, RECEIVE_BOOT_COMPLETED permission, and boot receiver declaration
- pubspec.yaml: needs `flutter_local_notifications` (or similar) package added
</code_context>
<deferred>
## Deferred Ideas
None — discussion stayed within phase scope
</deferred>
---
*Phase: 04-notifications*
*Context gathered: 2026-03-16*

View File

@@ -0,0 +1,614 @@
# 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 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:**
```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)

View File

@@ -0,0 +1,80 @@
---
phase: 4
slug: notifications
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-03-16
---
# Phase 4 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | flutter_test (built-in) |
| **Config file** | none — uses flutter test runner |
| **Quick run command** | `flutter test test/core/notifications/` |
| **Full suite command** | `flutter test` |
| **Estimated runtime** | ~15 seconds |
---
## Sampling Rate
- **After every task commit:** Run `flutter test test/core/notifications/`
- **After every plan wave:** Run `flutter test`
- **Before `/gsd:verify-work`:** Full suite must be green + `dart analyze --fatal-infos`
- **Max feedback latency:** 15 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
| 04-01-01 | 01 | 1 | NOTF-01 | unit | `flutter test test/core/notifications/notification_service_test.dart` | ❌ W0 | ⬜ pending |
| 04-01-02 | 01 | 1 | NOTF-01 | unit | `flutter test test/core/notifications/notification_service_test.dart` | ❌ W0 | ⬜ pending |
| 04-01-03 | 01 | 1 | NOTF-02 | unit | `flutter test test/core/notifications/notification_settings_notifier_test.dart` | ❌ W0 | ⬜ pending |
| 04-01-04 | 01 | 1 | NOTF-02 | unit | `flutter test test/core/notifications/notification_settings_notifier_test.dart` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `test/core/notifications/notification_service_test.dart` — stubs for NOTF-01 scheduling logic
- [ ] `test/core/notifications/notification_settings_notifier_test.dart` — stubs for 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`
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Boot receiver manifest entry present (exported=true) | NOTF-01 | Static XML config, not runtime testable | Inspect AndroidManifest.xml for `ScheduledNotificationBootReceiver` with `android:exported="true"` |
| POST_NOTIFICATIONS permission requested on toggle-on | NOTF-01 | Native Android permission dialog | Run on Android 13+ emulator, toggle notification ON, verify dialog appears |
| Notification actually appears on device | NOTF-01 | flutter_local_notifications dispatches to native | Run on emulator, schedule notification 1 min ahead, verify it appears |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 15s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending

View File

@@ -0,0 +1,169 @@
---
phase: 04-notifications
verified: 2026-03-16T15:00:00Z
status: passed
score: 21/21 must-haves verified
re_verification: false
---
# Phase 4: Notifications Verification Report
**Phase Goal:** Users receive a daily summary notification reminding them of today's task count, and can control notification behavior from settings
**Verified:** 2026-03-16T15:00:00Z
**Status:** PASSED
**Re-verification:** No — initial verification
---
## Goal Achievement
### Observable Truths
All must-haves are drawn from the PLAN frontmatter of plans 01 and 02. Plan 03 is a verification-gate plan (no truths, no artifacts) and contributes no additional must-haves.
#### Plan 01 Must-Haves
| # | Truth | Status | Evidence |
|----|-----------------------------------------------------------------------------------------------|------------|---------------------------------------------------------------------------------------------------|
| 1 | NotificationService can schedule a daily notification at a given TimeOfDay | VERIFIED | `scheduleDailyNotification` in `notification_service.dart` lines 30-55; uses `zonedSchedule` |
| 2 | NotificationService can cancel all scheduled notifications | VERIFIED | `cancelAll()` delegates to `_plugin.cancelAll()` at line 57 |
| 3 | NotificationService can request POST_NOTIFICATIONS permission | VERIFIED | `requestPermission()` resolves `AndroidFlutterLocalNotificationsPlugin`, calls `requestNotificationsPermission()` |
| 4 | NotificationSettingsNotifier persists enabled boolean and TimeOfDay to SharedPreferences | VERIFIED | `setEnabled` and `setTime` each call `SharedPreferences.getInstance()` and persist values |
| 5 | NotificationSettingsNotifier loads persisted values on build | VERIFIED | `build()` calls `_load()` which reads SharedPreferences and overrides state asynchronously |
| 6 | DailyPlanDao can return a one-shot count of overdue + today tasks | VERIFIED | `getOverdueAndTodayTaskCount` and `getOverdueTaskCount` present in `daily_plan_dao.dart` lines 36-55 |
| 7 | Timezone is initialized before any notification scheduling | VERIFIED | `main.dart`: `tz.initializeTimeZones()``FlutterTimezone.getLocalTimezone()``tz.setLocalLocation()``NotificationService().initialize()` |
| 8 | Android build compiles with core library desugaring enabled | VERIFIED | `build.gradle.kts` line 14: `isCoreLibraryDesugaringEnabled = true`; line 48: `coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")` |
| 9 | AndroidManifest has POST_NOTIFICATIONS permission, RECEIVE_BOOT_COMPLETED permission, and boot receiver | VERIFIED | Lines 2-4: both permissions; lines 38-48: `ScheduledNotificationReceiver` (exported=false) and `ScheduledNotificationBootReceiver` (exported=true) with BOOT_COMPLETED intent-filter |
#### Plan 02 Must-Haves
| # | Truth | Status | Evidence |
|----|-----------------------------------------------------------------------------------------------|------------|---------------------------------------------------------------------------------------------------|
| 10 | Settings screen shows a Benachrichtigungen section between Darstellung and Uber | VERIFIED | `settings_screen.dart` lines 144-173: section inserted between `Divider` after Darstellung and `Divider` before Uber |
| 11 | SwitchListTile toggles notification enabled/disabled | VERIFIED | Line 156: `SwitchListTile` with `value: notificationSettings.enabled` and `onChanged: _onNotificationToggle` |
| 12 | When toggle is ON, time picker row appears below with progressive disclosure animation | VERIFIED | Lines 162-171: `AnimatedSize` wrapping conditional `ListTile` when `notificationSettings.enabled` is true |
| 13 | When toggle is OFF, time picker row is hidden | VERIFIED | Same `AnimatedSize`: returns `SizedBox.shrink()` when disabled; widget test confirms `find.text('Uhrzeit')` finds nothing |
| 14 | Tapping time row opens Material 3 showTimePicker dialog | VERIFIED | `_onPickTime()` at line 78 calls `showTimePicker` with `initialEntryMode: TimePickerEntryMode.dial` |
| 15 | Toggling ON requests POST_NOTIFICATIONS permission on Android 13+ | VERIFIED | `_onNotificationToggle(true)` immediately calls `NotificationService().requestPermission()` before state update |
| 16 | If permission denied, toggle reverts to OFF | VERIFIED | Lines 23-34: if `!granted`, SnackBar shown and early return — `setEnabled` is never called, state stays off |
| 17 | If permanently denied, user is guided to system notification settings | VERIFIED | SnackBar message `notificationsPermissionDeniedHint` tells user to go to system settings. Note: no action button (simplified per plan's "simpler approach" option — v21 has no `openNotificationSettings()`) |
| 18 | When enabled + time set, daily notification is scheduled with correct body from DAO query | VERIFIED | `_scheduleNotification()` lines 49-76: queries `getOverdueAndTodayTaskCount` and `getOverdueTaskCount`, builds body, calls `scheduleDailyNotification` |
| 19 | Skip notification scheduling when task count is 0 | VERIFIED | Lines 58-62: if `total == 0`, calls `cancelAll()` and returns without scheduling |
| 20 | Notification body shows overdue count only when overdue > 0 | VERIFIED | Lines 66-68: `overdue > 0` uses `notificationBodyWithOverdue(total, overdue)`, else `notificationBody(total)` |
| 21 | Tapping notification navigates to Home tab | VERIFIED | `notification_service.dart` line 79: `_onTap` calls `router.go('/')` using top-level `router` from `router.dart` |
**Score:** 21/21 truths verified
---
### Required Artifacts
| Artifact | Provides | Status | Details |
|-------------------------------------------------------------------------------|-------------------------------------------------------|------------|------------------------------------------------------------|
| `lib/core/notifications/notification_service.dart` | Singleton wrapper around FlutterLocalNotificationsPlugin | VERIFIED | 81 lines; substantive; wired in main.dart and settings_screen.dart |
| `lib/core/notifications/notification_settings_notifier.dart` | Riverpod notifier for notification enabled + time | VERIFIED | 52 lines; `@Riverpod(keepAlive: true)`; wired in settings_screen.dart |
| `lib/core/notifications/notification_settings_notifier.g.dart` | Riverpod generated code; provider `notificationSettingsProvider` | VERIFIED | Generated; referenced in settings tests and screen |
| `lib/features/settings/presentation/settings_screen.dart` | Benachrichtigungen section with SwitchListTile + AnimatedSize | VERIFIED | 196 lines; ConsumerStatefulWidget; imports and uses both notifier and service |
| `test/core/notifications/notification_service_test.dart` | Unit tests for singleton and nextInstanceOf TZ logic | VERIFIED | 97 lines; 5 tests; all pass |
| `test/core/notifications/notification_settings_notifier_test.dart` | Unit tests for persistence and state management | VERIFIED | 132 lines; 7 tests; all pass |
| `test/features/settings/settings_screen_test.dart` | Widget tests for notification settings UI | VERIFIED | 109 lines; 5 widget tests; all pass |
| `android/app/src/main/AndroidManifest.xml` | Android notification permissions and receivers | VERIFIED | POST_NOTIFICATIONS + RECEIVE_BOOT_COMPLETED + both receivers |
| `android/app/build.gradle.kts` | Android build with desugaring | VERIFIED | compileSdk=35, isCoreLibraryDesugaringEnabled=true, desugar_jdk_libs:2.1.4 |
| `lib/main.dart` | Timezone init + NotificationService initialization | VERIFIED | 17 lines; full async chain before runApp |
| `lib/features/home/data/daily_plan_dao.dart` | One-shot task count queries for notification body | VERIFIED | `getOverdueAndTodayTaskCount` and `getOverdueTaskCount` present and substantive |
| `lib/l10n/app_de.arb` | 7 notification ARB strings | VERIFIED | Lines 92-109: all 7 keys present with correct placeholders |
---
### Key Link Verification
| From | To | Via | Status | Details |
|---------------------------------------------|----------------------------------------------|----------------------------------------------|----------|----------------------------------------------------------------|
| `notification_service.dart` | `flutter_local_notifications` | `FlutterLocalNotificationsPlugin` | WIRED | Imported line 3; instantiated line 13; used throughout |
| `notification_settings_notifier.dart` | `shared_preferences` | `SharedPreferences.getInstance()` | WIRED | Lines 30, 42, 48: three persistence calls |
| `lib/main.dart` | `notification_service.dart` | `NotificationService().initialize()` | WIRED | Line 15: called after timezone init, before runApp |
| `settings_screen.dart` | `notification_settings_notifier.dart` | `ref.watch(notificationSettingsProvider)` | WIRED | Line 98: watch; lines 37, 43, 50, 79, 87: read+notifier |
| `settings_screen.dart` | `notification_service.dart` | `NotificationService().scheduleDailyNotification` | WIRED | Line 71: call in `_scheduleNotification()`; line 45: `cancelAll()` |
| `notification_service.dart` | `router.dart` | `router.go('/')` | WIRED | Line 6 import; line 79: `router.go('/')` in `_onTap` |
---
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|----------------|-------------------------------------------------------------------------------|-----------|-----------------------------------------------------------------------------------|
| NOTF-01 | 04-01, 04-02, 04-03 | User receives a daily summary notification showing today's task count at a configurable time | SATISFIED | NotificationService with `scheduleDailyNotification`, DailyPlanDao queries, AndroidManifest configured, timezone initialized in main.dart, scheduling driven by DAO task count |
| NOTF-02 | 04-01, 04-02, 04-03 | User can enable/disable notifications in settings | SATISFIED | NotificationSettingsNotifier with SharedPreferences persistence, SwitchListTile in Settings screen, AnimatedSize time picker, permission request flow |
No orphaned requirements found. All requirements mapped to Phase 4 in REQUIREMENTS.md (NOTF-01, NOTF-02) are claimed and satisfied by the phase plans.
---
### Anti-Patterns Found
No anti-patterns found. Scanned:
- `lib/core/notifications/notification_service.dart`
- `lib/core/notifications/notification_settings_notifier.dart`
- `lib/features/settings/presentation/settings_screen.dart`
No TODOs, FIXMEs, placeholder comments, empty implementations, or stub handlers detected.
---
### Human Verification Required
The following behaviors require a physical Android device or emulator to verify:
#### 1. Permission Grant and Notification Scheduling
**Test:** Install app on Android 13+ device. Navigate to Settings. Toggle "Tagliche Erinnerung" ON.
**Expected:** Android system permission dialog appears. After granting, the time row appears with the default 07:00 time.
**Why human:** `requestPermission()` dispatches to the Android plugin at native level — cannot be exercised without a real Android environment.
#### 2. Permission Denial Flow
**Test:** On Android 13+, toggle ON, then deny the system permission dialog.
**Expected:** Toggle remains OFF. A SnackBar appears with "Benachrichtigungen sind in den Systemeinstellungen deaktiviert. Tippe hier, um sie zu aktivieren."
**Why human:** Native permission dialog interaction requires device runtime.
#### 3. Daily Notification Delivery
**Test:** Enable notifications, set a time 1-2 minutes in the future. Wait.
**Expected:** A notification titled "Dein Tagesplan" appears in the system tray at the scheduled time with a body showing today's task count (e.g. "3 Aufgaben fallig").
**Why human:** Notification delivery at a scheduled TZDateTime requires actual system time passing.
#### 4. Notification Tap Navigation
**Test:** Tap the delivered notification from the system tray while the app is in the background.
**Expected:** App opens (or foregrounds) directly to the Home/Daily Plan tab.
**Why human:** `_onTap` with `router.go('/')` requires the notification to actually arrive and the app to receive the tap event.
#### 5. Boot Receiver
**Test:** Enable notifications on a device, reboot the device.
**Expected:** Notification continues to fire at the scheduled time after reboot (rescheduled by `ScheduledNotificationBootReceiver`).
**Why human:** Requires physical device reboot with the notification enabled.
---
### Summary
Phase 4 goal is achieved. All 21 observable truths from the plan frontmatter are verified against the actual codebase:
- **NotificationService** is a complete, non-stub singleton wrapping `FlutterLocalNotificationsPlugin` with TZ-aware scheduling, permission request, and cancel.
- **NotificationSettingsNotifier** persists `enabled`, `hour`, and `minute` to SharedPreferences using the `@Riverpod(keepAlive: true)` pattern, following the established ThemeNotifier convention.
- **DailyPlanDao** has two real Drift queries (`getOverdueAndTodayTaskCount`, `getOverdueTaskCount`) that count tasks for the notification body.
- **Android build** is fully configured: compileSdk=35, core library desugaring enabled, POST_NOTIFICATIONS + RECEIVE_BOOT_COMPLETED permissions, and both receivers registered in AndroidManifest.
- **main.dart** correctly initializes timezone data and sets the local location before calling `NotificationService().initialize()`.
- **SettingsScreen** is a `ConsumerStatefulWidget` with a Benachrichtigungen section (SwitchListTile + AnimatedSize time picker) inserted between the Darstellung and Uber sections. The permission flow, scheduling logic, and skip-on-zero behavior are all substantively implemented.
- **Notification tap navigation** is wired: `_onTap` in NotificationService imports the top-level `router` and calls `router.go('/')`.
- **All 7 ARB keys** are present in `app_de.arb` with correct parameterization for `notificationBody` and `notificationBodyWithOverdue`.
- **89/89 tests pass** and **dart analyze --fatal-infos** reports zero issues.
- **NOTF-01** and **NOTF-02** are fully satisfied. No orphaned requirements.
Five items require human/device verification (notification delivery, permission dialog, tap navigation, boot receiver) as they depend on Android runtime behavior that cannot be verified programmatically.
---
_Verified: 2026-03-16T15:00:00Z_
_Verifier: Claude (gsd-verifier)_