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,14 @@
|
||||
# Signals Slice
|
||||
|
||||
This slice owns the derived relationship signal feed.
|
||||
|
||||
Use it for product logic that turns stored context into suggestions, signals,
|
||||
or future recommendation hooks.
|
||||
|
||||
Current signal sources:
|
||||
|
||||
- local synthesized follow-through suggestions derived from people, reminders,
|
||||
ideas, dates, and preference signals
|
||||
- optional LLM-generated signals when configured
|
||||
- backend feed items when running against a real gateway or when no better
|
||||
local feed exists
|
||||
@@ -0,0 +1,369 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Signals Presentation
|
||||
|
||||
This folder contains UI for reviewing inferred preference signals and related
|
||||
trust/confirmation flows. It should stay close to user confirmation behavior.
|
||||
@@ -0,0 +1,238 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||
import 'package:relationship_saver/features/signals/domain/signals_feed_synthesizer.dart';
|
||||
import 'package:relationship_saver/features/signals/signals_controller.dart';
|
||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||
|
||||
class SignalsView extends ConsumerWidget {
|
||||
const SignalsView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final AsyncValue<SignalsFeed> signals = ref.watch(
|
||||
signalsControllerProvider,
|
||||
);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final bool compact = constraints.maxWidth < 760;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
compact ? 16 : 28,
|
||||
compact ? 16 : 24,
|
||||
compact ? 16 : 28,
|
||||
compact ? 20 : 28,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
if (compact)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Signals',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Local and backend suggestions you can turn into follow-through.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () {
|
||||
ref.read(signalsControllerProvider.notifier).refresh();
|
||||
},
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
tooltip: 'Refresh feed',
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Signals',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Local and backend suggestions you can turn into follow-through.',
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () {
|
||||
ref.read(signalsControllerProvider.notifier).refresh();
|
||||
},
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
tooltip: 'Refresh feed',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: signals.when(
|
||||
data: (SignalsFeed feed) {
|
||||
if (feed.items.isEmpty) {
|
||||
return const Center(child: Text('No signals right now.'));
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: feed.items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final SignalItem item = feed.items[index];
|
||||
return FrostedCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEAF8FB),
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
child: Text(
|
||||
item.type.toUpperCase(),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(
|
||||
color: AppTheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${item.createdAt.month}/${item.createdAt.day}',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
item.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
if (item.description
|
||||
case final String description)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item.metadata?[signalMetadataPersonNameKey]
|
||||
case final String personName)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
personName,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: <Widget>[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
signalsControllerProvider.notifier,
|
||||
)
|
||||
.acknowledge(
|
||||
item.id,
|
||||
SignalAction.saved,
|
||||
);
|
||||
},
|
||||
icon: Icon(_primaryActionIcon(item)),
|
||||
label: Text(_primaryActionLabel(item)),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
signalsControllerProvider.notifier,
|
||||
)
|
||||
.acknowledge(
|
||||
item.id,
|
||||
SignalAction.dismissed,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: const Text('Dismiss'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (Object error, StackTrace stackTrace) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Unable to load signals. Try refresh.',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _primaryActionLabel(SignalItem item) {
|
||||
if (item.metadata?[signalMetadataActionKey] == createReminderSignalAction) {
|
||||
return 'Create Reminder';
|
||||
}
|
||||
return 'Save';
|
||||
}
|
||||
|
||||
IconData _primaryActionIcon(SignalItem item) {
|
||||
if (item.metadata?[signalMetadataActionKey] == createReminderSignalAction) {
|
||||
return Icons.notifications_active_outlined;
|
||||
}
|
||||
return Icons.bookmark_add_outlined;
|
||||
}
|
||||
@@ -1,19 +1,29 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_config.dart';
|
||||
import 'package:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||
import 'package:relationship_saver/features/signals/domain/signals_feed_synthesizer.dart';
|
||||
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
|
||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||
|
||||
class SignalsController extends AsyncNotifier<SignalsFeed> {
|
||||
final Set<String> _hiddenSignalIds = <String>{};
|
||||
|
||||
@override
|
||||
Future<SignalsFeed> build() async {
|
||||
return _load();
|
||||
final LocalDataState localData = await ref.watch(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
return _load(localData: localData);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading<SignalsFeed>();
|
||||
try {
|
||||
final SignalsFeed feed = await _load();
|
||||
final LocalDataState localData = await ref.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
final SignalsFeed feed = await _load(localData: localData);
|
||||
state = AsyncData<SignalsFeed>(feed);
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncError<SignalsFeed>(error, stackTrace);
|
||||
@@ -26,9 +36,25 @@ class SignalsController extends AsyncNotifier<SignalsFeed> {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(backendGatewayProvider)
|
||||
.acknowledgeSignal(signalId: id, action: action);
|
||||
final SignalItem? item = current.items
|
||||
.where((SignalItem value) => value.id == id)
|
||||
.cast<SignalItem?>()
|
||||
.firstOrNull;
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isLocalSignal(item)) {
|
||||
if (action == SignalAction.saved) {
|
||||
await _materializeLocalSignal(item);
|
||||
}
|
||||
await ref.read(localRepositoryProvider.notifier).dismissLocalSignal(id);
|
||||
} else {
|
||||
await ref
|
||||
.read(backendGatewayProvider)
|
||||
.acknowledgeSignal(signalId: id, action: action);
|
||||
_hiddenSignalIds.add(id);
|
||||
}
|
||||
|
||||
final List<SignalItem> updated = current.items
|
||||
.where((SignalItem item) => item.id != id)
|
||||
@@ -36,51 +62,114 @@ class SignalsController extends AsyncNotifier<SignalsFeed> {
|
||||
state = AsyncData<SignalsFeed>(current.copyWith(items: updated));
|
||||
}
|
||||
|
||||
Future<SignalsFeed> _load() async {
|
||||
try {
|
||||
return await ref.read(backendGatewayProvider).getSignalsFeed(limit: 20);
|
||||
} catch (_) {
|
||||
final LocalDataState localData = await ref.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
final List<PersonProfile> people = localData.people;
|
||||
final PersonProfile defaultPerson = people.isNotEmpty
|
||||
? people.first
|
||||
: PersonProfile(
|
||||
id: 'person-default',
|
||||
name: 'Someone Important',
|
||||
relationship: 'Relationship',
|
||||
affinityScore: 70,
|
||||
nextMoment: DateTime(2026, 2, 20),
|
||||
tags: <String>[],
|
||||
notes: '',
|
||||
);
|
||||
final PersonProfile secondaryPerson = people.length > 1
|
||||
? people[1]
|
||||
: defaultPerson;
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
return SignalsFeed(
|
||||
cursor: 'fallback-signals',
|
||||
items: <SignalItem>[
|
||||
SignalItem(
|
||||
id: 'fallback-1',
|
||||
type: 'recommendation',
|
||||
title: 'Check in with ${defaultPerson.name} tonight',
|
||||
description: 'A short voice memo can keep momentum.',
|
||||
personId: defaultPerson.id,
|
||||
createdAt: now,
|
||||
),
|
||||
SignalItem(
|
||||
id: 'fallback-2',
|
||||
type: 'trend',
|
||||
title: 'Gift idea: practical + personal is trending',
|
||||
description: 'Combine utility with one sentimental detail.',
|
||||
personId: secondaryPerson.id,
|
||||
createdAt: now.subtract(const Duration(hours: 2)),
|
||||
),
|
||||
],
|
||||
);
|
||||
Future<SignalsFeed> _load({required LocalDataState localData}) async {
|
||||
final List<PersonProfile> people = localData.people;
|
||||
final List<SignalItem> items = <SignalItem>[
|
||||
...SignalsFeedSynthesizer.synthesize(state: localData),
|
||||
];
|
||||
|
||||
String? cursor;
|
||||
if (!AppConfig.useFakeBackend || items.isEmpty) {
|
||||
try {
|
||||
final SignalsFeed remote = await ref
|
||||
.read(backendGatewayProvider)
|
||||
.getSignalsFeed(limit: 20);
|
||||
cursor = remote.cursor;
|
||||
items.addAll(remote.items);
|
||||
} catch (_) {
|
||||
if (items.isEmpty) {
|
||||
items.addAll(_fallbackSignals(people));
|
||||
cursor = 'fallback-signals';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final List<SignalItem> visible = items
|
||||
.where((SignalItem item) => !_hiddenSignalIds.contains(item.id))
|
||||
.toList(growable: false);
|
||||
return SignalsFeed(cursor: cursor, items: visible);
|
||||
}
|
||||
|
||||
bool _isLocalSignal(SignalItem item) {
|
||||
return item.metadata?[signalMetadataSourceKey] == localSignalSource;
|
||||
}
|
||||
|
||||
Future<void> _materializeLocalSignal(SignalItem item) async {
|
||||
final Map<String, dynamic>? metadata = item.metadata;
|
||||
if (metadata == null) {
|
||||
return;
|
||||
}
|
||||
if (metadata[signalMetadataActionKey] != createReminderSignalAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String? title = metadata[signalMetadataReminderTitleKey] as String?;
|
||||
final String? cadenceName =
|
||||
metadata[signalMetadataReminderCadenceKey] as String?;
|
||||
final String? nextAtRaw =
|
||||
metadata[signalMetadataReminderNextAtKey] as String?;
|
||||
if (title == null || cadenceName == null || nextAtRaw == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final LocalDataState localData = await ref.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
final bool duplicate = localData.reminders.any((ReminderRule reminder) {
|
||||
return reminder.personId == item.personId &&
|
||||
reminder.title.trim().toLowerCase() == title.trim().toLowerCase();
|
||||
});
|
||||
if (duplicate) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.addReminder(
|
||||
title: title,
|
||||
cadence: ReminderCadence.values.firstWhere(
|
||||
(ReminderCadence value) => value.name == cadenceName,
|
||||
orElse: () => ReminderCadence.weekly,
|
||||
),
|
||||
nextAt: DateTime.parse(nextAtRaw).toLocal(),
|
||||
personId: item.personId,
|
||||
);
|
||||
}
|
||||
|
||||
List<SignalItem> _fallbackSignals(List<PersonProfile> people) {
|
||||
final PersonProfile defaultPerson = people.isNotEmpty
|
||||
? people.first
|
||||
: PersonProfile(
|
||||
id: 'person-default',
|
||||
name: 'Someone Important',
|
||||
relationship: 'Relationship',
|
||||
affinityScore: 70,
|
||||
nextMoment: DateTime(2026, 2, 20),
|
||||
tags: <String>[],
|
||||
notes: '',
|
||||
);
|
||||
final PersonProfile secondaryPerson = people.length > 1
|
||||
? people[1]
|
||||
: defaultPerson;
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
return <SignalItem>[
|
||||
SignalItem(
|
||||
id: 'fallback-1',
|
||||
type: 'recommendation',
|
||||
title: 'Check in with ${defaultPerson.name} tonight',
|
||||
description: 'A short voice memo can keep momentum.',
|
||||
personId: defaultPerson.id,
|
||||
createdAt: now,
|
||||
),
|
||||
SignalItem(
|
||||
id: 'fallback-2',
|
||||
type: 'trend',
|
||||
title: 'Gift idea: practical + personal is trending',
|
||||
description: 'Combine utility with one sentimental detail.',
|
||||
personId: secondaryPerson.id,
|
||||
createdAt: now.subtract(const Duration(hours: 2)),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,3 +178,7 @@ signalsControllerProvider =
|
||||
AsyncNotifierProvider<SignalsController, SignalsFeed>(
|
||||
SignalsController.new,
|
||||
);
|
||||
|
||||
extension<T> on Iterable<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
|
||||
@@ -1,211 +1,2 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||
import 'package:relationship_saver/features/signals/signals_controller.dart';
|
||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||
|
||||
class SignalsView extends ConsumerWidget {
|
||||
const SignalsView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final AsyncValue<SignalsFeed> signals = ref.watch(
|
||||
signalsControllerProvider,
|
||||
);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final bool compact = constraints.maxWidth < 760;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
compact ? 16 : 28,
|
||||
compact ? 16 : 24,
|
||||
compact ? 16 : 28,
|
||||
compact ? 20 : 28,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
if (compact)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Signals',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Backend-driven recommendations and trends you can act on.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () {
|
||||
ref.read(signalsControllerProvider.notifier).refresh();
|
||||
},
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
tooltip: 'Refresh feed',
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Signals',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Backend-driven recommendations and trends you can act on.',
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () {
|
||||
ref.read(signalsControllerProvider.notifier).refresh();
|
||||
},
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
tooltip: 'Refresh feed',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: signals.when(
|
||||
data: (SignalsFeed feed) {
|
||||
if (feed.items.isEmpty) {
|
||||
return const Center(child: Text('No signals right now.'));
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: feed.items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final SignalItem item = feed.items[index];
|
||||
return FrostedCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEAF8FB),
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
child: Text(
|
||||
item.type.toUpperCase(),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(
|
||||
color: AppTheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${item.createdAt.month}/${item.createdAt.day}',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
item.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
if (item.description
|
||||
case final String description)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: <Widget>[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
signalsControllerProvider.notifier,
|
||||
)
|
||||
.acknowledge(
|
||||
item.id,
|
||||
SignalAction.saved,
|
||||
);
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.bookmark_add_outlined,
|
||||
),
|
||||
label: const Text('Save'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
signalsControllerProvider.notifier,
|
||||
)
|
||||
.acknowledge(
|
||||
item.id,
|
||||
SignalAction.dismissed,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: const Text('Dismiss'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (Object error, StackTrace stackTrace) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Unable to load signals. Try refresh.',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// Legacy compatibility export for the signals presentation entry.
|
||||
export 'package:relationship_saver/features/signals/presentation/signals_view.dart';
|
||||
|
||||
Reference in New Issue
Block a user