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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
const Uuid _uuid = Uuid();
|
||||
|
||||
class AiDigestParseResult {
|
||||
const AiDigestParseResult({required this.drafts, this.error});
|
||||
|
||||
final List<AiSuggestionDraft> drafts;
|
||||
final String? error;
|
||||
|
||||
bool get success => error == null;
|
||||
}
|
||||
|
||||
class AiDigestResponseParser {
|
||||
const AiDigestResponseParser({
|
||||
this.maxItems = 10,
|
||||
this.maxTitleChars = 90,
|
||||
this.maxDetailsChars = 420,
|
||||
});
|
||||
|
||||
final int maxItems;
|
||||
final int maxTitleChars;
|
||||
final int maxDetailsChars;
|
||||
|
||||
AiDigestParseResult parse({
|
||||
required String response,
|
||||
required Map<String, String> tokenToPersonId,
|
||||
required String sourceRunId,
|
||||
required DateTime suggestedAt,
|
||||
}) {
|
||||
try {
|
||||
final Object? decoded = jsonDecode(_extractJson(response));
|
||||
final List<dynamic> items = _extractItems(decoded);
|
||||
final List<AiSuggestionDraft> drafts = <AiSuggestionDraft>[];
|
||||
|
||||
for (final dynamic item in items.take(maxItems)) {
|
||||
if (item is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
final String? token = _string(item['personToken']);
|
||||
final String? personId = token == null ? null : tokenToPersonId[token];
|
||||
if (personId == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final AiSuggestionKind? kind = _kind(item['kind']);
|
||||
final String title = _trimAndCap(
|
||||
_string(item['title']) ?? '',
|
||||
maxTitleChars,
|
||||
);
|
||||
if (kind == null || title.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
drafts.add(
|
||||
AiSuggestionDraft(
|
||||
id: 'ai-${_uuid.v4()}',
|
||||
personId: personId,
|
||||
kind: kind,
|
||||
title: title,
|
||||
details: _trimAndCap(
|
||||
_string(item['details']) ?? '',
|
||||
maxDetailsChars,
|
||||
),
|
||||
suggestedAt: suggestedAt,
|
||||
suggestedFor:
|
||||
_dateOrNull(item['suggestedFor']) ??
|
||||
_dateOrNull(item['suggestedTiming']),
|
||||
confidence: _confidence(item['confidence']),
|
||||
status: AiSuggestionStatus.pending,
|
||||
sourceRunId: sourceRunId,
|
||||
reason: _trimAndCap(_string(item['reason']) ?? '', 240),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (drafts.isEmpty) {
|
||||
return const AiDigestParseResult(
|
||||
drafts: <AiSuggestionDraft>[],
|
||||
error: 'No valid digest suggestions returned.',
|
||||
);
|
||||
}
|
||||
|
||||
return AiDigestParseResult(drafts: drafts);
|
||||
} catch (error) {
|
||||
return AiDigestParseResult(
|
||||
drafts: const <AiSuggestionDraft>[],
|
||||
error: 'Unable to parse digest response: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _extractJson(String response) {
|
||||
final String trimmed = response.trim();
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
return trimmed;
|
||||
}
|
||||
final int objectStart = trimmed.indexOf('{');
|
||||
final int arrayStart = trimmed.indexOf('[');
|
||||
final int start = objectStart == -1
|
||||
? arrayStart
|
||||
: arrayStart == -1
|
||||
? objectStart
|
||||
: objectStart < arrayStart
|
||||
? objectStart
|
||||
: arrayStart;
|
||||
final int objectEnd = trimmed.lastIndexOf('}');
|
||||
final int arrayEnd = trimmed.lastIndexOf(']');
|
||||
final int end = objectEnd > arrayEnd ? objectEnd : arrayEnd;
|
||||
if (start < 0 || end < start) {
|
||||
throw const FormatException('No JSON object or array found.');
|
||||
}
|
||||
return trimmed.substring(start, end + 1);
|
||||
}
|
||||
|
||||
List<dynamic> _extractItems(Object? decoded) {
|
||||
if (decoded is List<dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final Object? suggestions = decoded['suggestions'] ?? decoded['items'];
|
||||
if (suggestions is List<dynamic>) {
|
||||
return suggestions;
|
||||
}
|
||||
}
|
||||
throw const FormatException('Digest response must be a JSON array.');
|
||||
}
|
||||
|
||||
AiSuggestionKind? _kind(Object? raw) {
|
||||
final String? value = _string(raw);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
for (final AiSuggestionKind kind in AiSuggestionKind.values) {
|
||||
if (kind.name == value) {
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _string(Object? raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
final String value = '$raw'.trim();
|
||||
return value.isEmpty ? null : value;
|
||||
}
|
||||
|
||||
DateTime? _dateOrNull(Object? raw) {
|
||||
final String? value = _string(raw);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return DateTime.tryParse(value)?.toLocal();
|
||||
}
|
||||
|
||||
double _confidence(Object? raw) {
|
||||
if (raw is num) {
|
||||
return raw.toDouble().clamp(0.0, 1.0).toDouble();
|
||||
}
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
String _trimAndCap(String value, int maxChars) {
|
||||
final String trimmed = value.trim();
|
||||
if (trimmed.length <= maxChars) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.substring(0, maxChars).trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:relationship_saver/app/state/local_data_state.dart';
|
||||
import 'package:relationship_saver/features/people/domain/person_models.dart';
|
||||
|
||||
class AnonymizedLlmDigestContext {
|
||||
const AnonymizedLlmDigestContext({
|
||||
required this.payload,
|
||||
required this.tokenToPersonId,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> payload;
|
||||
final Map<String, String> tokenToPersonId;
|
||||
|
||||
String toPromptJson() {
|
||||
return const JsonEncoder.withIndent(' ').convert(payload);
|
||||
}
|
||||
}
|
||||
|
||||
class AnonymizedLlmContextBuilder {
|
||||
const AnonymizedLlmContextBuilder({
|
||||
this.maxPeople = 20,
|
||||
this.maxSignalsPerPerson = 8,
|
||||
DateTime Function()? now,
|
||||
}) : _now = now ?? DateTime.now;
|
||||
|
||||
final int maxPeople;
|
||||
final int maxSignalsPerPerson;
|
||||
final DateTime Function() _now;
|
||||
|
||||
AnonymizedLlmDigestContext build(LocalDataState state) {
|
||||
final DateTime now = _now();
|
||||
final List<PersonProfile> selectedPeople =
|
||||
state.people
|
||||
.where(
|
||||
(PersonProfile person) => _isDigestRelevant(state, person, now),
|
||||
)
|
||||
.toList(growable: false)
|
||||
..sort(
|
||||
(PersonProfile a, PersonProfile b) => _urgencyScore(
|
||||
state,
|
||||
b,
|
||||
now,
|
||||
).compareTo(_urgencyScore(state, a, now)),
|
||||
);
|
||||
|
||||
final List<Map<String, dynamic>> peoplePayload = <Map<String, dynamic>>[];
|
||||
final Map<String, String> tokenToPersonId = <String, String>{};
|
||||
|
||||
for (int i = 0; i < math.min(selectedPeople.length, maxPeople); i += 1) {
|
||||
final PersonProfile person = selectedPeople[i];
|
||||
final String token = 'person_${(i + 1).toString().padLeft(3, '0')}';
|
||||
tokenToPersonId[token] = person.id;
|
||||
peoplePayload.add(_personPayload(state, person, token, now));
|
||||
}
|
||||
|
||||
return AnonymizedLlmDigestContext(
|
||||
tokenToPersonId: tokenToPersonId,
|
||||
payload: <String, dynamic>{
|
||||
'schema': 'relationship_saver_private_digest_v1',
|
||||
'task':
|
||||
'Create a balanced private weekly digest with gift ideas, event ideas, reminders, and check-ins.',
|
||||
'rules': <String>[
|
||||
'Use only personToken values from the input.',
|
||||
'Do not infer or ask for names.',
|
||||
'Return JSON only.',
|
||||
'Prefer practical suggestions that can be reviewed before saving.',
|
||||
],
|
||||
'limits': <String, dynamic>{
|
||||
'maxItems': 10,
|
||||
'allowedKinds': <String>[
|
||||
'giftIdea',
|
||||
'eventIdea',
|
||||
'checkIn',
|
||||
'reminder',
|
||||
],
|
||||
},
|
||||
'people': peoplePayload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _isDigestRelevant(
|
||||
LocalDataState state,
|
||||
PersonProfile person,
|
||||
DateTime now,
|
||||
) {
|
||||
if (_daysUntil(person.nextMoment, now).abs() <= 60) {
|
||||
return true;
|
||||
}
|
||||
final DateTime? last = person.lastInteractedAt;
|
||||
if (last == null || now.difference(last).inDays >= 14) {
|
||||
return true;
|
||||
}
|
||||
return state.importantDates.any(
|
||||
(PersonImportantDate value) =>
|
||||
value.personId == person.id &&
|
||||
!value.isSensitive &&
|
||||
_daysUntil(value.date, now).abs() <= 60,
|
||||
) ||
|
||||
state.preferenceSignals.any(
|
||||
(PersonPreferenceSignal signal) =>
|
||||
signal.personId == person.id &&
|
||||
signal.status != PreferenceSignalStatus.dismissed,
|
||||
);
|
||||
}
|
||||
|
||||
int _urgencyScore(LocalDataState state, PersonProfile person, DateTime now) {
|
||||
final int nextMomentDays = _daysUntil(person.nextMoment, now).abs();
|
||||
int score = math.max(0, 80 - nextMomentDays);
|
||||
final DateTime? last = person.lastInteractedAt;
|
||||
if (last == null) {
|
||||
score += 30;
|
||||
} else {
|
||||
score += math.min(40, now.difference(last).inDays);
|
||||
}
|
||||
score +=
|
||||
state.importantDates
|
||||
.where(
|
||||
(PersonImportantDate value) =>
|
||||
value.personId == person.id &&
|
||||
!value.isSensitive &&
|
||||
_daysUntil(value.date, now).abs() <= 60,
|
||||
)
|
||||
.length *
|
||||
10;
|
||||
return score;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _personPayload(
|
||||
LocalDataState state,
|
||||
PersonProfile person,
|
||||
String token,
|
||||
DateTime now,
|
||||
) {
|
||||
final List<PersonPreferenceSignal> signals = state.preferenceSignals
|
||||
.where(
|
||||
(PersonPreferenceSignal signal) =>
|
||||
signal.personId == person.id &&
|
||||
signal.status != PreferenceSignalStatus.dismissed,
|
||||
)
|
||||
.take(maxSignalsPerPerson)
|
||||
.toList(growable: false);
|
||||
|
||||
final List<PersonImportantDate> dates = state.importantDates
|
||||
.where(
|
||||
(PersonImportantDate value) =>
|
||||
value.personId == person.id && !value.isSensitive,
|
||||
)
|
||||
.take(maxSignalsPerPerson)
|
||||
.toList(growable: false);
|
||||
|
||||
return <String, dynamic>{
|
||||
'personToken': token,
|
||||
'relationshipCategory': _relationshipCategory(person.relationship),
|
||||
'affinityBand': _affinityBand(person.affinityScore),
|
||||
'upcoming': <String>[
|
||||
_relativeWindow('next planned moment', person.nextMoment, now),
|
||||
...dates.map(
|
||||
(PersonImportantDate value) => _relativeWindow(
|
||||
_safeCategory(value.classification),
|
||||
value.date,
|
||||
now,
|
||||
),
|
||||
),
|
||||
],
|
||||
'recency': _recency(person.lastInteractedAt, now),
|
||||
'interests': _safeList(<String>[
|
||||
...person.tags,
|
||||
...signals
|
||||
.where(
|
||||
(PersonPreferenceSignal signal) =>
|
||||
signal.polarity == PreferenceSignalPolarity.like,
|
||||
)
|
||||
.map((PersonPreferenceSignal signal) => signal.label),
|
||||
]).take(maxSignalsPerPerson).toList(growable: false),
|
||||
'constraints': _safeList(
|
||||
signals
|
||||
.where(
|
||||
(PersonPreferenceSignal signal) =>
|
||||
signal.polarity == PreferenceSignalPolarity.dislike,
|
||||
)
|
||||
.map((PersonPreferenceSignal signal) => 'avoid ${signal.label}')
|
||||
.toList(growable: false),
|
||||
).take(maxSignalsPerPerson).toList(growable: false),
|
||||
};
|
||||
}
|
||||
|
||||
String _relationshipCategory(String value) {
|
||||
final String normalized = value.toLowerCase();
|
||||
if (_containsAny(normalized, <String>[
|
||||
'partner',
|
||||
'spouse',
|
||||
'wife',
|
||||
'husband',
|
||||
])) {
|
||||
return 'partner';
|
||||
}
|
||||
if (_containsAny(normalized, <String>[
|
||||
'family',
|
||||
'sister',
|
||||
'brother',
|
||||
'mother',
|
||||
'father',
|
||||
'parent',
|
||||
'cousin',
|
||||
'aunt',
|
||||
'uncle',
|
||||
])) {
|
||||
return 'family';
|
||||
}
|
||||
if (normalized.contains('friend')) {
|
||||
return 'friend';
|
||||
}
|
||||
if (_containsAny(normalized, <String>['work', 'colleague', 'coworker'])) {
|
||||
return 'colleague';
|
||||
}
|
||||
return 'relationship';
|
||||
}
|
||||
|
||||
String _affinityBand(int score) {
|
||||
if (score >= 85) {
|
||||
return 'very close';
|
||||
}
|
||||
if (score >= 65) {
|
||||
return 'close';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
String _relativeWindow(String label, DateTime date, DateTime now) {
|
||||
final int days = _daysUntil(date, now);
|
||||
final String when = days == 0
|
||||
? 'today'
|
||||
: days > 0
|
||||
? 'in about $days days'
|
||||
: 'about ${days.abs()} days ago';
|
||||
return '${_safeCategory(label)} $when';
|
||||
}
|
||||
|
||||
String _recency(DateTime? lastInteractedAt, DateTime now) {
|
||||
if (lastInteractedAt == null) {
|
||||
return 'no recent interaction recorded';
|
||||
}
|
||||
final int days = now.difference(lastInteractedAt).inDays;
|
||||
if (days <= 1) {
|
||||
return 'contacted recently';
|
||||
}
|
||||
return 'last contact about $days days ago';
|
||||
}
|
||||
|
||||
List<String> _safeList(Iterable<String> values) {
|
||||
final List<String> out = <String>[];
|
||||
final Set<String> seen = <String>{};
|
||||
for (final String raw in values) {
|
||||
final String value = _safeCategory(raw);
|
||||
if (value.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
if (seen.add(value.toLowerCase())) {
|
||||
out.add(value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
String _safeCategory(String value) {
|
||||
return value
|
||||
.trim()
|
||||
.replaceAll(RegExp(r'https?://\S+'), '')
|
||||
.replaceAll(RegExp(r'[^a-zA-Z0-9 +&/-]+'), '')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
int _daysUntil(DateTime date, DateTime now) {
|
||||
return date.difference(DateTime(now.year, now.month, now.day)).inDays;
|
||||
}
|
||||
|
||||
bool _containsAny(String value, List<String> probes) {
|
||||
return probes.any(value.contains);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
const String llmDigestTaskUniqueName = 'relationship_saver_llm_digest_weekly';
|
||||
const String llmDigestTaskName = 'com.relationshipsaver.llm.digest';
|
||||
|
||||
abstract interface class LlmDigestBackgroundScheduler {
|
||||
Future<void> configure(LlmDigestConfigState config);
|
||||
|
||||
Future<void> cancel();
|
||||
}
|
||||
|
||||
class WorkmanagerLlmDigestBackgroundScheduler
|
||||
implements LlmDigestBackgroundScheduler {
|
||||
const WorkmanagerLlmDigestBackgroundScheduler({Workmanager? workmanager})
|
||||
: _workmanager = workmanager;
|
||||
|
||||
final Workmanager? _workmanager;
|
||||
|
||||
Workmanager get _driver => _workmanager ?? Workmanager();
|
||||
|
||||
@override
|
||||
Future<void> configure(LlmDigestConfigState config) async {
|
||||
if (!config.enabled) {
|
||||
await cancel();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _driver.registerPeriodicTask(
|
||||
llmDigestTaskUniqueName,
|
||||
llmDigestTaskName,
|
||||
frequency: const Duration(days: 7),
|
||||
initialDelay: _initialDelay(config, DateTime.now()),
|
||||
constraints: Constraints(
|
||||
networkType: config.requireWifi
|
||||
? NetworkType.unmetered
|
||||
: NetworkType.connected,
|
||||
requiresCharging: config.requireCharging,
|
||||
),
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
|
||||
);
|
||||
} on UnimplementedError {
|
||||
// Flutter tests and unsupported platforms have no Workmanager backend.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel() async {
|
||||
try {
|
||||
await _driver.cancelByUniqueName(llmDigestTaskUniqueName);
|
||||
} on UnimplementedError {
|
||||
// Flutter tests and unsupported platforms have no Workmanager backend.
|
||||
}
|
||||
}
|
||||
|
||||
Duration _initialDelay(LlmDigestConfigState config, DateTime now) {
|
||||
DateTime target = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
config.preferredHour,
|
||||
);
|
||||
final int dayDelta = (config.preferredWeekday - now.weekday) % 7;
|
||||
target = target.add(Duration(days: dayDelta));
|
||||
if (!target.isAfter(now)) {
|
||||
target = target.add(const Duration(days: 7));
|
||||
}
|
||||
return target.difference(now);
|
||||
}
|
||||
}
|
||||
|
||||
final Provider<LlmDigestBackgroundScheduler>
|
||||
llmDigestBackgroundSchedulerProvider = Provider<LlmDigestBackgroundScheduler>(
|
||||
(Ref ref) => const WorkmanagerLlmDigestBackgroundScheduler(),
|
||||
);
|
||||
|
||||
class LlmDigestBackgroundRunner extends ConsumerStatefulWidget {
|
||||
const LlmDigestBackgroundRunner({required this.child, super.key});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState<LlmDigestBackgroundRunner> createState() =>
|
||||
_LlmDigestBackgroundRunnerState();
|
||||
}
|
||||
|
||||
class _LlmDigestBackgroundRunnerState
|
||||
extends ConsumerState<LlmDigestBackgroundRunner> {
|
||||
ProviderSubscription<LlmDigestConfigState>? _subscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_initializeAndConfigure());
|
||||
_subscription = ref.listenManual<LlmDigestConfigState>(
|
||||
llmDigestConfigProvider,
|
||||
(LlmDigestConfigState? previous, LlmDigestConfigState next) {
|
||||
unawaited(
|
||||
ref.read(llmDigestBackgroundSchedulerProvider).configure(next),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _initializeAndConfigure() async {
|
||||
await ref.read(llmDigestConfigProvider.notifier).initialize();
|
||||
await ref
|
||||
.read(llmDigestBackgroundSchedulerProvider)
|
||||
.configure(ref.read(llmDigestConfigProvider));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:battery_plus/battery_plus.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
|
||||
|
||||
abstract interface class LlmDigestEnvironment {
|
||||
Future<bool> canRun(LlmDigestConfigState config);
|
||||
}
|
||||
|
||||
class DeviceLlmDigestEnvironment implements LlmDigestEnvironment {
|
||||
DeviceLlmDigestEnvironment({Connectivity? connectivity, Battery? battery})
|
||||
: _connectivity = connectivity ?? Connectivity(),
|
||||
_battery = battery ?? Battery();
|
||||
|
||||
final Connectivity _connectivity;
|
||||
final Battery _battery;
|
||||
|
||||
@override
|
||||
Future<bool> canRun(LlmDigestConfigState config) async {
|
||||
if (config.requireWifi) {
|
||||
final List<ConnectivityResult> results = await _connectivity
|
||||
.checkConnectivity();
|
||||
if (!results.contains(ConnectivityResult.wifi)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.requireCharging) {
|
||||
final BatteryState state = await _battery.batteryState;
|
||||
if (state != BatteryState.charging && state != BatteryState.full) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
final Provider<LlmDigestEnvironment> llmDigestEnvironmentProvider =
|
||||
Provider<LlmDigestEnvironment>((Ref ref) => DeviceLlmDigestEnvironment());
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/app/data/relationship_repository.dart';
|
||||
import 'package:relationship_saver/app/state/local_data_state.dart';
|
||||
import 'package:relationship_saver/core/llm/llm_config.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/ai_digest_response_parser.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/anonymized_llm_context_builder.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/llm_digest_environment.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/llm_digest_text_client.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/data/llm_digest_run_store.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
const Duration _scheduledCooldown = Duration(days: 7);
|
||||
const Duration _staleRunningAfter = Duration(minutes: 30);
|
||||
const Uuid _uuid = Uuid();
|
||||
|
||||
final Provider<bool> llmDigestApiKeyConfiguredProvider = Provider<bool>(
|
||||
(Ref ref) => ref.watch(llmConfigProvider).isConfigured,
|
||||
);
|
||||
|
||||
final Provider<Future<void> Function()> llmDigestInitializerProvider =
|
||||
Provider<Future<void> Function()>((Ref ref) {
|
||||
return () async {
|
||||
await ref.read(llmConfigProvider.notifier).initialize();
|
||||
await ref.read(llmDigestConfigProvider.notifier).initialize();
|
||||
};
|
||||
});
|
||||
|
||||
class LlmDigestRunResult {
|
||||
const LlmDigestRunResult({
|
||||
required this.started,
|
||||
required this.completed,
|
||||
this.createdDraftCount = 0,
|
||||
this.reason,
|
||||
});
|
||||
|
||||
final bool started;
|
||||
final bool completed;
|
||||
final int createdDraftCount;
|
||||
final String? reason;
|
||||
}
|
||||
|
||||
class LlmDigestOrchestrator {
|
||||
LlmDigestOrchestrator(this._ref, {DateTime Function()? now})
|
||||
: _now = now ?? DateTime.now;
|
||||
|
||||
final Ref _ref;
|
||||
final DateTime Function() _now;
|
||||
|
||||
Future<LlmDigestRunResult> runScheduledDigest() {
|
||||
return _run(bypassCooldown: false, notify: true);
|
||||
}
|
||||
|
||||
Future<LlmDigestRunResult> runManualDigest() {
|
||||
return _run(bypassCooldown: true, notify: true);
|
||||
}
|
||||
|
||||
Future<LlmDigestRunResult> _run({
|
||||
required bool bypassCooldown,
|
||||
required bool notify,
|
||||
}) async {
|
||||
await _ref.read(llmDigestInitializerProvider)();
|
||||
|
||||
final LlmDigestConfigState config = _ref.read(llmDigestConfigProvider);
|
||||
if (!config.enabled && !bypassCooldown) {
|
||||
return const LlmDigestRunResult(
|
||||
started: false,
|
||||
completed: false,
|
||||
reason: 'Digest is disabled.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!_ref.read(llmDigestApiKeyConfiguredProvider)) {
|
||||
await _markFailure('No LLM API key configured.');
|
||||
return const LlmDigestRunResult(
|
||||
started: false,
|
||||
completed: false,
|
||||
reason: 'No LLM API key configured.',
|
||||
);
|
||||
}
|
||||
|
||||
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
|
||||
final LlmDigestRunState before = await store.read();
|
||||
final DateTime now = _now();
|
||||
if (_isAlreadyRunning(before, now)) {
|
||||
return const LlmDigestRunResult(
|
||||
started: false,
|
||||
completed: false,
|
||||
reason: 'Digest is already running.',
|
||||
);
|
||||
}
|
||||
if (!bypassCooldown && !_cooldownAllowsRun(before, now)) {
|
||||
return const LlmDigestRunResult(
|
||||
started: false,
|
||||
completed: false,
|
||||
reason: 'Weekly digest cooldown is still active.',
|
||||
);
|
||||
}
|
||||
if (!bypassCooldown && !_failureBackoffAllowsRun(before, now)) {
|
||||
return const LlmDigestRunResult(
|
||||
started: false,
|
||||
completed: false,
|
||||
reason: 'Failure backoff is still active.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!await _ref.read(llmDigestEnvironmentProvider).canRun(config)) {
|
||||
return const LlmDigestRunResult(
|
||||
started: false,
|
||||
completed: false,
|
||||
reason: 'Network or charging policy is not satisfied.',
|
||||
);
|
||||
}
|
||||
|
||||
await store.write(
|
||||
before.copyWith(lastStartedAt: now, running: true, lastFailure: null),
|
||||
);
|
||||
|
||||
try {
|
||||
final LocalDataState state = await _ref.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
final AnonymizedLlmDigestContext context =
|
||||
const AnonymizedLlmContextBuilder().build(state);
|
||||
if (context.tokenToPersonId.isEmpty) {
|
||||
await store.write(
|
||||
before.copyWith(
|
||||
lastStartedAt: now,
|
||||
lastCompletedAt: now,
|
||||
running: false,
|
||||
lastFailure: null,
|
||||
consecutiveFailures: 0,
|
||||
),
|
||||
);
|
||||
return const LlmDigestRunResult(
|
||||
started: true,
|
||||
completed: true,
|
||||
reason: 'No eligible people for digest.',
|
||||
);
|
||||
}
|
||||
|
||||
final String sourceRunId = 'digest-${_uuid.v4()}';
|
||||
final String response = await _ref
|
||||
.read(llmDigestTextClientProvider)
|
||||
.complete(
|
||||
systemPrompt: _systemPrompt,
|
||||
userPrompt: context.toPromptJson(),
|
||||
);
|
||||
final AiDigestParseResult parsed = const AiDigestResponseParser().parse(
|
||||
response: response,
|
||||
tokenToPersonId: context.tokenToPersonId,
|
||||
sourceRunId: sourceRunId,
|
||||
suggestedAt: now,
|
||||
);
|
||||
|
||||
if (!parsed.success) {
|
||||
await _markFailure(parsed.error ?? 'Invalid digest response.');
|
||||
return LlmDigestRunResult(
|
||||
started: true,
|
||||
completed: false,
|
||||
reason: parsed.error,
|
||||
);
|
||||
}
|
||||
|
||||
await _ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.saveAiSuggestionDrafts(parsed.drafts);
|
||||
await store.write(
|
||||
LlmDigestRunState(
|
||||
lastStartedAt: now,
|
||||
lastCompletedAt: _now(),
|
||||
consecutiveFailures: 0,
|
||||
),
|
||||
);
|
||||
if (notify && parsed.drafts.isNotEmpty) {
|
||||
await _ref
|
||||
.read(aiDigestNotifierProvider)
|
||||
.showDigestReady(parsed.drafts.length);
|
||||
}
|
||||
return LlmDigestRunResult(
|
||||
started: true,
|
||||
completed: true,
|
||||
createdDraftCount: parsed.drafts.length,
|
||||
);
|
||||
} catch (error) {
|
||||
await _markFailure('$error');
|
||||
return LlmDigestRunResult(
|
||||
started: true,
|
||||
completed: false,
|
||||
reason: '$error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markFailure(String reason) async {
|
||||
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
|
||||
final LlmDigestRunState current = await store.read();
|
||||
await store.write(
|
||||
LlmDigestRunState(
|
||||
lastStartedAt: current.lastStartedAt,
|
||||
lastCompletedAt: current.lastCompletedAt,
|
||||
lastFailure: reason,
|
||||
consecutiveFailures: current.consecutiveFailures + 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isAlreadyRunning(LlmDigestRunState state, DateTime now) {
|
||||
final DateTime? started = state.lastStartedAt;
|
||||
return state.running &&
|
||||
started != null &&
|
||||
now.difference(started) < _staleRunningAfter;
|
||||
}
|
||||
|
||||
bool _cooldownAllowsRun(LlmDigestRunState state, DateTime now) {
|
||||
final DateTime? completed = state.lastCompletedAt;
|
||||
return completed == null || now.difference(completed) >= _scheduledCooldown;
|
||||
}
|
||||
|
||||
bool _failureBackoffAllowsRun(LlmDigestRunState state, DateTime now) {
|
||||
if (state.consecutiveFailures <= 0) {
|
||||
return true;
|
||||
}
|
||||
final DateTime? started = state.lastStartedAt;
|
||||
if (started == null) {
|
||||
return true;
|
||||
}
|
||||
final int multiplier = 1 << state.consecutiveFailures.clamp(0, 5).toInt();
|
||||
return now.difference(started) >= Duration(hours: multiplier);
|
||||
}
|
||||
}
|
||||
|
||||
const String _systemPrompt = '''
|
||||
You create private relationship-maintenance digest suggestions.
|
||||
The input contains pseudonymous people only. Never ask for or invent names.
|
||||
Return exactly one JSON object:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"personToken": "person_001",
|
||||
"kind": "giftIdea" | "eventIdea" | "checkIn" | "reminder",
|
||||
"title": "short action title",
|
||||
"details": "practical details for the user to review",
|
||||
"suggestedTiming": "optional ISO-8601 date or null",
|
||||
"confidence": 0.0,
|
||||
"reason": "brief non-sensitive rationale"
|
||||
}
|
||||
]
|
||||
}
|
||||
Return at most 10 suggestions. Use only personToken values present in input.
|
||||
''';
|
||||
|
||||
final Provider<LlmDigestOrchestrator> llmDigestOrchestratorProvider =
|
||||
Provider<LlmDigestOrchestrator>((Ref ref) => LlmDigestOrchestrator(ref));
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/llm/llm_service.dart';
|
||||
|
||||
abstract interface class LlmDigestTextClient {
|
||||
Future<String> complete({
|
||||
required String systemPrompt,
|
||||
required String userPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
class ConfiguredLlmDigestTextClient implements LlmDigestTextClient {
|
||||
const ConfiguredLlmDigestTextClient(this._service);
|
||||
|
||||
final LlmService _service;
|
||||
|
||||
@override
|
||||
Future<String> complete({
|
||||
required String systemPrompt,
|
||||
required String userPrompt,
|
||||
}) {
|
||||
return _service.completeText(
|
||||
systemPrompt: systemPrompt,
|
||||
userPrompt: userPrompt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final Provider<LlmDigestTextClient> llmDigestTextClientProvider =
|
||||
Provider<LlmDigestTextClient>((Ref ref) {
|
||||
return ConfiguredLlmDigestTextClient(ref.watch(llmServiceProvider));
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const String _enabledKey = 'llm_digest_enabled';
|
||||
const String _preferredWeekdayKey = 'llm_digest_preferred_weekday';
|
||||
const String _preferredHourKey = 'llm_digest_preferred_hour';
|
||||
const String _requireWifiKey = 'llm_digest_require_wifi';
|
||||
const String _requireChargingKey = 'llm_digest_require_charging';
|
||||
|
||||
class LlmDigestConfigNotifier extends Notifier<LlmDigestConfigState> {
|
||||
Future<void>? _initializeFuture;
|
||||
|
||||
@override
|
||||
LlmDigestConfigState build() {
|
||||
return const LlmDigestConfigState();
|
||||
}
|
||||
|
||||
Future<void> initialize() {
|
||||
_initializeFuture ??= _initializeFromStorage();
|
||||
return _initializeFuture!;
|
||||
}
|
||||
|
||||
Future<void> _initializeFromStorage() async {
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
state = LlmDigestConfigState(
|
||||
enabled: prefs.getBool(_enabledKey) ?? false,
|
||||
preferredWeekday: prefs.getInt(_preferredWeekdayKey) ?? DateTime.sunday,
|
||||
preferredHour: prefs.getInt(_preferredHourKey) ?? 21,
|
||||
requireWifi: prefs.getBool(_requireWifiKey) ?? true,
|
||||
requireCharging: prefs.getBool(_requireChargingKey) ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_enabledKey, enabled);
|
||||
state = state.copyWith(enabled: enabled);
|
||||
}
|
||||
|
||||
Future<void> setPreferredRunWindow({
|
||||
required int weekday,
|
||||
required int hour,
|
||||
}) async {
|
||||
final int normalizedWeekday = weekday.clamp(
|
||||
DateTime.monday,
|
||||
DateTime.sunday,
|
||||
);
|
||||
final int normalizedHour = hour.clamp(0, 23);
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_preferredWeekdayKey, normalizedWeekday);
|
||||
await prefs.setInt(_preferredHourKey, normalizedHour);
|
||||
state = state.copyWith(
|
||||
preferredWeekday: normalizedWeekday,
|
||||
preferredHour: normalizedHour,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setNetworkPolicy({
|
||||
required bool requireWifi,
|
||||
required bool requireCharging,
|
||||
}) async {
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_requireWifiKey, requireWifi);
|
||||
await prefs.setBool(_requireChargingKey, requireCharging);
|
||||
state = state.copyWith(
|
||||
requireWifi: requireWifi,
|
||||
requireCharging: requireCharging,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final NotifierProvider<LlmDigestConfigNotifier, LlmDigestConfigState>
|
||||
llmDigestConfigProvider =
|
||||
NotifierProvider<LlmDigestConfigNotifier, LlmDigestConfigState>(
|
||||
LlmDigestConfigNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const String _runStateKey = 'llm_digest_run_state';
|
||||
|
||||
class LlmDigestRunStore {
|
||||
const LlmDigestRunStore();
|
||||
|
||||
Future<LlmDigestRunState> read() async {
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
final String? raw = prefs.getString(_runStateKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return const LlmDigestRunState();
|
||||
}
|
||||
try {
|
||||
return LlmDigestRunState.fromJson(
|
||||
jsonDecode(raw) as Map<String, dynamic>,
|
||||
);
|
||||
} on FormatException {
|
||||
return const LlmDigestRunState();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> write(LlmDigestRunState state) async {
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_runStateKey, jsonEncode(state.toJson()));
|
||||
}
|
||||
}
|
||||
|
||||
final Provider<LlmDigestRunStore> llmDigestRunStoreProvider =
|
||||
Provider<LlmDigestRunStore>((Ref ref) => const LlmDigestRunStore());
|
||||
@@ -0,0 +1,207 @@
|
||||
// ignore_for_file: sort_constructors_first
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum AiSuggestionKind { giftIdea, eventIdea, checkIn, reminder }
|
||||
|
||||
enum AiSuggestionStatus { pending, accepted, dismissed }
|
||||
|
||||
enum LlmDigestCadence { weekly }
|
||||
|
||||
@immutable
|
||||
class AiSuggestionDraft {
|
||||
const AiSuggestionDraft({
|
||||
required this.id,
|
||||
required this.personId,
|
||||
required this.kind,
|
||||
required this.title,
|
||||
required this.details,
|
||||
required this.suggestedAt,
|
||||
required this.confidence,
|
||||
required this.status,
|
||||
required this.sourceRunId,
|
||||
this.suggestedFor,
|
||||
this.reason,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String personId;
|
||||
final AiSuggestionKind kind;
|
||||
final String title;
|
||||
final String details;
|
||||
final DateTime suggestedAt;
|
||||
final DateTime? suggestedFor;
|
||||
final double confidence;
|
||||
final AiSuggestionStatus status;
|
||||
final String sourceRunId;
|
||||
final String? reason;
|
||||
|
||||
AiSuggestionDraft copyWith({
|
||||
String? id,
|
||||
String? personId,
|
||||
AiSuggestionKind? kind,
|
||||
String? title,
|
||||
String? details,
|
||||
DateTime? suggestedAt,
|
||||
DateTime? suggestedFor,
|
||||
double? confidence,
|
||||
AiSuggestionStatus? status,
|
||||
String? sourceRunId,
|
||||
String? reason,
|
||||
}) {
|
||||
return AiSuggestionDraft(
|
||||
id: id ?? this.id,
|
||||
personId: personId ?? this.personId,
|
||||
kind: kind ?? this.kind,
|
||||
title: title ?? this.title,
|
||||
details: details ?? this.details,
|
||||
suggestedAt: suggestedAt ?? this.suggestedAt,
|
||||
suggestedFor: suggestedFor ?? this.suggestedFor,
|
||||
confidence: confidence ?? this.confidence,
|
||||
status: status ?? this.status,
|
||||
sourceRunId: sourceRunId ?? this.sourceRunId,
|
||||
reason: reason ?? this.reason,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'personId': personId,
|
||||
'kind': kind.name,
|
||||
'title': title,
|
||||
'details': details,
|
||||
'suggestedAt': suggestedAt.toUtc().toIso8601String(),
|
||||
'suggestedFor': suggestedFor?.toUtc().toIso8601String(),
|
||||
'confidence': confidence,
|
||||
'status': status.name,
|
||||
'sourceRunId': sourceRunId,
|
||||
'reason': reason,
|
||||
};
|
||||
}
|
||||
|
||||
factory AiSuggestionDraft.fromJson(Map<String, dynamic> json) {
|
||||
final String kindName =
|
||||
json['kind'] as String? ?? AiSuggestionKind.checkIn.name;
|
||||
final String statusName =
|
||||
json['status'] as String? ?? AiSuggestionStatus.pending.name;
|
||||
return AiSuggestionDraft(
|
||||
id: json['id'] as String,
|
||||
personId: json['personId'] as String,
|
||||
kind: AiSuggestionKind.values.firstWhere(
|
||||
(AiSuggestionKind value) => value.name == kindName,
|
||||
orElse: () => AiSuggestionKind.checkIn,
|
||||
),
|
||||
title: json['title'] as String? ?? 'Suggestion',
|
||||
details: json['details'] as String? ?? '',
|
||||
suggestedAt: DateTime.parse(
|
||||
json['suggestedAt'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
).toLocal(),
|
||||
suggestedFor: json['suggestedFor'] == null
|
||||
? null
|
||||
: DateTime.parse(json['suggestedFor'] as String).toLocal(),
|
||||
confidence: (json['confidence'] as num?)?.toDouble() ?? 0,
|
||||
status: AiSuggestionStatus.values.firstWhere(
|
||||
(AiSuggestionStatus value) => value.name == statusName,
|
||||
orElse: () => AiSuggestionStatus.pending,
|
||||
),
|
||||
sourceRunId: json['sourceRunId'] as String? ?? 'unknown-run',
|
||||
reason: json['reason'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class LlmDigestRunState {
|
||||
const LlmDigestRunState({
|
||||
this.lastStartedAt,
|
||||
this.lastCompletedAt,
|
||||
this.lastFailure,
|
||||
this.consecutiveFailures = 0,
|
||||
this.running = false,
|
||||
});
|
||||
|
||||
final DateTime? lastStartedAt;
|
||||
final DateTime? lastCompletedAt;
|
||||
final String? lastFailure;
|
||||
final int consecutiveFailures;
|
||||
final bool running;
|
||||
|
||||
LlmDigestRunState copyWith({
|
||||
DateTime? lastStartedAt,
|
||||
DateTime? lastCompletedAt,
|
||||
String? lastFailure,
|
||||
int? consecutiveFailures,
|
||||
bool? running,
|
||||
}) {
|
||||
return LlmDigestRunState(
|
||||
lastStartedAt: lastStartedAt ?? this.lastStartedAt,
|
||||
lastCompletedAt: lastCompletedAt ?? this.lastCompletedAt,
|
||||
lastFailure: lastFailure,
|
||||
consecutiveFailures: consecutiveFailures ?? this.consecutiveFailures,
|
||||
running: running ?? this.running,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'lastStartedAt': lastStartedAt?.toUtc().toIso8601String(),
|
||||
'lastCompletedAt': lastCompletedAt?.toUtc().toIso8601String(),
|
||||
'lastFailure': lastFailure,
|
||||
'consecutiveFailures': consecutiveFailures,
|
||||
'running': running,
|
||||
};
|
||||
}
|
||||
|
||||
factory LlmDigestRunState.fromJson(Map<String, dynamic> json) {
|
||||
return LlmDigestRunState(
|
||||
lastStartedAt: json['lastStartedAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['lastStartedAt'] as String).toLocal(),
|
||||
lastCompletedAt: json['lastCompletedAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['lastCompletedAt'] as String).toLocal(),
|
||||
lastFailure: json['lastFailure'] as String?,
|
||||
consecutiveFailures: (json['consecutiveFailures'] as num?)?.toInt() ?? 0,
|
||||
running: json['running'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class LlmDigestConfigState {
|
||||
const LlmDigestConfigState({
|
||||
this.enabled = false,
|
||||
this.cadence = LlmDigestCadence.weekly,
|
||||
this.preferredWeekday = DateTime.sunday,
|
||||
this.preferredHour = 21,
|
||||
this.requireWifi = true,
|
||||
this.requireCharging = true,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final LlmDigestCadence cadence;
|
||||
final int preferredWeekday;
|
||||
final int preferredHour;
|
||||
final bool requireWifi;
|
||||
final bool requireCharging;
|
||||
|
||||
LlmDigestConfigState copyWith({
|
||||
bool? enabled,
|
||||
LlmDigestCadence? cadence,
|
||||
int? preferredWeekday,
|
||||
int? preferredHour,
|
||||
bool? requireWifi,
|
||||
bool? requireCharging,
|
||||
}) {
|
||||
return LlmDigestConfigState(
|
||||
enabled: enabled ?? this.enabled,
|
||||
cadence: cadence ?? this.cadence,
|
||||
preferredWeekday: preferredWeekday ?? this.preferredWeekday,
|
||||
preferredHour: preferredHour ?? this.preferredHour,
|
||||
requireWifi: requireWifi ?? this.requireWifi,
|
||||
requireCharging: requireCharging ?? this.requireCharging,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_review_view.dart';
|
||||
|
||||
class AiDigestNotificationListener extends ConsumerStatefulWidget {
|
||||
const AiDigestNotificationListener({required this.child, super.key});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState<AiDigestNotificationListener> createState() =>
|
||||
_AiDigestNotificationListenerState();
|
||||
}
|
||||
|
||||
class _AiDigestNotificationListenerState
|
||||
extends ConsumerState<AiDigestNotificationListener> {
|
||||
StreamSubscription<String>? _subscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_subscription = ref
|
||||
.read(aiDigestNotificationIntentBusProvider)
|
||||
.intents
|
||||
.listen(_handleIntent);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
|
||||
Future<void> _handleIntent(String payload) async {
|
||||
if (!mounted || payload != aiDigestNotificationPayload) {
|
||||
return;
|
||||
}
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (BuildContext context) => const AiDigestReviewView(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/app/data/relationship_repository.dart';
|
||||
import 'package:relationship_saver/app/state/local_data_state.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
|
||||
import 'package:relationship_saver/features/people/domain/person_models.dart';
|
||||
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||
|
||||
class AiDigestReviewView extends ConsumerWidget {
|
||||
const AiDigestReviewView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final AsyncValue<LocalDataState> localData = ref.watch(
|
||||
localRepositoryProvider,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(title: const Text('AI Review Inbox')),
|
||||
body: localData.when(
|
||||
data: (LocalDataState data) => _AiDigestReviewContent(data: data),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (Object error, StackTrace stackTrace) =>
|
||||
Center(child: Text('Unable to load AI suggestions. $error')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AiDigestReviewContent extends ConsumerWidget {
|
||||
const _AiDigestReviewContent({required this.data});
|
||||
|
||||
final LocalDataState data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final List<AiSuggestionDraft> pending = data.aiSuggestionDrafts
|
||||
.where(
|
||||
(AiSuggestionDraft draft) =>
|
||||
draft.status == AiSuggestionStatus.pending,
|
||||
)
|
||||
.toList(growable: false);
|
||||
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 SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
compact ? 16 : 28,
|
||||
compact ? 16 : 24,
|
||||
compact ? 16 : 28,
|
||||
28,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Private suggestions',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Review each suggestion before it changes local data.',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (pending.isEmpty)
|
||||
const FrostedCard(child: Text('No pending AI suggestions.'))
|
||||
else
|
||||
...pending.map(
|
||||
(AiSuggestionDraft draft) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _AiSuggestionCard(
|
||||
draft: draft,
|
||||
person: peopleById[draft.personId],
|
||||
onAccept: () => ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.acceptAiSuggestionDraft(draft.id),
|
||||
onDismiss: () => ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.dismissAiSuggestionDraft(draft.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AiSuggestionCard extends StatelessWidget {
|
||||
const _AiSuggestionCard({
|
||||
required this.draft,
|
||||
required this.person,
|
||||
required this.onAccept,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
final AiSuggestionDraft draft;
|
||||
final PersonProfile? person;
|
||||
final VoidCallback onAccept;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FrostedCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Icon(_iconForKind(draft.kind), color: AppTheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
draft.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${_kindLabel(draft.kind)} · ${person?.name ?? 'Unknown person'}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (draft.details.isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: 12),
|
||||
Text(draft.details),
|
||||
],
|
||||
if (draft.reason != null &&
|
||||
draft.reason!.trim().isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Why: ${draft.reason}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: <Widget>[
|
||||
FilledButton.icon(
|
||||
onPressed: person == null ? null : onAccept,
|
||||
icon: const Icon(Icons.check_rounded),
|
||||
label: const Text('Accept'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onDismiss,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: const Text('Dismiss'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _iconForKind(AiSuggestionKind kind) {
|
||||
return switch (kind) {
|
||||
AiSuggestionKind.giftIdea => Icons.card_giftcard_rounded,
|
||||
AiSuggestionKind.eventIdea => Icons.event_available_rounded,
|
||||
AiSuggestionKind.checkIn => Icons.chat_bubble_outline_rounded,
|
||||
AiSuggestionKind.reminder => Icons.notifications_active_outlined,
|
||||
};
|
||||
}
|
||||
|
||||
String _kindLabel(AiSuggestionKind kind) {
|
||||
return switch (kind) {
|
||||
AiSuggestionKind.giftIdea => 'Gift idea',
|
||||
AiSuggestionKind.eventIdea => 'Event idea',
|
||||
AiSuggestionKind.checkIn => 'Check-in',
|
||||
AiSuggestionKind.reminder => 'Reminder',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user