Add LLM digest setup and share timestamp context
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user