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