f655adfbea
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.
370 lines
12 KiB
Dart
370 lines
12 KiB
Dart
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<SignalItem> synthesize({
|
|
required LocalDataState state,
|
|
DateTime? now,
|
|
int limit = 12,
|
|
}) {
|
|
final DateTime baseline = now ?? DateTime.now();
|
|
final List<_SignalCandidate> candidates = <_SignalCandidate>[];
|
|
final Map<String, List<RelationshipIdea>> ideasByPerson =
|
|
<String, List<RelationshipIdea>>{};
|
|
final Map<String, List<ReminderRule>> remindersByPerson =
|
|
<String, List<ReminderRule>>{};
|
|
final Map<String, List<PersonImportantDate>> datesByPerson =
|
|
<String, List<PersonImportantDate>>{};
|
|
final Map<String, List<PersonPreferenceSignal>> preferencesByPerson =
|
|
<String, List<PersonPreferenceSignal>>{};
|
|
|
|
for (final RelationshipIdea idea in state.ideas) {
|
|
if (idea.personId == null || idea.isArchived) {
|
|
continue;
|
|
}
|
|
ideasByPerson
|
|
.putIfAbsent(idea.personId!, () => <RelationshipIdea>[])
|
|
.add(idea);
|
|
}
|
|
for (final ReminderRule reminder in state.reminders) {
|
|
if (reminder.personId == null) {
|
|
continue;
|
|
}
|
|
remindersByPerson
|
|
.putIfAbsent(reminder.personId!, () => <ReminderRule>[])
|
|
.add(reminder);
|
|
}
|
|
for (final PersonImportantDate value in state.importantDates) {
|
|
datesByPerson
|
|
.putIfAbsent(value.personId, () => <PersonImportantDate>[])
|
|
.add(value);
|
|
}
|
|
for (final PersonPreferenceSignal signal in state.preferenceSignals) {
|
|
if (signal.status == PreferenceSignalStatus.dismissed) {
|
|
continue;
|
|
}
|
|
preferencesByPerson
|
|
.putIfAbsent(signal.personId, () => <PersonPreferenceSignal>[])
|
|
.add(signal);
|
|
}
|
|
|
|
for (final PersonProfile person in state.people) {
|
|
final List<ReminderRule> personReminders =
|
|
remindersByPerson[person.id] ?? const <ReminderRule>[];
|
|
final List<RelationshipIdea> personIdeas =
|
|
ideasByPerson[person.id] ?? const <RelationshipIdea>[];
|
|
final List<PersonImportantDate> personDates =
|
|
datesByPerson[person.id] ?? const <PersonImportantDate>[];
|
|
final List<PersonPreferenceSignal> preferences =
|
|
preferencesByPerson[person.id] ?? const <PersonPreferenceSignal>[];
|
|
|
|
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<ReminderRule> 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: <String, dynamic>{
|
|
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<PersonImportantDate> dates,
|
|
required List<RelationshipIdea> ideas,
|
|
required DateTime now,
|
|
}) {
|
|
if (dates.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
final List<PersonImportantDate> 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: <String, dynamic>{
|
|
signalMetadataSourceKey: localSignalSource,
|
|
signalMetadataPersonNameKey: person.name,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
static _SignalCandidate? _buildIdeaFollowThroughCandidate({
|
|
required PersonProfile person,
|
|
required List<RelationshipIdea> ideas,
|
|
required List<ReminderRule> 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: <String, dynamic>{
|
|
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<PersonPreferenceSignal> preferences,
|
|
required List<RelationshipIdea> ideas,
|
|
}) {
|
|
if (preferences.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
final List<PersonPreferenceSignal> 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: <String, dynamic>{
|
|
signalMetadataSourceKey: localSignalSource,
|
|
signalMetadataPersonNameKey: person.name,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SignalCandidate {
|
|
const _SignalCandidate({required this.priority, required this.item});
|
|
|
|
final int priority;
|
|
final SignalItem item;
|
|
}
|
|
|
|
extension<T> on List<T> {
|
|
T? get firstOrNull => isEmpty ? null : first;
|
|
}
|