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,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<bool> 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,
|
||||
);
|
||||
|
||||
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';
|
||||
|
||||
/// 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() {
|
||||
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<void>.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));
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user