318 lines
15 KiB
Markdown
318 lines
15 KiB
Markdown
---
|
|
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>
|