Files
Rijad Zuzo f655adfbea 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.
2026-05-17 00:17:20 +02:00

106 lines
3.1 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
const String aiDigestNotificationPayload = 'ai_digest_review_inbox';
final Provider<AiDigestNotificationIntentBus>
aiDigestNotificationIntentBusProvider = Provider<AiDigestNotificationIntentBus>(
(Ref ref) {
final AiDigestNotificationIntentBus bus = AiDigestNotificationIntentBus();
ref.onDispose(() {
unawaited(bus.close());
});
return bus;
},
);
class AiDigestNotificationIntentBus {
final StreamController<String> _controller =
StreamController<String>.broadcast();
Stream<String> get intents => _controller.stream;
void emitTap(String payload) {
if (!_controller.isClosed) {
_controller.add(payload);
}
}
Future<void> close() => _controller.close();
}
abstract interface class AiDigestNotifier {
Future<void> showDigestReady(int count);
}
class LocalAiDigestNotifier implements AiDigestNotifier {
LocalAiDigestNotifier({
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> showDigestReady(int count) async {
if (!_supportsNotifications()) {
return;
}
await _ensureInitialized();
await _plugin.show(
id: 8124,
title: 'Relationship digest ready',
body:
'$count private ${count == 1 ? 'suggestion is' : 'suggestions are'} ready to review.',
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
'relationship_saver_ai_digest',
'Relationship Digest',
channelDescription: 'Private AI digest suggestions ready for review',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
),
iOS: DarwinNotificationDetails(),
macOS: DarwinNotificationDetails(),
),
payload: aiDigestNotificationPayload,
);
}
Future<void> _ensureInitialized() {
return _initializeFuture ??= _plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
macOS: DarwinInitializationSettings(),
),
onDidReceiveNotificationResponse: (NotificationResponse response) {
final String? payload = response.payload;
if (payload != null && payload.isNotEmpty) {
_onTapPayload?.call(payload);
}
},
);
}
bool _supportsNotifications() {
if (kIsWeb) {
return false;
}
return defaultTargetPlatform != TargetPlatform.linux;
}
}
final Provider<AiDigestNotifier> aiDigestNotifierProvider =
Provider<AiDigestNotifier>((Ref ref) {
return LocalAiDigestNotifier(
onTapPayload: ref.watch(aiDigestNotificationIntentBusProvider).emitTap,
);
});