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)
This commit is contained in:
2026-03-16 14:57:04 +01:00
parent 0f6789becd
commit 4f72eac933
4 changed files with 151 additions and 31 deletions

View File

@@ -14,15 +14,14 @@ class NotificationService {
const android = AndroidInitializationSettings('@mipmap/ic_launcher'); const android = AndroidInitializationSettings('@mipmap/ic_launcher');
const settings = InitializationSettings(android: android); const settings = InitializationSettings(android: android);
await _plugin.initialize( await _plugin.initialize(
settings, settings: settings,
onDidReceiveNotificationResponse: _onTap, onDidReceiveNotificationResponse: _onTap,
); );
} }
Future<bool> requestPermission() async { Future<bool> requestPermission() async {
final android = _plugin final android = _plugin.resolvePlatformSpecificImplementation<
.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>();
AndroidFlutterLocalNotificationsPlugin>();
return await android?.requestNotificationsPermission() ?? false; return await android?.requestNotificationsPermission() ?? false;
} }
@@ -32,7 +31,7 @@ class NotificationService {
required String body, required String body,
}) async { }) async {
await _plugin.cancelAll(); await _plugin.cancelAll();
final scheduledDate = _nextInstanceOf(time); final scheduledDate = nextInstanceOf(time);
const details = NotificationDetails( const details = NotificationDetails(
android: AndroidNotificationDetails( android: AndroidNotificationDetails(
'daily_summary', 'daily_summary',
@@ -43,11 +42,11 @@ class NotificationService {
), ),
); );
await _plugin.zonedSchedule( await _plugin.zonedSchedule(
0, id: 0,
title: title, title: title,
body: body, body: body,
scheduledDate: scheduledDate, scheduledDate: scheduledDate,
details, notificationDetails: details,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.time, matchDateTimeComponents: DateTimeComponents.time,
); );

View File

@@ -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<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);
}
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);
}
}

View File

@@ -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<NotificationSettingsNotifier, NotificationSettings> {
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<NotificationSettings>(value),
);
}
}
String _$notificationSettingsNotifierHash() =>
r'0d04ca73c4724bb84ce8d92608cd238cb362254a';
abstract class _$NotificationSettingsNotifier
extends $Notifier<NotificationSettings> {
NotificationSettings build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<NotificationSettings, NotificationSettings>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<NotificationSettings, NotificationSettings>,
NotificationSettings,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -5,6 +5,16 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:household_keeper/core/notifications/notification_settings_notifier.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<ProviderContainer> makeContainer() async {
final container = ProviderContainer();
// Trigger build
container.read(notificationSettingsProvider);
// Allow the async _load() to complete
await Future<void>.delayed(Duration.zero);
return container;
}
void main() { void main() {
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
@@ -15,32 +25,32 @@ void main() {
final container = ProviderContainer(); final container = ProviderContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
final state = container.read(notificationSettingsNotifierProvider); final state = container.read(notificationSettingsProvider);
expect(state.enabled, isFalse); expect(state.enabled, isFalse);
expect(state.time, const TimeOfDay(hour: 7, minute: 0)); expect(state.time, const TimeOfDay(hour: 7, minute: 0));
}); });
test('setEnabled(true) updates state.enabled to true', () async { test('setEnabled(true) updates state.enabled to true', () async {
final container = ProviderContainer(); final container = await makeContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
await container await container
.read(notificationSettingsNotifierProvider.notifier) .read(notificationSettingsProvider.notifier)
.setEnabled(true); .setEnabled(true);
expect( expect(
container.read(notificationSettingsNotifierProvider).enabled, container.read(notificationSettingsProvider).enabled,
isTrue, isTrue,
); );
}); });
test('setEnabled(true) persists to SharedPreferences', () async { test('setEnabled(true) persists to SharedPreferences', () async {
final container = ProviderContainer(); final container = await makeContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
await container await container
.read(notificationSettingsNotifierProvider.notifier) .read(notificationSettingsProvider.notifier)
.setEnabled(true); .setEnabled(true);
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -48,32 +58,32 @@ void main() {
}); });
test('setEnabled(false) updates state.enabled to false', () async { test('setEnabled(false) updates state.enabled to false', () async {
final container = ProviderContainer(); final container = await makeContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
// First enable, then disable // First enable, then disable
await container await container
.read(notificationSettingsNotifierProvider.notifier) .read(notificationSettingsProvider.notifier)
.setEnabled(true); .setEnabled(true);
await container await container
.read(notificationSettingsNotifierProvider.notifier) .read(notificationSettingsProvider.notifier)
.setEnabled(false); .setEnabled(false);
expect( expect(
container.read(notificationSettingsNotifierProvider).enabled, container.read(notificationSettingsProvider).enabled,
isFalse, isFalse,
); );
}); });
test('setEnabled(false) persists to SharedPreferences', () async { test('setEnabled(false) persists to SharedPreferences', () async {
final container = ProviderContainer(); final container = await makeContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
await container await container
.read(notificationSettingsNotifierProvider.notifier) .read(notificationSettingsProvider.notifier)
.setEnabled(true); .setEnabled(true);
await container await container
.read(notificationSettingsNotifierProvider.notifier) .read(notificationSettingsProvider.notifier)
.setEnabled(false); .setEnabled(false);
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -83,15 +93,15 @@ void main() {
test( test(
'setTime(09:30) updates state.time and persists hour+minute', 'setTime(09:30) updates state.time and persists hour+minute',
() async { () async {
final container = ProviderContainer(); final container = await makeContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
await container await container
.read(notificationSettingsNotifierProvider.notifier) .read(notificationSettingsProvider.notifier)
.setTime(const TimeOfDay(hour: 9, minute: 30)); .setTime(const TimeOfDay(hour: 9, minute: 30));
expect( expect(
container.read(notificationSettingsNotifierProvider).time, container.read(notificationSettingsProvider).time,
const TimeOfDay(hour: 9, minute: 30), const TimeOfDay(hour: 9, minute: 30),
); );
@@ -110,16 +120,10 @@ void main() {
'notifications_minute': 15, 'notifications_minute': 15,
}); });
final container = ProviderContainer(); final container = await makeContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
// Trigger build final state = container.read(notificationSettingsProvider);
container.read(notificationSettingsNotifierProvider);
// Wait for async _load() to complete
await Future<void>.delayed(Duration.zero);
final state = container.read(notificationSettingsNotifierProvider);
expect(state.enabled, isTrue); expect(state.enabled, isTrue);
expect(state.time, const TimeOfDay(hour: 8, minute: 15)); expect(state.time, const TimeOfDay(hour: 8, minute: 15));
}, },