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:
@@ -14,14 +14,13 @@ 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,
|
||||||
);
|
);
|
||||||
|
|||||||
52
lib/core/notifications/notification_settings_notifier.dart
Normal file
52
lib/core/notifications/notification_settings_notifier.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
lib/core/notifications/notification_settings_notifier.g.dart
Normal file
65
lib/core/notifications/notification_settings_notifier.g.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user