feat: add local-first private AI digest workflow

Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
This commit is contained in:
Rijad Zuzo
2026-05-17 00:17:20 +02:00
parent dab50abf0e
commit f655adfbea
212 changed files with 24178 additions and 15895 deletions
+9
View File
@@ -0,0 +1,9 @@
# Reminders Slice
This slice owns reminder rules and local scheduling behavior.
- `domain/reminder_models.dart`: reminder cadence and rule models.
- `presentation/reminders_view.dart`: reminder management screen.
- `application/`: notification listeners, local scheduling, and providers.
Work here when you change reminder UX or local notification behavior.
@@ -0,0 +1,4 @@
# Reminders Application
Reminder scheduling orchestration and provider wiring live here. Start here when
the behavior of reminder execution or platform scheduling needs to change.
@@ -0,0 +1,18 @@
import 'dart:async';
/// Broadcasts reminder notification tap intents to the UI layer.
class ReminderNotificationIntentBus {
final StreamController<String> _controller =
StreamController<String>.broadcast();
Stream<String> get intents => _controller.stream;
void emitTap(String reminderId) {
if (reminderId.trim().isEmpty) {
return;
}
_controller.add(reminderId.trim());
}
Future<void> close() => _controller.close();
}
@@ -0,0 +1,67 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/reminders/reminders_view.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
/// Listens for reminder notification taps and opens reminder management UI.
class ReminderNotificationListener extends ConsumerStatefulWidget {
const ReminderNotificationListener({required this.child, super.key});
final Widget child;
@override
ConsumerState<ReminderNotificationListener> createState() =>
_ReminderNotificationListenerState();
}
class _ReminderNotificationListenerState
extends ConsumerState<ReminderNotificationListener> {
StreamSubscription<String>? _subscription;
@override
void initState() {
super.initState();
_subscription = ref
.read(reminderNotificationIntentBusProvider)
.intents
.listen(_handleIntent);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
Future<void> _handleIntent(String reminderId) async {
if (!mounted) {
return;
}
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) =>
_ReminderNotificationScreen(reminderId: reminderId),
),
);
}
}
class _ReminderNotificationScreen extends StatelessWidget {
const _ReminderNotificationScreen({required this.reminderId});
final String reminderId;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Reminder $reminderId')),
body: const RemindersView(),
);
}
}
@@ -0,0 +1,27 @@
import 'package:relationship_saver/features/local/local_models.dart';
/// Schedules and reconciles reminder deliveries for current local state.
abstract class ReminderScheduler {
/// Reconciles platform schedules to match current enabled reminders.
Future<void> reconcile(List<ReminderRule> reminders);
/// Clears all platform-scheduled reminder jobs.
Future<void> clearAll();
/// Requests notification permissions where the platform requires them.
Future<bool> requestPermissions();
}
/// Default scheduler used until platform notifications are integrated.
class NoopReminderScheduler implements ReminderScheduler {
const NoopReminderScheduler();
@override
Future<void> clearAll() async {}
@override
Future<void> reconcile(List<ReminderRule> reminders) async {}
@override
Future<bool> requestPermissions() async => true;
}
@@ -0,0 +1,240 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_notification_intent_bus.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart';
import 'package:timezone/timezone.dart' as tz;
const String _channelId = 'relationship_saver_reminders';
const String _channelName = 'Relationship Reminders';
const String _channelDescription =
'Reminders for relationship plans and follow-ups';
/// Schedules reminders with local notifications where platform support exists.
class LocalNotificationsReminderScheduler implements ReminderScheduler {
LocalNotificationsReminderScheduler({
ReminderNotificationsDriver? driver,
DateTime Function()? now,
ReminderNotificationIntentBus? intentBus,
}) : _driver =
driver ??
FlutterReminderNotificationsDriver(onTapPayload: intentBus?.emitTap),
_now = now ?? DateTime.now;
final ReminderNotificationsDriver _driver;
final DateTime Function() _now;
@override
Future<void> clearAll() async {
if (!_supportsSchedulingPlatform()) {
return;
}
await _driver.ensureInitialized();
await _driver.cancelAll();
}
@override
Future<void> reconcile(List<ReminderRule> reminders) async {
if (!_supportsSchedulingPlatform()) {
return;
}
await _driver.ensureInitialized();
await _driver.cancelAll();
final DateTime now = _now();
final List<ReminderRule> enabled = reminders
.where((ReminderRule reminder) => reminder.enabled)
.toList(growable: false);
for (final ReminderRule reminder in enabled) {
final DateTimeComponents? repeating = _repeatComponents(reminder.cadence);
if (repeating == null && reminder.nextAt.isBefore(now)) {
continue;
}
await _driver.schedule(
id: _notificationId(reminder.id),
title: reminder.title,
body: _buildBody(reminder),
scheduledAt: reminder.nextAt,
repeatComponents: repeating,
payload: reminder.id,
);
}
}
@override
Future<bool> requestPermissions() async {
if (!_supportsSchedulingPlatform()) {
return false;
}
await _driver.ensureInitialized();
return _driver.requestPermissions();
}
DateTimeComponents? _repeatComponents(ReminderCadence cadence) {
return switch (cadence) {
ReminderCadence.daily => DateTimeComponents.time,
ReminderCadence.weekly => DateTimeComponents.dayOfWeekAndTime,
ReminderCadence.monthly => DateTimeComponents.dayOfMonthAndTime,
};
}
String _buildBody(ReminderRule reminder) {
final String frequency = switch (reminder.cadence) {
ReminderCadence.daily => 'daily',
ReminderCadence.weekly => 'weekly',
ReminderCadence.monthly => 'monthly',
};
return 'Your $frequency reminder is due now.';
}
bool _supportsSchedulingPlatform() {
if (kIsWeb) {
return false;
}
return defaultTargetPlatform != TargetPlatform.linux;
}
int _notificationId(String raw) {
int hash = 0;
for (final int unit in raw.codeUnits) {
hash = ((hash * 31) + unit) & 0x7fffffff;
}
return hash;
}
}
/// Driver wrapper around the notification plugin for easier testing.
abstract class ReminderNotificationsDriver {
Future<void> ensureInitialized();
Future<void> cancelAll();
Future<void> schedule({
required int id,
required String title,
required String body,
required DateTime scheduledAt,
required DateTimeComponents? repeatComponents,
required String payload,
});
Future<bool> requestPermissions();
}
class FlutterReminderNotificationsDriver
implements ReminderNotificationsDriver {
FlutterReminderNotificationsDriver({
FlutterLocalNotificationsPlugin? plugin,
void Function(String payload)? onTapPayload,
}) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(),
_onTapPayload = onTapPayload;
final FlutterLocalNotificationsPlugin _plugin;
final void Function(String payload)? _onTapPayload;
Future<void>? _initializeFuture;
@override
Future<void> ensureInitialized() {
return _initializeFuture ??= _initialize();
}
@override
Future<void> cancelAll() async {
await _plugin.cancelAll();
}
@override
Future<void> schedule({
required int id,
required String title,
required String body,
required DateTime scheduledAt,
required DateTimeComponents? repeatComponents,
required String payload,
}) async {
await _plugin.zonedSchedule(
id: id,
title: title,
body: body,
scheduledDate: tz.TZDateTime.from(scheduledAt.toUtc(), tz.UTC),
notificationDetails: _notificationDetails,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
matchDateTimeComponents: repeatComponents,
payload: payload,
);
}
@override
Future<bool> requestPermissions() async {
if (defaultTargetPlatform == TargetPlatform.android) {
return await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
false;
}
if (defaultTargetPlatform == TargetPlatform.iOS) {
return await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
false;
}
if (defaultTargetPlatform == TargetPlatform.macOS) {
return await _plugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
false;
}
return true;
}
Future<void> _initialize() async {
await _plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
macOS: DarwinInitializationSettings(),
linux: LinuxInitializationSettings(defaultActionName: 'Open reminders'),
windows: WindowsInitializationSettings(
appName: 'Relationship Saver',
appUserModelId: 'com.relationshipsaver.app',
guid: 'ee23c0af-5898-4d96-b599-cf2d7d0d37d7',
),
),
onDidReceiveNotificationResponse: (NotificationResponse response) {
final String? payload = response.payload;
if (payload == null || payload.isEmpty) {
return;
}
_onTapPayload?.call(payload);
},
);
}
NotificationDetails get _notificationDetails {
return const NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
channelDescription: _channelDescription,
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(),
macOS: DarwinNotificationDetails(),
linux: LinuxNotificationDetails(),
windows: WindowsNotificationDetails(),
);
}
}
@@ -0,0 +1,30 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_notification_intent_bus.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_local_notifications.dart';
/// Broadcasts reminder notification tap intents to interested UI listeners.
final Provider<ReminderNotificationIntentBus>
reminderNotificationIntentBusProvider = Provider<ReminderNotificationIntentBus>(
(Ref ref) {
final ReminderNotificationIntentBus bus = ReminderNotificationIntentBus();
ref.onDispose(() {
unawaited(bus.close());
});
return bus;
},
);
/// Provides reminder delivery scheduler implementation.
final Provider<ReminderScheduler> reminderSchedulerProvider =
Provider<ReminderScheduler>((Ref ref) {
if (AppConfig.enableLocalNotifications) {
return LocalNotificationsReminderScheduler(
intentBus: ref.watch(reminderNotificationIntentBusProvider),
);
}
return const NoopReminderScheduler();
});
+4
View File
@@ -0,0 +1,4 @@
# Reminders Domain
Reminder models and cadence enums live here. Keep this layer free of platform
notification details so it stays easy to test and reuse.
@@ -0,0 +1,71 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
/// Reminder cadence supported by the local scheduling layer.
enum ReminderCadence { daily, weekly, monthly }
/// Reminder rule stored locally and optionally synced later.
@immutable
class ReminderRule {
const ReminderRule({
required this.id,
required this.title,
required this.cadence,
required this.nextAt,
this.personId,
this.enabled = true,
});
final String id;
final String? personId;
final String title;
final ReminderCadence cadence;
final DateTime nextAt;
final bool enabled;
ReminderRule copyWith({
String? id,
String? personId,
String? title,
ReminderCadence? cadence,
DateTime? nextAt,
bool? enabled,
}) {
return ReminderRule(
id: id ?? this.id,
personId: personId ?? this.personId,
title: title ?? this.title,
cadence: cadence ?? this.cadence,
nextAt: nextAt ?? this.nextAt,
enabled: enabled ?? this.enabled,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'title': title,
'cadence': cadence.name,
'nextAt': nextAt.toUtc().toIso8601String(),
'enabled': enabled,
};
}
factory ReminderRule.fromJson(Map<String, dynamic> json) {
final String cadenceName =
json['cadence'] as String? ?? ReminderCadence.weekly.name;
return ReminderRule(
id: json['id'] as String,
personId: json['personId'] as String?,
title: json['title'] as String,
cadence: ReminderCadence.values.firstWhere(
(ReminderCadence cadence) => cadence.name == cadenceName,
orElse: () => ReminderCadence.weekly,
),
nextAt: DateTime.parse(json['nextAt'] as String).toLocal(),
enabled: json['enabled'] as bool? ?? true,
);
}
}
@@ -0,0 +1,4 @@
# Reminders Presentation
Reminder list and editor UI belong here. Use this folder for reminder-facing
widgets, not for scheduling adapters or background execution.
@@ -0,0 +1,561 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
class RemindersView extends ConsumerWidget {
const RemindersView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return localData.when(
data: (LocalDataState data) {
final List<ReminderRule> reminders = data.reminders;
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in data.people) person.id: person,
};
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Reminders',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Set recurring nudges so good intentions become habits.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () =>
_addReminder(context, ref, data.people),
icon: const Icon(Icons.alarm_add_rounded),
label: const Text('Add Reminder'),
),
],
)
else
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Reminders',
style: Theme.of(
context,
).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Set recurring nudges so good intentions become habits.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
],
),
),
FilledButton.icon(
onPressed: () =>
_addReminder(context, ref, data.people),
icon: const Icon(Icons.alarm_add_rounded),
label: const Text('Add Reminder'),
),
],
),
const SizedBox(height: 16),
Expanded(
child: ListView.separated(
itemCount: reminders.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final ReminderRule reminder = reminders[index];
final PersonProfile? person = reminder.personId == null
? null
: peopleById[reminder.personId!];
return FrostedCard(
child: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
reminder.title,
style: Theme.of(
context,
).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Switch(
value: reminder.enabled,
onChanged: (_) {
ref
.read(
localRepositoryProvider
.notifier,
)
.toggleReminderEnabled(
reminder.id,
);
},
),
],
),
Text(
'${_labelForCadence(reminder.cadence)}${_formatDateTime(reminder.nextAt)}',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: <Widget>[
OutlinedButton.icon(
onPressed: () => _editReminder(
context,
ref,
data.people,
reminder,
),
icon: const Icon(Icons.edit_rounded),
label: const Text('Edit'),
),
TextButton.icon(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteReminder(reminder.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
label: const Text('Delete'),
),
],
),
],
)
: Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
reminder.title,
style: Theme.of(
context,
).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${_labelForCadence(reminder.cadence)}${_formatDateTime(reminder.nextAt)}',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
Switch(
value: reminder.enabled,
onChanged: (_) {
ref
.read(
localRepositoryProvider.notifier,
)
.toggleReminderEnabled(reminder.id);
},
),
IconButton(
onPressed: () => _editReminder(
context,
ref,
data.people,
reminder,
),
icon: const Icon(Icons.edit_rounded),
tooltip: 'Edit',
),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider.notifier,
)
.deleteReminder(reminder.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete',
),
],
),
);
},
),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load reminders'));
},
);
}
Future<void> _addReminder(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
) async {
final _ReminderDraft? draft = await showDialog<_ReminderDraft>(
context: context,
builder: (BuildContext context) =>
_ReminderEditorDialog(title: 'Add Reminder', people: people),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addReminder(
title: draft.title,
cadence: draft.cadence,
nextAt: draft.nextAt,
personId: draft.personId,
);
}
Future<void> _editReminder(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
ReminderRule reminder,
) async {
final _ReminderDraft? draft = await showDialog<_ReminderDraft>(
context: context,
builder: (BuildContext context) => _ReminderEditorDialog(
title: 'Edit Reminder',
people: people,
initial: _ReminderDraft(
personId: reminder.personId,
title: reminder.title,
cadence: reminder.cadence,
nextAt: reminder.nextAt,
),
),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.updateReminder(
reminder.copyWith(
personId: draft.personId,
title: draft.title,
cadence: draft.cadence,
nextAt: draft.nextAt,
),
);
}
}
class _ReminderEditorDialog extends StatefulWidget {
const _ReminderEditorDialog({
required this.title,
required this.people,
this.initial,
});
final String title;
final List<PersonProfile> people;
final _ReminderDraft? initial;
@override
State<_ReminderEditorDialog> createState() => _ReminderEditorDialogState();
}
class _ReminderEditorDialogState extends State<_ReminderEditorDialog> {
late final TextEditingController _titleController;
late ReminderCadence _cadence;
late DateTime _nextAt;
String? _personId;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.initial?.title ?? '');
_cadence = widget.initial?.cadence ?? ReminderCadence.weekly;
_nextAt =
widget.initial?.nextAt ?? DateTime.now().add(const Duration(days: 2));
_personId = widget.initial?.personId;
}
@override
void dispose() {
_titleController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
DropdownButtonFormField<ReminderCadence>(
initialValue: _cadence,
items: ReminderCadence.values
.map(
(ReminderCadence cadence) =>
DropdownMenuItem<ReminderCadence>(
value: cadence,
child: Text(_labelForCadence(cadence)),
),
)
.toList(growable: false),
onChanged: (ReminderCadence? value) {
if (value == null) {
return;
}
setState(() {
_cadence = value;
});
},
decoration: const InputDecoration(labelText: 'Cadence'),
),
DropdownButtonFormField<String?>(
initialValue: _personId,
items: <DropdownMenuItem<String?>>[
const DropdownMenuItem<String?>(
value: null,
child: Text('Unassigned'),
),
...widget.people.map(
(PersonProfile person) => DropdownMenuItem<String?>(
value: person.id,
child: Text(person.name),
),
),
],
onChanged: (String? value) {
setState(() {
_personId = value;
});
},
decoration: const InputDecoration(labelText: 'Person'),
),
const SizedBox(height: 12),
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 360;
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
_formatDateTime(_nextAt),
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _pickDateTime,
icon: const Icon(Icons.schedule_rounded),
label: const Text('Pick Time'),
),
],
);
}
return Row(
children: <Widget>[
Expanded(
child: Text(
_formatDateTime(_nextAt),
style: Theme.of(context).textTheme.bodyLarge,
),
),
OutlinedButton.icon(
onPressed: _pickDateTime,
icon: const Icon(Icons.schedule_rounded),
label: const Text('Pick Time'),
),
],
);
},
),
],
),
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final String title = _titleController.text.trim();
if (title.isEmpty) {
return;
}
Navigator.of(context).pop(
_ReminderDraft(
personId: _personId,
title: title,
cadence: _cadence,
nextAt: _nextAt,
),
);
},
child: const Text('Save'),
),
],
);
}
Future<void> _pickDateTime() async {
final DateTime now = DateTime.now();
final DateTime? date = await showDatePicker(
context: context,
initialDate: _nextAt,
firstDate: now.subtract(const Duration(days: 1)),
lastDate: now.add(const Duration(days: 3650)),
);
if (date == null || !mounted) {
return;
}
final TimeOfDay? time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_nextAt),
);
if (time == null || !mounted) {
return;
}
setState(() {
_nextAt = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
});
}
}
class _ReminderDraft {
const _ReminderDraft({
required this.personId,
required this.title,
required this.cadence,
required this.nextAt,
});
final String? personId;
final String title;
final ReminderCadence cadence;
final DateTime nextAt;
}
String _labelForCadence(ReminderCadence cadence) {
return switch (cadence) {
ReminderCadence.daily => 'Daily',
ReminderCadence.weekly => 'Weekly',
ReminderCadence.monthly => 'Monthly',
};
}
String _formatDateTime(DateTime value) {
final String month = value.month.toString().padLeft(2, '0');
final String day = value.day.toString().padLeft(2, '0');
final String hour = value.hour.toString().padLeft(2, '0');
final String minute = value.minute.toString().padLeft(2, '0');
return '$month/$day/${value.year} $hour:$minute';
}
+2 -561
View File
@@ -1,561 +1,2 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
class RemindersView extends ConsumerWidget {
const RemindersView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return localData.when(
data: (LocalDataState data) {
final List<ReminderRule> reminders = data.reminders;
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in data.people) person.id: person,
};
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Reminders',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Set recurring nudges so good intentions become habits.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () =>
_addReminder(context, ref, data.people),
icon: const Icon(Icons.alarm_add_rounded),
label: const Text('Add Reminder'),
),
],
)
else
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Reminders',
style: Theme.of(
context,
).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Set recurring nudges so good intentions become habits.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
],
),
),
FilledButton.icon(
onPressed: () =>
_addReminder(context, ref, data.people),
icon: const Icon(Icons.alarm_add_rounded),
label: const Text('Add Reminder'),
),
],
),
const SizedBox(height: 16),
Expanded(
child: ListView.separated(
itemCount: reminders.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final ReminderRule reminder = reminders[index];
final PersonProfile? person = reminder.personId == null
? null
: peopleById[reminder.personId!];
return FrostedCard(
child: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
reminder.title,
style: Theme.of(
context,
).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Switch(
value: reminder.enabled,
onChanged: (_) {
ref
.read(
localRepositoryProvider
.notifier,
)
.toggleReminderEnabled(
reminder.id,
);
},
),
],
),
Text(
'${_labelForCadence(reminder.cadence)}${_formatDateTime(reminder.nextAt)}',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: <Widget>[
OutlinedButton.icon(
onPressed: () => _editReminder(
context,
ref,
data.people,
reminder,
),
icon: const Icon(Icons.edit_rounded),
label: const Text('Edit'),
),
TextButton.icon(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteReminder(reminder.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
label: const Text('Delete'),
),
],
),
],
)
: Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
reminder.title,
style: Theme.of(
context,
).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${_labelForCadence(reminder.cadence)}${_formatDateTime(reminder.nextAt)}',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
Switch(
value: reminder.enabled,
onChanged: (_) {
ref
.read(
localRepositoryProvider.notifier,
)
.toggleReminderEnabled(reminder.id);
},
),
IconButton(
onPressed: () => _editReminder(
context,
ref,
data.people,
reminder,
),
icon: const Icon(Icons.edit_rounded),
tooltip: 'Edit',
),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider.notifier,
)
.deleteReminder(reminder.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete',
),
],
),
);
},
),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load reminders'));
},
);
}
Future<void> _addReminder(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
) async {
final _ReminderDraft? draft = await showDialog<_ReminderDraft>(
context: context,
builder: (BuildContext context) =>
_ReminderEditorDialog(title: 'Add Reminder', people: people),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addReminder(
title: draft.title,
cadence: draft.cadence,
nextAt: draft.nextAt,
personId: draft.personId,
);
}
Future<void> _editReminder(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
ReminderRule reminder,
) async {
final _ReminderDraft? draft = await showDialog<_ReminderDraft>(
context: context,
builder: (BuildContext context) => _ReminderEditorDialog(
title: 'Edit Reminder',
people: people,
initial: _ReminderDraft(
personId: reminder.personId,
title: reminder.title,
cadence: reminder.cadence,
nextAt: reminder.nextAt,
),
),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.updateReminder(
reminder.copyWith(
personId: draft.personId,
title: draft.title,
cadence: draft.cadence,
nextAt: draft.nextAt,
),
);
}
}
class _ReminderEditorDialog extends StatefulWidget {
const _ReminderEditorDialog({
required this.title,
required this.people,
this.initial,
});
final String title;
final List<PersonProfile> people;
final _ReminderDraft? initial;
@override
State<_ReminderEditorDialog> createState() => _ReminderEditorDialogState();
}
class _ReminderEditorDialogState extends State<_ReminderEditorDialog> {
late final TextEditingController _titleController;
late ReminderCadence _cadence;
late DateTime _nextAt;
String? _personId;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.initial?.title ?? '');
_cadence = widget.initial?.cadence ?? ReminderCadence.weekly;
_nextAt =
widget.initial?.nextAt ?? DateTime.now().add(const Duration(days: 2));
_personId = widget.initial?.personId;
}
@override
void dispose() {
_titleController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
DropdownButtonFormField<ReminderCadence>(
initialValue: _cadence,
items: ReminderCadence.values
.map(
(ReminderCadence cadence) =>
DropdownMenuItem<ReminderCadence>(
value: cadence,
child: Text(_labelForCadence(cadence)),
),
)
.toList(growable: false),
onChanged: (ReminderCadence? value) {
if (value == null) {
return;
}
setState(() {
_cadence = value;
});
},
decoration: const InputDecoration(labelText: 'Cadence'),
),
DropdownButtonFormField<String?>(
initialValue: _personId,
items: <DropdownMenuItem<String?>>[
const DropdownMenuItem<String?>(
value: null,
child: Text('Unassigned'),
),
...widget.people.map(
(PersonProfile person) => DropdownMenuItem<String?>(
value: person.id,
child: Text(person.name),
),
),
],
onChanged: (String? value) {
setState(() {
_personId = value;
});
},
decoration: const InputDecoration(labelText: 'Person'),
),
const SizedBox(height: 12),
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 360;
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
_formatDateTime(_nextAt),
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _pickDateTime,
icon: const Icon(Icons.schedule_rounded),
label: const Text('Pick Time'),
),
],
);
}
return Row(
children: <Widget>[
Expanded(
child: Text(
_formatDateTime(_nextAt),
style: Theme.of(context).textTheme.bodyLarge,
),
),
OutlinedButton.icon(
onPressed: _pickDateTime,
icon: const Icon(Icons.schedule_rounded),
label: const Text('Pick Time'),
),
],
);
},
),
],
),
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final String title = _titleController.text.trim();
if (title.isEmpty) {
return;
}
Navigator.of(context).pop(
_ReminderDraft(
personId: _personId,
title: title,
cadence: _cadence,
nextAt: _nextAt,
),
);
},
child: const Text('Save'),
),
],
);
}
Future<void> _pickDateTime() async {
final DateTime now = DateTime.now();
final DateTime? date = await showDatePicker(
context: context,
initialDate: _nextAt,
firstDate: now.subtract(const Duration(days: 1)),
lastDate: now.add(const Duration(days: 3650)),
);
if (date == null || !mounted) {
return;
}
final TimeOfDay? time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_nextAt),
);
if (time == null || !mounted) {
return;
}
setState(() {
_nextAt = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
});
}
}
class _ReminderDraft {
const _ReminderDraft({
required this.personId,
required this.title,
required this.cadence,
required this.nextAt,
});
final String? personId;
final String title;
final ReminderCadence cadence;
final DateTime nextAt;
}
String _labelForCadence(ReminderCadence cadence) {
return switch (cadence) {
ReminderCadence.daily => 'Daily',
ReminderCadence.weekly => 'Weekly',
ReminderCadence.monthly => 'Monthly',
};
}
String _formatDateTime(DateTime value) {
final String month = value.month.toString().padLeft(2, '0');
final String day = value.day.toString().padLeft(2, '0');
final String hour = value.hour.toString().padLeft(2, '0');
final String minute = value.minute.toString().padLeft(2, '0');
return '$month/$day/${value.year} $hour:$minute';
}
// Legacy compatibility export for the reminders presentation entry.
export 'package:relationship_saver/features/reminders/presentation/reminders_view.dart';
@@ -0,0 +1,4 @@
# Reminder Scheduling
Platform notification adapters and scheduling listeners live here. This is the
boundary between reminder domain logic and device-specific behavior.
@@ -1,18 +1,2 @@
import 'dart:async';
/// Broadcasts reminder notification tap intents to the UI layer.
class ReminderNotificationIntentBus {
final StreamController<String> _controller =
StreamController<String>.broadcast();
Stream<String> get intents => _controller.stream;
void emitTap(String reminderId) {
if (reminderId.trim().isEmpty) {
return;
}
_controller.add(reminderId.trim());
}
Future<void> close() => _controller.close();
}
// Legacy compatibility export for reminder notification intent handling.
export 'package:relationship_saver/features/reminders/application/reminder_notification_intent_bus.dart';
@@ -1,67 +1,2 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/reminders/reminders_view.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
/// Listens for reminder notification taps and opens reminder management UI.
class ReminderNotificationListener extends ConsumerStatefulWidget {
const ReminderNotificationListener({required this.child, super.key});
final Widget child;
@override
ConsumerState<ReminderNotificationListener> createState() =>
_ReminderNotificationListenerState();
}
class _ReminderNotificationListenerState
extends ConsumerState<ReminderNotificationListener> {
StreamSubscription<String>? _subscription;
@override
void initState() {
super.initState();
_subscription = ref
.read(reminderNotificationIntentBusProvider)
.intents
.listen(_handleIntent);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
Future<void> _handleIntent(String reminderId) async {
if (!mounted) {
return;
}
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) =>
_ReminderNotificationScreen(reminderId: reminderId),
),
);
}
}
class _ReminderNotificationScreen extends StatelessWidget {
const _ReminderNotificationScreen({required this.reminderId});
final String reminderId;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Reminder $reminderId')),
body: const RemindersView(),
);
}
}
// Legacy compatibility export for the reminder notification listener.
export 'package:relationship_saver/features/reminders/application/reminder_notification_listener.dart';
@@ -1,27 +1,2 @@
import 'package:relationship_saver/features/local/local_models.dart';
/// Schedules and reconciles reminder deliveries for current local state.
abstract class ReminderScheduler {
/// Reconciles platform schedules to match current enabled reminders.
Future<void> reconcile(List<ReminderRule> reminders);
/// Clears all platform-scheduled reminder jobs.
Future<void> clearAll();
/// Requests notification permissions where the platform requires them.
Future<bool> requestPermissions();
}
/// Default scheduler used until platform notifications are integrated.
class NoopReminderScheduler implements ReminderScheduler {
const NoopReminderScheduler();
@override
Future<void> clearAll() async {}
@override
Future<void> reconcile(List<ReminderRule> reminders) async {}
@override
Future<bool> requestPermissions() async => true;
}
// Legacy compatibility export for the reminders scheduling service.
export 'package:relationship_saver/features/reminders/application/reminder_scheduler.dart';
@@ -1,240 +1,2 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_notification_intent_bus.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart';
import 'package:timezone/timezone.dart' as tz;
const String _channelId = 'relationship_saver_reminders';
const String _channelName = 'Relationship Reminders';
const String _channelDescription =
'Reminders for relationship plans and follow-ups';
/// Schedules reminders with local notifications where platform support exists.
class LocalNotificationsReminderScheduler implements ReminderScheduler {
LocalNotificationsReminderScheduler({
ReminderNotificationsDriver? driver,
DateTime Function()? now,
ReminderNotificationIntentBus? intentBus,
}) : _driver =
driver ??
FlutterReminderNotificationsDriver(onTapPayload: intentBus?.emitTap),
_now = now ?? DateTime.now;
final ReminderNotificationsDriver _driver;
final DateTime Function() _now;
@override
Future<void> clearAll() async {
if (!_supportsSchedulingPlatform()) {
return;
}
await _driver.ensureInitialized();
await _driver.cancelAll();
}
@override
Future<void> reconcile(List<ReminderRule> reminders) async {
if (!_supportsSchedulingPlatform()) {
return;
}
await _driver.ensureInitialized();
await _driver.cancelAll();
final DateTime now = _now();
final List<ReminderRule> enabled = reminders
.where((ReminderRule reminder) => reminder.enabled)
.toList(growable: false);
for (final ReminderRule reminder in enabled) {
final DateTimeComponents? repeating = _repeatComponents(reminder.cadence);
if (repeating == null && reminder.nextAt.isBefore(now)) {
continue;
}
await _driver.schedule(
id: _notificationId(reminder.id),
title: reminder.title,
body: _buildBody(reminder),
scheduledAt: reminder.nextAt,
repeatComponents: repeating,
payload: reminder.id,
);
}
}
@override
Future<bool> requestPermissions() async {
if (!_supportsSchedulingPlatform()) {
return false;
}
await _driver.ensureInitialized();
return _driver.requestPermissions();
}
DateTimeComponents? _repeatComponents(ReminderCadence cadence) {
return switch (cadence) {
ReminderCadence.daily => DateTimeComponents.time,
ReminderCadence.weekly => DateTimeComponents.dayOfWeekAndTime,
ReminderCadence.monthly => DateTimeComponents.dayOfMonthAndTime,
};
}
String _buildBody(ReminderRule reminder) {
final String frequency = switch (reminder.cadence) {
ReminderCadence.daily => 'daily',
ReminderCadence.weekly => 'weekly',
ReminderCadence.monthly => 'monthly',
};
return 'Your $frequency reminder is due now.';
}
bool _supportsSchedulingPlatform() {
if (kIsWeb) {
return false;
}
return defaultTargetPlatform != TargetPlatform.linux;
}
int _notificationId(String raw) {
int hash = 0;
for (final int unit in raw.codeUnits) {
hash = ((hash * 31) + unit) & 0x7fffffff;
}
return hash;
}
}
/// Driver wrapper around the notification plugin for easier testing.
abstract class ReminderNotificationsDriver {
Future<void> ensureInitialized();
Future<void> cancelAll();
Future<void> schedule({
required int id,
required String title,
required String body,
required DateTime scheduledAt,
required DateTimeComponents? repeatComponents,
required String payload,
});
Future<bool> requestPermissions();
}
class FlutterReminderNotificationsDriver
implements ReminderNotificationsDriver {
FlutterReminderNotificationsDriver({
FlutterLocalNotificationsPlugin? plugin,
void Function(String payload)? onTapPayload,
}) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(),
_onTapPayload = onTapPayload;
final FlutterLocalNotificationsPlugin _plugin;
final void Function(String payload)? _onTapPayload;
Future<void>? _initializeFuture;
@override
Future<void> ensureInitialized() {
return _initializeFuture ??= _initialize();
}
@override
Future<void> cancelAll() async {
await _plugin.cancelAll();
}
@override
Future<void> schedule({
required int id,
required String title,
required String body,
required DateTime scheduledAt,
required DateTimeComponents? repeatComponents,
required String payload,
}) async {
await _plugin.zonedSchedule(
id: id,
title: title,
body: body,
scheduledDate: tz.TZDateTime.from(scheduledAt.toUtc(), tz.UTC),
notificationDetails: _notificationDetails,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
matchDateTimeComponents: repeatComponents,
payload: payload,
);
}
@override
Future<bool> requestPermissions() async {
if (defaultTargetPlatform == TargetPlatform.android) {
return await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
false;
}
if (defaultTargetPlatform == TargetPlatform.iOS) {
return await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
false;
}
if (defaultTargetPlatform == TargetPlatform.macOS) {
return await _plugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
false;
}
return true;
}
Future<void> _initialize() async {
await _plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
macOS: DarwinInitializationSettings(),
linux: LinuxInitializationSettings(defaultActionName: 'Open reminders'),
windows: WindowsInitializationSettings(
appName: 'Relationship Saver',
appUserModelId: 'com.relationshipsaver.app',
guid: 'ee23c0af-5898-4d96-b599-cf2d7d0d37d7',
),
),
onDidReceiveNotificationResponse: (NotificationResponse response) {
final String? payload = response.payload;
if (payload == null || payload.isEmpty) {
return;
}
_onTapPayload?.call(payload);
},
);
}
NotificationDetails get _notificationDetails {
return const NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
channelDescription: _channelDescription,
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(),
macOS: DarwinNotificationDetails(),
linux: LinuxNotificationDetails(),
windows: WindowsNotificationDetails(),
);
}
}
// Legacy compatibility export for the local notifications reminder backend.
export 'package:relationship_saver/features/reminders/application/reminder_scheduler_local_notifications.dart';
@@ -1,30 +1,2 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_notification_intent_bus.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_local_notifications.dart';
/// Broadcasts reminder notification tap intents to interested UI listeners.
final Provider<ReminderNotificationIntentBus>
reminderNotificationIntentBusProvider = Provider<ReminderNotificationIntentBus>(
(Ref ref) {
final ReminderNotificationIntentBus bus = ReminderNotificationIntentBus();
ref.onDispose(() {
unawaited(bus.close());
});
return bus;
},
);
/// Provides reminder delivery scheduler implementation.
final Provider<ReminderScheduler> reminderSchedulerProvider =
Provider<ReminderScheduler>((Ref ref) {
if (AppConfig.enableLocalNotifications) {
return LocalNotificationsReminderScheduler(
intentBus: ref.watch(reminderNotificationIntentBusProvider),
);
}
return const NoopReminderScheduler();
});
// Legacy compatibility export for the reminder scheduler provider.
export 'package:relationship_saver/features/reminders/application/reminder_scheduler_provider.dart';