From 4f72eac9331953506f94d05f6b0956c1d8a3dc40 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 16 Mar 2026 14:57:04 +0100 Subject: [PATCH] feat(04-01): NotificationSettingsNotifier with SharedPreferences persistence and full test suite - Fix NotificationService API calls to use flutter_local_notifications v21 named parameters - Expose nextInstanceOf as @visibleForTesting public method for unit testing - Create NotificationSettingsNotifier with @Riverpod(keepAlive: true) - NotificationSettings data class with enabled bool + TimeOfDay fields - Persist notifications_enabled, notifications_hour, notifications_minute to SharedPreferences - Sync default state in build(), async _load() overrides on hydration - Update tests to use correct Riverpod 3 provider name (notificationSettingsProvider) - Add makeContainer() helper to await initial _load() before asserting mutations - All 84 tests pass (72 existing + 12 new notification tests) --- .../notifications/notification_service.dart | 13 ++-- .../notification_settings_notifier.dart | 52 +++++++++++++++ .../notification_settings_notifier.g.dart | 65 +++++++++++++++++++ .../notification_settings_notifier_test.dart | 52 ++++++++------- 4 files changed, 151 insertions(+), 31 deletions(-) create mode 100644 lib/core/notifications/notification_settings_notifier.dart create mode 100644 lib/core/notifications/notification_settings_notifier.g.dart diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index 426db1e..864f9d2 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -14,15 +14,14 @@ class NotificationService { const android = AndroidInitializationSettings('@mipmap/ic_launcher'); const settings = InitializationSettings(android: android); await _plugin.initialize( - settings, + settings: settings, onDidReceiveNotificationResponse: _onTap, ); } Future requestPermission() async { - final android = _plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>(); + final android = _plugin.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); return await android?.requestNotificationsPermission() ?? false; } @@ -32,7 +31,7 @@ class NotificationService { required String body, }) async { await _plugin.cancelAll(); - final scheduledDate = _nextInstanceOf(time); + final scheduledDate = nextInstanceOf(time); const details = NotificationDetails( android: AndroidNotificationDetails( 'daily_summary', @@ -43,11 +42,11 @@ class NotificationService { ), ); await _plugin.zonedSchedule( - 0, + id: 0, title: title, body: body, scheduledDate: scheduledDate, - details, + notificationDetails: details, androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, matchDateTimeComponents: DateTimeComponents.time, ); diff --git a/lib/core/notifications/notification_settings_notifier.dart b/lib/core/notifications/notification_settings_notifier.dart new file mode 100644 index 0000000..e5f2716 --- /dev/null +++ b/lib/core/notifications/notification_settings_notifier.dart @@ -0,0 +1,52 @@ +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 _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 setEnabled(bool enabled) async { + state = NotificationSettings(enabled: enabled, time: state.time); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_enabledKey, enabled); + } + + Future 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); + } +} diff --git a/lib/core/notifications/notification_settings_notifier.g.dart b/lib/core/notifications/notification_settings_notifier.g.dart new file mode 100644 index 0000000..d56c264 --- /dev/null +++ b/lib/core/notifications/notification_settings_notifier.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'notification_settings_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(NotificationSettingsNotifier) +final notificationSettingsProvider = NotificationSettingsNotifierProvider._(); + +final class NotificationSettingsNotifierProvider + extends + $NotifierProvider { + NotificationSettingsNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'notificationSettingsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$notificationSettingsNotifierHash(); + + @$internal + @override + NotificationSettingsNotifier create() => NotificationSettingsNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(NotificationSettings value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$notificationSettingsNotifierHash() => + r'0d04ca73c4724bb84ce8d92608cd238cb362254a'; + +abstract class _$NotificationSettingsNotifier + extends $Notifier { + NotificationSettings build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + NotificationSettings, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/test/core/notifications/notification_settings_notifier_test.dart b/test/core/notifications/notification_settings_notifier_test.dart index 05ad729..3ac44ed 100644 --- a/test/core/notifications/notification_settings_notifier_test.dart +++ b/test/core/notifications/notification_settings_notifier_test.dart @@ -5,6 +5,16 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:household_keeper/core/notifications/notification_settings_notifier.dart'; +/// Helper: create a container and wait for the initial async _load() to finish. +Future makeContainer() async { + final container = ProviderContainer(); + // Trigger build + container.read(notificationSettingsProvider); + // Allow the async _load() to complete + await Future.delayed(Duration.zero); + return container; +} + void main() { setUp(() { SharedPreferences.setMockInitialValues({}); @@ -15,32 +25,32 @@ void main() { final container = ProviderContainer(); addTearDown(container.dispose); - final state = container.read(notificationSettingsNotifierProvider); + final state = container.read(notificationSettingsProvider); expect(state.enabled, isFalse); expect(state.time, const TimeOfDay(hour: 7, minute: 0)); }); test('setEnabled(true) updates state.enabled to true', () async { - final container = ProviderContainer(); + final container = await makeContainer(); addTearDown(container.dispose); await container - .read(notificationSettingsNotifierProvider.notifier) + .read(notificationSettingsProvider.notifier) .setEnabled(true); expect( - container.read(notificationSettingsNotifierProvider).enabled, + container.read(notificationSettingsProvider).enabled, isTrue, ); }); test('setEnabled(true) persists to SharedPreferences', () async { - final container = ProviderContainer(); + final container = await makeContainer(); addTearDown(container.dispose); await container - .read(notificationSettingsNotifierProvider.notifier) + .read(notificationSettingsProvider.notifier) .setEnabled(true); final prefs = await SharedPreferences.getInstance(); @@ -48,32 +58,32 @@ void main() { }); test('setEnabled(false) updates state.enabled to false', () async { - final container = ProviderContainer(); + final container = await makeContainer(); addTearDown(container.dispose); // First enable, then disable await container - .read(notificationSettingsNotifierProvider.notifier) + .read(notificationSettingsProvider.notifier) .setEnabled(true); await container - .read(notificationSettingsNotifierProvider.notifier) + .read(notificationSettingsProvider.notifier) .setEnabled(false); expect( - container.read(notificationSettingsNotifierProvider).enabled, + container.read(notificationSettingsProvider).enabled, isFalse, ); }); test('setEnabled(false) persists to SharedPreferences', () async { - final container = ProviderContainer(); + final container = await makeContainer(); addTearDown(container.dispose); await container - .read(notificationSettingsNotifierProvider.notifier) + .read(notificationSettingsProvider.notifier) .setEnabled(true); await container - .read(notificationSettingsNotifierProvider.notifier) + .read(notificationSettingsProvider.notifier) .setEnabled(false); final prefs = await SharedPreferences.getInstance(); @@ -83,15 +93,15 @@ void main() { test( 'setTime(09:30) updates state.time and persists hour+minute', () async { - final container = ProviderContainer(); + final container = await makeContainer(); addTearDown(container.dispose); await container - .read(notificationSettingsNotifierProvider.notifier) + .read(notificationSettingsProvider.notifier) .setTime(const TimeOfDay(hour: 9, minute: 30)); expect( - container.read(notificationSettingsNotifierProvider).time, + container.read(notificationSettingsProvider).time, const TimeOfDay(hour: 9, minute: 30), ); @@ -110,16 +120,10 @@ void main() { 'notifications_minute': 15, }); - final container = ProviderContainer(); + final container = await makeContainer(); addTearDown(container.dispose); - // Trigger build - container.read(notificationSettingsNotifierProvider); - - // Wait for async _load() to complete - await Future.delayed(Duration.zero); - - final state = container.read(notificationSettingsNotifierProvider); + final state = container.read(notificationSettingsProvider); expect(state.enabled, isTrue); expect(state.time, const TimeOfDay(hour: 8, minute: 15)); },