Add LLM digest setup and share timestamp context

This commit is contained in:
Rijad Zuzo
2026-05-19 19:17:35 +02:00
parent 9d2da0c600
commit 5d80af375e
42 changed files with 2878 additions and 39 deletions
@@ -3,6 +3,7 @@ import 'dart:math' as math;
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/ai_digest/application/calendar_event_launcher.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
@@ -22,6 +23,12 @@ class DashboardView extends ConsumerWidget {
data: (LocalDataState data) {
final DashboardSummary summary = data.summary();
final List<DashboardTask> tasks = data.tasks;
final DateTime now = DateTime.now();
final List<AiSuggestionDraft> upcomingEventSuggestions =
_upcomingEventSuggestions(data.aiSuggestionDrafts, now);
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in data.people) person.id: person,
};
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
@@ -42,12 +49,19 @@ class DashboardView extends ConsumerWidget {
),
const SizedBox(height: 8),
Text(
'See the relationship graph first, then execute the highest-leverage next moves.',
'Review incoming context, then act on time-sensitive opportunities.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 18),
_HomeQueueCard(
unresolvedShares: data.sharedInbox,
upcomingEvents: upcomingEventSuggestions,
peopleById: peopleById,
compact: compact,
),
const SizedBox(height: 20),
_RelationshipGraphCard(people: data.people, compact: compact),
const SizedBox(height: 20),
Wrap(
@@ -130,6 +144,293 @@ class DashboardView extends ConsumerWidget {
}
}
class _HomeQueueCard extends StatelessWidget {
const _HomeQueueCard({
required this.unresolvedShares,
required this.upcomingEvents,
required this.peopleById,
required this.compact,
});
final List<SharedInboxEntry> unresolvedShares;
final List<AiSuggestionDraft> upcomingEvents;
final Map<String, PersonProfile> peopleById;
final bool compact;
@override
Widget build(BuildContext context) {
final List<Widget> sections = <Widget>[
_UnresolvedSharesPanel(entries: unresolvedShares),
_UpcomingEventsPanel(events: upcomingEvents, peopleById: peopleById),
];
return compact
? Column(
children: sections
.map(
(Widget child) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: child,
),
)
.toList(growable: false),
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(child: sections[0]),
const SizedBox(width: 14),
Expanded(child: sections[1]),
],
);
}
}
class _UnresolvedSharesPanel extends StatelessWidget {
const _UnresolvedSharesPanel({required this.entries});
final List<SharedInboxEntry> entries;
@override
Widget build(BuildContext context) {
final List<SharedInboxEntry> visibleEntries = entries
.take(3)
.toList(growable: false);
return FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
const Icon(Icons.inbox_rounded, color: AppTheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
'Unresolved Shares',
style: Theme.of(context).textTheme.titleLarge,
),
),
_CountPill(count: entries.length),
],
),
const SizedBox(height: 12),
if (entries.isEmpty)
Text(
'Inbox is clear.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
)
else
...visibleEntries.map(
(SharedInboxEntry entry) => _ShareInboxPreview(entry: entry),
),
if (entries.length > visibleEntries.length)
Text(
'+${entries.length - visibleEntries.length} more in inbox',
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
),
],
),
);
}
}
class _ShareInboxPreview extends StatelessWidget {
const _ShareInboxPreview({required this.entry});
final SharedInboxEntry entry;
@override
Widget build(BuildContext context) {
final String sender = entry.sourceDisplayName?.trim().isNotEmpty == true
? entry.sourceDisplayName!.trim()
: entry.sourceApp;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Icon(Icons.circle, size: 8, color: Color(0xFFFFA447)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
sender,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
_compactShareText(entry.payload.rawText),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
);
}
}
class _UpcomingEventsPanel extends StatelessWidget {
const _UpcomingEventsPanel({required this.events, required this.peopleById});
final List<AiSuggestionDraft> events;
final Map<String, PersonProfile> peopleById;
@override
Widget build(BuildContext context) {
return FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
const Icon(
Icons.event_available_rounded,
color: Color(0xFF2B7FFF),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Upcoming Events',
style: Theme.of(context).textTheme.titleLarge,
),
),
_CountPill(count: events.length),
],
),
const SizedBox(height: 12),
if (events.isEmpty)
Text(
'No grounded event suggestions in the next 30 days.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
)
else
...events.map(
(AiSuggestionDraft event) => _UpcomingEventPreview(
draft: event,
person: peopleById[event.personId],
),
),
],
),
);
}
}
class _UpcomingEventPreview extends StatelessWidget {
const _UpcomingEventPreview({required this.draft, required this.person});
final AiSuggestionDraft draft;
final PersonProfile? person;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF5FAFB),
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
draft.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
'${_shortDate(draft.suggestedFor!)} · ${person?.name ?? 'Unknown person'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
if (draft.eventLocation != null &&
draft.eventLocation!.trim().isNotEmpty) ...<Widget>[
const SizedBox(height: 2),
Text(
draft.eventLocation!.trim(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.textSecondary,
),
),
],
],
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: 'Add to calendar',
onPressed: () => _addToCalendar(context),
icon: const Icon(Icons.calendar_month_rounded),
),
],
),
),
);
}
Future<void> _addToCalendar(BuildContext context) async {
final bool launched = await const CalendarEventLauncher()
.addSuggestionToCalendar(draft);
if (!context.mounted || launched) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Could not open calendar.')));
}
}
class _CountPill extends StatelessWidget {
const _CountPill({required this.count});
final int count;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFEAF7FB),
borderRadius: BorderRadius.circular(99),
),
child: Text(
'$count',
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: AppTheme.primary),
),
);
}
}
class _RelationshipGraphCard extends StatelessWidget {
const _RelationshipGraphCard({required this.people, required this.compact});
@@ -2566,6 +2867,38 @@ String _shortName(String name) {
return parts.first;
}
List<AiSuggestionDraft> _upcomingEventSuggestions(
List<AiSuggestionDraft> drafts,
DateTime now,
) {
final DateTime today = DateTime(now.year, now.month, now.day);
final DateTime windowEnd = today.add(const Duration(days: 30));
final List<AiSuggestionDraft> events =
drafts
.where((AiSuggestionDraft draft) {
final DateTime? startsAt = draft.suggestedFor;
return draft.kind == AiSuggestionKind.eventIdea &&
draft.status == AiSuggestionStatus.pending &&
startsAt != null &&
!startsAt.isBefore(today) &&
startsAt.isBefore(windowEnd);
})
.toList(growable: false)
..sort(
(AiSuggestionDraft a, AiSuggestionDraft b) =>
a.suggestedFor!.compareTo(b.suggestedFor!),
);
return events.take(5).toList(growable: false);
}
String _compactShareText(String value) {
final String compact = value.trim().replaceAll(RegExp(r'\s+'), ' ');
if (compact.length <= 120) {
return compact;
}
return '${compact.substring(0, 117).trim()}...';
}
String _shortDate(DateTime value) {
const List<String> weekdays = <String>[
'Mon',