docs(04): create phase plan
This commit is contained in:
339
.planning/phases/04-notifications/04-01-PLAN.md
Normal file
339
.planning/phases/04-notifications/04-01-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user