import 'package:relationship_saver/app/state/local_data_state.dart'; import 'package:relationship_saver/features/ideas/domain/idea_models.dart'; import 'package:relationship_saver/features/people/domain/person_models.dart'; import 'package:relationship_saver/features/reminders/domain/reminder_models.dart'; import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; const String signalMetadataSourceKey = 'source'; const String signalMetadataPersonNameKey = 'personName'; const String signalMetadataActionKey = 'action'; const String signalMetadataReminderTitleKey = 'reminderTitle'; const String signalMetadataReminderCadenceKey = 'reminderCadence'; const String signalMetadataReminderNextAtKey = 'reminderNextAt'; const String localSignalSource = 'local'; const String createReminderSignalAction = 'createReminder'; class SignalsFeedSynthesizer { const SignalsFeedSynthesizer._(); static List synthesize({ required LocalDataState state, DateTime? now, int limit = 12, }) { final DateTime baseline = now ?? DateTime.now(); final List<_SignalCandidate> candidates = <_SignalCandidate>[]; final Map> ideasByPerson = >{}; final Map> remindersByPerson = >{}; final Map> datesByPerson = >{}; final Map> preferencesByPerson = >{}; for (final RelationshipIdea idea in state.ideas) { if (idea.personId == null || idea.isArchived) { continue; } ideasByPerson .putIfAbsent(idea.personId!, () => []) .add(idea); } for (final ReminderRule reminder in state.reminders) { if (reminder.personId == null) { continue; } remindersByPerson .putIfAbsent(reminder.personId!, () => []) .add(reminder); } for (final PersonImportantDate value in state.importantDates) { datesByPerson .putIfAbsent(value.personId, () => []) .add(value); } for (final PersonPreferenceSignal signal in state.preferenceSignals) { if (signal.status == PreferenceSignalStatus.dismissed) { continue; } preferencesByPerson .putIfAbsent(signal.personId, () => []) .add(signal); } for (final PersonProfile person in state.people) { final List personReminders = remindersByPerson[person.id] ?? const []; final List personIdeas = ideasByPerson[person.id] ?? const []; final List personDates = datesByPerson[person.id] ?? const []; final List preferences = preferencesByPerson[person.id] ?? const []; final _SignalCandidate? checkIn = _buildCheckInCandidate( person: person, reminders: personReminders, now: baseline, ); if (checkIn != null) { candidates.add(checkIn); } final _SignalCandidate? dateSignal = _buildUpcomingDateCandidate( person: person, dates: personDates, ideas: personIdeas, now: baseline, ); if (dateSignal != null) { candidates.add(dateSignal); } final _SignalCandidate? ideaSignal = _buildIdeaFollowThroughCandidate( person: person, ideas: personIdeas, reminders: personReminders, now: baseline, ); if (ideaSignal != null) { candidates.add(ideaSignal); } final _SignalCandidate? preferenceSignal = _buildPreferenceCandidate( person: person, preferences: preferences, ideas: personIdeas, ); if (preferenceSignal != null) { candidates.add(preferenceSignal); } } candidates.sort((_SignalCandidate a, _SignalCandidate b) { final int priority = a.priority.compareTo(b.priority); if (priority != 0) { return priority; } return b.item.createdAt.compareTo(a.item.createdAt); }); return candidates .take(limit) .map((_SignalCandidate candidate) => candidate.item) .where((SignalItem item) => !state.dismissedSignalIds.contains(item.id)) .toList(growable: false); } static _SignalCandidate? _buildCheckInCandidate({ required PersonProfile person, required List reminders, required DateTime now, }) { final DateTime? lastTouch = person.lastInteractedAt; if (lastTouch == null) { return null; } final int daysSince = now.difference(lastTouch).inDays; if (daysSince < 7) { return null; } final bool hasNearReminder = reminders.any((ReminderRule reminder) { if (!reminder.enabled) { return false; } final int daysUntil = reminder.nextAt.difference(now).inDays; return daysUntil <= 7; }); if (hasNearReminder) { return null; } final DateTime reminderAt = DateTime( now.year, now.month, now.day, 19, ).add(const Duration(days: 1)); return _SignalCandidate( priority: 0, item: SignalItem( id: 'local-checkin-${person.id}-${lastTouch.millisecondsSinceEpoch}', type: 'recommendation', title: 'Check in with ${person.name}', description: 'It has been $daysSince days since the last logged interaction. A short message would keep the relationship warm.', personId: person.id, createdAt: lastTouch.toUtc(), metadata: { signalMetadataSourceKey: localSignalSource, signalMetadataPersonNameKey: person.name, signalMetadataActionKey: createReminderSignalAction, signalMetadataReminderTitleKey: 'Check in with ${person.name}', signalMetadataReminderCadenceKey: ReminderCadence.weekly.name, signalMetadataReminderNextAtKey: reminderAt.toUtc().toIso8601String(), }, ), ); } static _SignalCandidate? _buildUpcomingDateCandidate({ required PersonProfile person, required List dates, required List ideas, required DateTime now, }) { if (dates.isEmpty) { return null; } final List upcoming = dates .where((PersonImportantDate date) { final int daysUntil = date.date.difference(now).inDays; return daysUntil >= 0 && daysUntil <= 21; }) .toList(growable: false) ..sort( (PersonImportantDate a, PersonImportantDate b) => a.date.compareTo(b.date), ); if (upcoming.isEmpty) { return null; } final PersonImportantDate date = upcoming.first; final int daysUntil = date.date.difference(now).inDays; final int ideaCount = ideas.length; final String timing = switch (daysUntil) { 0 => 'is today', 1 => 'is tomorrow', _ => 'is in $daysUntil days', }; final String ideaText = ideaCount == 0 ? 'No saved idea is linked to this yet.' : '$ideaCount saved idea${ideaCount == 1 ? '' : 's'} already exist.'; return _SignalCandidate( priority: daysUntil <= 3 ? 1 : 2, item: SignalItem( id: 'local-date-${date.id}', type: 'date', title: '${date.label} for ${person.name} $timing', description: '$ideaText Review the profile and turn one into a plan.', personId: person.id, createdAt: date.updatedAt.toUtc(), metadata: { signalMetadataSourceKey: localSignalSource, signalMetadataPersonNameKey: person.name, }, ), ); } static _SignalCandidate? _buildIdeaFollowThroughCandidate({ required PersonProfile person, required List ideas, required List reminders, required DateTime now, }) { if (ideas.isEmpty) { return null; } final RelationshipIdea? oldestIdea = (ideas.toList(growable: false) ..sort((RelationshipIdea a, RelationshipIdea b) { return a.createdAt.compareTo(b.createdAt); })) .firstOrNull; if (oldestIdea == null) { return null; } if (now.difference(oldestIdea.createdAt).inDays < 3) { return null; } final bool hasSimilarReminder = reminders.any((ReminderRule reminder) { final String title = reminder.title.toLowerCase(); return title.contains(person.name.toLowerCase()) || title.contains(oldestIdea.title.toLowerCase()); }); if (hasSimilarReminder) { return null; } final DateTime reminderAt = DateTime( now.year, now.month, now.day, 18, ).add(const Duration(days: 1)); final String typeLabel = oldestIdea.type == IdeaType.gift ? 'gift' : 'plan'; return _SignalCandidate( priority: 3, item: SignalItem( id: 'local-idea-${oldestIdea.id}', type: 'recommendation', title: 'Turn "${oldestIdea.title}" into a real $typeLabel', description: 'This idea for ${person.name} has been sitting in the backlog since ${oldestIdea.createdAt.month}/${oldestIdea.createdAt.day}.', personId: person.id, createdAt: oldestIdea.createdAt.toUtc(), metadata: { signalMetadataSourceKey: localSignalSource, signalMetadataPersonNameKey: person.name, signalMetadataActionKey: createReminderSignalAction, signalMetadataReminderTitleKey: 'Follow through on ${oldestIdea.title} for ${person.name}', signalMetadataReminderCadenceKey: ReminderCadence.weekly.name, signalMetadataReminderNextAtKey: reminderAt.toUtc().toIso8601String(), }, ), ); } static _SignalCandidate? _buildPreferenceCandidate({ required PersonProfile person, required List preferences, required List ideas, }) { if (preferences.isEmpty) { return null; } final List ranked = preferences .where( (PersonPreferenceSignal signal) => signal.polarity == PreferenceSignalPolarity.like && signal.confidence >= 0.7, ) .toList(growable: false) ..sort((PersonPreferenceSignal a, PersonPreferenceSignal b) { final int confidence = b.confidence.compareTo(a.confidence); if (confidence != 0) { return confidence; } return b.lastSeenAt.compareTo(a.lastSeenAt); }); if (ranked.isEmpty) { return null; } final PersonPreferenceSignal signal = ranked.first; final bool alreadyCovered = ideas.any((RelationshipIdea idea) { final String haystack = '${idea.title} ${idea.details}' .toLowerCase() .trim(); return haystack.contains(signal.label.toLowerCase()); }); if (alreadyCovered) { return null; } return _SignalCandidate( priority: 4, item: SignalItem( id: 'local-preference-${signal.id}', type: 'preference', title: '${signal.label} is a strong hook for ${person.name}', description: 'Captured ${signal.occurrenceCount}x across ${signal.sourceApps.isEmpty ? 'shared messages' : signal.sourceApps.join(', ')}. Use it in your next plan or gift.', personId: person.id, createdAt: signal.lastSeenAt.toUtc(), metadata: { signalMetadataSourceKey: localSignalSource, signalMetadataPersonNameKey: person.name, }, ), ); } } class _SignalCandidate { const _SignalCandidate({required this.priority, required this.item}); final int priority; final SignalItem item; } extension on List { T? get firstOrNull => isEmpty ? null : first; }