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