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:
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user