2916 lines
93 KiB
Dart
2916 lines
93 KiB
Dart
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';
|
|
import 'package:relationship_saver/features/signals/signals_controller.dart';
|
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
|
|
|
class DashboardView extends ConsumerWidget {
|
|
const DashboardView({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final AsyncValue<LocalDataState> localData = ref.watch(
|
|
localRepositoryProvider,
|
|
);
|
|
|
|
return localData.when(
|
|
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) {
|
|
final bool compact = constraints.maxWidth < 720;
|
|
return SingleChildScrollView(
|
|
padding: EdgeInsets.fromLTRB(
|
|
compact ? 16 : 28,
|
|
compact ? 16 : 24,
|
|
compact ? 16 : 28,
|
|
compact ? 24 : 36,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
'Today',
|
|
style: Theme.of(context).textTheme.headlineMedium,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'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(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: <Widget>[
|
|
_StatCard(
|
|
label: 'Active People',
|
|
value: '${summary.activePeople}',
|
|
accent: const Color(0xFF16B8CA),
|
|
compact: compact,
|
|
),
|
|
_StatCard(
|
|
label: 'Upcoming Plans',
|
|
value: '${summary.upcomingPlans}',
|
|
accent: const Color(0xFF2B7FFF),
|
|
compact: compact,
|
|
),
|
|
_StatCard(
|
|
label: 'Pending Ideas',
|
|
value: '${summary.pendingIdeas}',
|
|
accent: const Color(0xFFFFA447),
|
|
compact: compact,
|
|
),
|
|
_StatCard(
|
|
label: 'Weekly Rhythm',
|
|
value: '${summary.weeklyConsistency}%',
|
|
accent: const Color(0xFF30B66A),
|
|
compact: compact,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
FrostedCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
'Next Moves',
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 14),
|
|
if (tasks.isEmpty)
|
|
Text(
|
|
'No tasks yet. Add moments, ideas, or reminders to generate follow-through actions.',
|
|
style: Theme.of(context).textTheme.bodyLarge
|
|
?.copyWith(color: AppTheme.textSecondary),
|
|
)
|
|
else
|
|
...tasks.map(
|
|
(DashboardTask task) => _TaskRow(
|
|
task: task,
|
|
compact: compact,
|
|
onToggleDone: () {
|
|
ref
|
|
.read(localRepositoryProvider.notifier)
|
|
.toggleTaskDone(task.id);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (Object error, StackTrace stackTrace) {
|
|
return Center(
|
|
child: Text(
|
|
'Could not load local data.',
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
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});
|
|
|
|
final List<PersonProfile> people;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final List<_RelationshipVisual> legend = _collectGraphLegend(people);
|
|
return FrostedCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Row(
|
|
children: <Widget>[
|
|
Expanded(
|
|
child: Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: <Widget>[
|
|
Text(
|
|
'Relationship Graph',
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFEAF7FB),
|
|
borderRadius: BorderRadius.circular(99),
|
|
),
|
|
child: Text(
|
|
'${people.length} nodes',
|
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
color: AppTheme.primary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
OutlinedButton.icon(
|
|
onPressed: people.isEmpty
|
|
? null
|
|
: () => _openGraphExplorer(context, people),
|
|
icon: const Icon(Icons.open_in_full_rounded, size: 18),
|
|
label: Text(compact ? 'Explore' : 'Open Explorer'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
height: compact ? 330 : 410,
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: <Color>[
|
|
const Color(0xFFEAF3F8),
|
|
const Color(0xFFF7FAFC),
|
|
Colors.white.withValues(alpha: 0.9),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
borderRadius: BorderRadius.circular(24),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.8)),
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(24),
|
|
child: LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints constraints) {
|
|
return _RelationshipGraphCanvas(
|
|
people: people,
|
|
compact: compact,
|
|
mode: _GraphCanvasMode.preview,
|
|
onOpenPersonInsights: (PersonProfile person) =>
|
|
_openPersonInsights(context, person),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Dashboard preview is scroll-first. Long-press a person for insights, or open the explorer to pan and zoom.',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: legend
|
|
.map(
|
|
(_RelationshipVisual visual) =>
|
|
_RelationshipLegendPill(visual: visual),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _GraphCanvasMode { preview, explorer }
|
|
|
|
class _RelationshipGraphExplorerPage extends StatefulWidget {
|
|
const _RelationshipGraphExplorerPage({required this.people});
|
|
|
|
final List<PersonProfile> people;
|
|
|
|
@override
|
|
State<_RelationshipGraphExplorerPage> createState() =>
|
|
_RelationshipGraphExplorerPageState();
|
|
}
|
|
|
|
class _RelationshipGraphExplorerPageState
|
|
extends State<_RelationshipGraphExplorerPage> {
|
|
late final TransformationController _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = TransformationController();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _resetView() {
|
|
_controller.value = Matrix4.identity();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bool compact = MediaQuery.sizeOf(context).width < 760;
|
|
final List<_RelationshipVisual> legend = _collectGraphLegend(widget.people);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Relationship Graph'),
|
|
actions: <Widget>[
|
|
IconButton(
|
|
tooltip: 'Reset view',
|
|
onPressed: _resetView,
|
|
icon: const Icon(Icons.center_focus_strong_rounded),
|
|
),
|
|
],
|
|
),
|
|
body: Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
compact ? 12 : 18,
|
|
compact ? 12 : 14,
|
|
compact ? 12 : 18,
|
|
compact ? 14 : 20,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.76),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.85)),
|
|
),
|
|
child: Wrap(
|
|
spacing: 10,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: <Widget>[
|
|
Text(
|
|
'Explorer mode: pan + pinch to zoom. Long-press a person to open insights.',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
FilledButton.tonalIcon(
|
|
onPressed: _resetView,
|
|
icon: const Icon(Icons.refresh_rounded, size: 18),
|
|
label: const Text('Reset View'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Expanded(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: <Color>[
|
|
const Color(0xFFEAF3F8),
|
|
const Color(0xFFF7FAFC),
|
|
Colors.white.withValues(alpha: 0.92),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
borderRadius: BorderRadius.circular(24),
|
|
border: Border.all(
|
|
color: Colors.white.withValues(alpha: 0.82),
|
|
),
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(24),
|
|
child: _RelationshipGraphCanvas(
|
|
people: widget.people,
|
|
compact: compact,
|
|
mode: _GraphCanvasMode.explorer,
|
|
transformationController: _controller,
|
|
onOpenPersonInsights: (PersonProfile person) =>
|
|
_openPersonInsights(context, person),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: legend
|
|
.map(
|
|
(_RelationshipVisual visual) => Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: _RelationshipLegendPill(visual: visual),
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RelationshipGraphCanvas extends StatelessWidget {
|
|
const _RelationshipGraphCanvas({
|
|
required this.people,
|
|
required this.compact,
|
|
required this.mode,
|
|
required this.onOpenPersonInsights,
|
|
this.transformationController,
|
|
});
|
|
|
|
final List<PersonProfile> people;
|
|
final bool compact;
|
|
final _GraphCanvasMode mode;
|
|
final ValueChanged<PersonProfile> onOpenPersonInsights;
|
|
final TransformationController? transformationController;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints constraints) {
|
|
if (people.isEmpty) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text(
|
|
'No people yet. Add your first relationship to start the graph.',
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final _GraphLayout layout = _GraphLayout.build(
|
|
people: people,
|
|
size: constraints.biggest,
|
|
compact: compact,
|
|
);
|
|
|
|
final bool explorer = mode == _GraphCanvasMode.explorer;
|
|
return InteractiveViewer(
|
|
transformationController: transformationController,
|
|
minScale: explorer ? 0.55 : 1.0,
|
|
maxScale: explorer ? 3.2 : 1.0,
|
|
boundaryMargin: explorer
|
|
? const EdgeInsets.all(260)
|
|
: EdgeInsets.zero,
|
|
panEnabled: explorer,
|
|
scaleEnabled: explorer,
|
|
child: SizedBox(
|
|
width: constraints.maxWidth,
|
|
height: constraints.maxHeight,
|
|
child: Stack(
|
|
children: <Widget>[
|
|
Positioned.fill(
|
|
child: CustomPaint(
|
|
painter: _RelationshipEdgePainter(
|
|
center: layout.center,
|
|
edges: layout.edges,
|
|
),
|
|
),
|
|
),
|
|
...layout.nodes.map(
|
|
(_GraphNode node) => _GraphBubble(
|
|
node: node,
|
|
compact: compact,
|
|
onLongPress: () => onOpenPersonInsights(node.person),
|
|
),
|
|
),
|
|
_CenterNode(center: layout.center, compact: compact),
|
|
if (layout.hiddenCount > 0)
|
|
Positioned(
|
|
right: 12,
|
|
top: 12,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.85),
|
|
borderRadius: BorderRadius.circular(99),
|
|
),
|
|
child: Text(
|
|
'+${layout.hiddenCount} more',
|
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (!explorer)
|
|
Positioned(
|
|
right: 12,
|
|
bottom: 12,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.88),
|
|
borderRadius: BorderRadius.circular(99),
|
|
border: Border.all(color: Colors.white),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
const Icon(
|
|
Icons.swipe_up_alt_rounded,
|
|
size: 14,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'Scroll page',
|
|
style: Theme.of(context).textTheme.labelLarge
|
|
?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
List<_RelationshipVisual> _collectGraphLegend(List<PersonProfile> source) {
|
|
final Set<String> seen = <String>{};
|
|
final List<_RelationshipVisual> result = <_RelationshipVisual>[];
|
|
|
|
for (final PersonProfile person in source) {
|
|
final _RelationshipVisual visual = _relationshipVisualFor(
|
|
person.relationship,
|
|
);
|
|
if (seen.add(visual.label)) {
|
|
result.add(visual);
|
|
}
|
|
if (result.length >= 7) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (result.isEmpty) {
|
|
result.add(_otherVisual);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void _openGraphExplorer(BuildContext context, List<PersonProfile> people) {
|
|
Navigator.of(context).push<void>(
|
|
MaterialPageRoute<void>(
|
|
builder: (BuildContext context) =>
|
|
_RelationshipGraphExplorerPage(people: people),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openPersonInsights(BuildContext context, PersonProfile person) {
|
|
Navigator.of(context).push<void>(
|
|
MaterialPageRoute<void>(
|
|
builder: (BuildContext context) {
|
|
return _PersonInsightsPage(personId: person.id);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
class _GraphLayout {
|
|
const _GraphLayout({
|
|
required this.center,
|
|
required this.nodes,
|
|
required this.edges,
|
|
required this.hiddenCount,
|
|
});
|
|
|
|
final Offset center;
|
|
final List<_GraphNode> nodes;
|
|
final List<_GraphEdge> edges;
|
|
final int hiddenCount;
|
|
|
|
static _GraphLayout build({
|
|
required List<PersonProfile> people,
|
|
required Size size,
|
|
required bool compact,
|
|
}) {
|
|
final int maxNodes = compact ? 10 : 16;
|
|
final List<PersonProfile> visiblePeople = people
|
|
.take(maxNodes)
|
|
.toList(growable: false);
|
|
final int hiddenCount = people.length - visiblePeople.length;
|
|
|
|
final Offset center = Offset(size.width / 2, size.height / 2);
|
|
if (visiblePeople.isEmpty) {
|
|
return _GraphLayout(
|
|
center: center,
|
|
nodes: const <_GraphNode>[],
|
|
edges: const <_GraphEdge>[],
|
|
hiddenCount: hiddenCount,
|
|
);
|
|
}
|
|
|
|
final double minSide = math.min(size.width, size.height);
|
|
final int total = visiblePeople.length;
|
|
final int innerCount = total <= 8 ? total : 8;
|
|
final int outerCount = total - innerCount;
|
|
final double innerRadius = minSide * (compact ? 0.28 : 0.31);
|
|
final double outerRadius = minSide * (compact ? 0.39 : 0.43);
|
|
|
|
final List<_GraphNode> nodes = <_GraphNode>[];
|
|
for (int i = 0; i < total; i += 1) {
|
|
final bool inOuterRing = i >= innerCount;
|
|
final int ringCount = inOuterRing ? outerCount : innerCount;
|
|
final int ringIndex = inOuterRing ? i - innerCount : i;
|
|
final double radius = inOuterRing ? outerRadius : innerRadius;
|
|
final double phaseOffset = inOuterRing ? math.pi / ringCount : 0;
|
|
final double angle =
|
|
(-math.pi / 2) +
|
|
phaseOffset +
|
|
((2 * math.pi * ringIndex) / ringCount);
|
|
|
|
final Offset position = Offset(
|
|
center.dx + radius * math.cos(angle),
|
|
center.dy + radius * math.sin(angle),
|
|
);
|
|
final PersonProfile person = visiblePeople[i];
|
|
final _RelationshipVisual visual = _relationshipVisualFor(
|
|
person.relationship,
|
|
);
|
|
nodes.add(_GraphNode(person: person, visual: visual, position: position));
|
|
}
|
|
|
|
final List<_GraphEdge> edges = <_GraphEdge>[];
|
|
for (final _GraphNode node in nodes) {
|
|
edges.add(
|
|
_GraphEdge(
|
|
start: center,
|
|
end: node.position,
|
|
color: node.visual.color.withValues(alpha: 0.46),
|
|
width: compact ? 1.7 : 2.0,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (nodes.length > 2) {
|
|
for (int i = 0; i < nodes.length; i += 1) {
|
|
final _GraphNode current = nodes[i];
|
|
final _GraphNode next = nodes[(i + 1) % nodes.length];
|
|
final Color color =
|
|
Color.lerp(current.visual.color, next.visual.color, 0.5) ??
|
|
AppTheme.accent;
|
|
|
|
edges.add(
|
|
_GraphEdge(
|
|
start: current.position,
|
|
end: next.position,
|
|
color: color.withValues(alpha: 0.2),
|
|
width: 1.1,
|
|
),
|
|
);
|
|
|
|
if (nodes.length >= 7 && i.isEven) {
|
|
final _GraphNode jump = nodes[(i + 3) % nodes.length];
|
|
edges.add(
|
|
_GraphEdge(
|
|
start: current.position,
|
|
end: jump.position,
|
|
color: current.visual.color.withValues(alpha: 0.1),
|
|
width: 1.0,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return _GraphLayout(
|
|
center: center,
|
|
nodes: nodes,
|
|
edges: edges,
|
|
hiddenCount: hiddenCount,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GraphNode {
|
|
const _GraphNode({
|
|
required this.person,
|
|
required this.visual,
|
|
required this.position,
|
|
});
|
|
|
|
final PersonProfile person;
|
|
final _RelationshipVisual visual;
|
|
final Offset position;
|
|
}
|
|
|
|
class _GraphEdge {
|
|
const _GraphEdge({
|
|
required this.start,
|
|
required this.end,
|
|
required this.color,
|
|
required this.width,
|
|
});
|
|
|
|
final Offset start;
|
|
final Offset end;
|
|
final Color color;
|
|
final double width;
|
|
}
|
|
|
|
class _RelationshipEdgePainter extends CustomPainter {
|
|
const _RelationshipEdgePainter({required this.center, required this.edges});
|
|
|
|
final Offset center;
|
|
final List<_GraphEdge> edges;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final Paint centerGlow = Paint()
|
|
..shader =
|
|
RadialGradient(
|
|
colors: <Color>[
|
|
AppTheme.accent.withValues(alpha: 0.15),
|
|
Colors.transparent,
|
|
],
|
|
).createShader(
|
|
Rect.fromCircle(
|
|
center: center,
|
|
radius: math.min(size.width, size.height) * 0.36,
|
|
),
|
|
);
|
|
|
|
canvas.drawCircle(
|
|
center,
|
|
math.min(size.width, size.height) * 0.36,
|
|
centerGlow,
|
|
);
|
|
|
|
for (final _GraphEdge edge in edges) {
|
|
final Paint paint = Paint()
|
|
..color = edge.color
|
|
..strokeWidth = edge.width
|
|
..strokeCap = StrokeCap.round
|
|
..style = PaintingStyle.stroke;
|
|
canvas.drawLine(edge.start, edge.end, paint);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _RelationshipEdgePainter oldDelegate) {
|
|
return oldDelegate.edges != edges || oldDelegate.center != center;
|
|
}
|
|
}
|
|
|
|
class _GraphBubble extends StatelessWidget {
|
|
const _GraphBubble({
|
|
required this.node,
|
|
required this.compact,
|
|
required this.onLongPress,
|
|
});
|
|
|
|
final _GraphNode node;
|
|
final bool compact;
|
|
final VoidCallback onLongPress;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final double diameter = compact ? 52 : 62;
|
|
final double labelWidth = compact ? 66 : 86;
|
|
|
|
return Positioned(
|
|
left: node.position.dx - (diameter / 2),
|
|
top: node.position.dy - (diameter / 2),
|
|
child: Tooltip(
|
|
message: '${node.person.name} • ${node.visual.label}',
|
|
child: GestureDetector(
|
|
key: ValueKey<String>('graph-node-${node.person.id}'),
|
|
behavior: HitTestBehavior.translucent,
|
|
onLongPress: onLongPress,
|
|
child: SizedBox(
|
|
width: labelWidth,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
Stack(
|
|
clipBehavior: Clip.none,
|
|
children: <Widget>[
|
|
Container(
|
|
width: diameter,
|
|
height: diameter,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: LinearGradient(
|
|
colors: <Color>[
|
|
_shiftLightness(node.visual.color, 0.16),
|
|
_shiftLightness(node.visual.color, -0.12),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
boxShadow: <BoxShadow>[
|
|
BoxShadow(
|
|
color: node.visual.color.withValues(alpha: 0.24),
|
|
blurRadius: 14,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
_initials(node.person.name),
|
|
style: Theme.of(context).textTheme.titleMedium
|
|
?.copyWith(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
right: -2,
|
|
top: -2,
|
|
child: Container(
|
|
width: compact ? 20 : 22,
|
|
height: compact ? 20 : 22,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Colors.white,
|
|
border: Border.all(
|
|
color: node.visual.color.withValues(alpha: 0.8),
|
|
),
|
|
),
|
|
child: Icon(
|
|
node.visual.icon,
|
|
size: compact ? 12 : 13,
|
|
color: node.visual.color,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (!compact) ...<Widget>[
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
_shortName(node.person.name),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CenterNode extends StatelessWidget {
|
|
const _CenterNode({required this.center, required this.compact});
|
|
|
|
final Offset center;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final double diameter = compact ? 74 : 86;
|
|
|
|
return Positioned(
|
|
left: center.dx - (diameter / 2),
|
|
top: center.dy - (diameter / 2),
|
|
child: Container(
|
|
width: diameter,
|
|
height: diameter,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: const LinearGradient(
|
|
colors: <Color>[Color(0xFF1AB6C8), Color(0xFF286DE0)],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
boxShadow: <BoxShadow>[
|
|
BoxShadow(
|
|
color: const Color(0xFF1AB6C8).withValues(alpha: 0.26),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 10),
|
|
),
|
|
],
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: <Widget>[
|
|
const Icon(Icons.favorite_rounded, color: Colors.white, size: 18),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'YOU',
|
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.4,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RelationshipLegendPill extends StatelessWidget {
|
|
const _RelationshipLegendPill({required this.visual});
|
|
|
|
final _RelationshipVisual visual;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: visual.color.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(99),
|
|
border: Border.all(color: visual.color.withValues(alpha: 0.18)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
Icon(visual.icon, size: 14, color: visual.color),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
visual.label,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _InsightQuickAction { capture, note, idea, reminder }
|
|
|
|
class _PersonInsightsPage extends ConsumerWidget {
|
|
const _PersonInsightsPage({required this.personId});
|
|
|
|
final String personId;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final AsyncValue<LocalDataState> localData = ref.watch(
|
|
localRepositoryProvider,
|
|
);
|
|
final AsyncValue<SignalsFeed> signals = ref.watch(
|
|
signalsControllerProvider,
|
|
);
|
|
final LocalDataState? localValue = localData.asData?.value;
|
|
final PersonProfile? quickAddPerson = localValue == null
|
|
? null
|
|
: _findPersonById(localValue.people, personId);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Person Insights')),
|
|
floatingActionButton: quickAddPerson == null
|
|
? null
|
|
: FloatingActionButton(
|
|
tooltip: 'Add to person',
|
|
onPressed: () => _openQuickAddSheet(context, ref, quickAddPerson),
|
|
child: const Icon(Icons.add_rounded),
|
|
),
|
|
body: localData.when(
|
|
data: (LocalDataState data) {
|
|
final PersonProfile? person = _findPersonById(data.people, personId);
|
|
if (person == null) {
|
|
return Center(
|
|
child: Text(
|
|
'Person was not found in local data.',
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
);
|
|
}
|
|
|
|
final List<RelationshipMoment> moments =
|
|
data.moments
|
|
.where(
|
|
(RelationshipMoment moment) => moment.personId == person.id,
|
|
)
|
|
.toList(growable: false)
|
|
..sort(
|
|
(RelationshipMoment a, RelationshipMoment b) =>
|
|
b.at.compareTo(a.at),
|
|
);
|
|
final List<RelationshipIdea> ideas =
|
|
data.ideas
|
|
.where((RelationshipIdea idea) => idea.personId == person.id)
|
|
.toList(growable: false)
|
|
..sort(
|
|
(RelationshipIdea a, RelationshipIdea b) =>
|
|
b.createdAt.compareTo(a.createdAt),
|
|
);
|
|
final List<ReminderRule> reminders =
|
|
data.reminders
|
|
.where(
|
|
(ReminderRule reminder) => reminder.personId == person.id,
|
|
)
|
|
.toList(growable: false)
|
|
..sort(
|
|
(ReminderRule a, ReminderRule b) =>
|
|
a.nextAt.compareTo(b.nextAt),
|
|
);
|
|
final List<PersonPreferenceSignal> preferenceSignals =
|
|
data.preferenceSignals
|
|
.where(
|
|
(PersonPreferenceSignal signal) =>
|
|
signal.personId == person.id,
|
|
)
|
|
.toList(growable: false)
|
|
..sort(_comparePreferenceSignals);
|
|
final List<DashboardTask> relatedTasks = _findTasksForPerson(
|
|
data.tasks,
|
|
person,
|
|
);
|
|
|
|
final List<SignalItem> personSignals =
|
|
signals.asData?.value.items
|
|
.where((SignalItem item) => item.personId == person.id)
|
|
.toList(growable: false) ??
|
|
const <SignalItem>[];
|
|
|
|
return LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints constraints) {
|
|
final bool compact = constraints.maxWidth < 760;
|
|
return SingleChildScrollView(
|
|
padding: EdgeInsets.fromLTRB(
|
|
compact ? 16 : 24,
|
|
compact ? 16 : 20,
|
|
compact ? 16 : 24,
|
|
compact ? 24 : 30,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
_personProfileCard(
|
|
context,
|
|
person,
|
|
compact,
|
|
onQuickAction: (_InsightQuickAction action) =>
|
|
_handleQuickAction(context, ref, person, action),
|
|
),
|
|
const SizedBox(height: 14),
|
|
_InsightSectionCard(
|
|
title: 'Chat-derived preferences',
|
|
icon: Icons.psychology_alt_outlined,
|
|
children: preferenceSignals.isEmpty
|
|
? <Widget>[
|
|
const _SectionEmpty(
|
|
label:
|
|
'No inferred preferences yet. Share chat messages to build context gradually.',
|
|
),
|
|
]
|
|
: preferenceSignals
|
|
.map(
|
|
(
|
|
PersonPreferenceSignal signal,
|
|
) => _InsightPreferenceSignalCard(
|
|
signal: signal,
|
|
compact: compact,
|
|
onConfirm: () async {
|
|
await ref
|
|
.read(
|
|
localRepositoryProvider.notifier,
|
|
)
|
|
.confirmPreferenceSignal(signal.id);
|
|
},
|
|
onDismiss: () async {
|
|
await ref
|
|
.read(
|
|
localRepositoryProvider.notifier,
|
|
)
|
|
.dismissPreferenceSignal(signal.id);
|
|
},
|
|
onWhy: () => _showPreferenceSignalEvidence(
|
|
context,
|
|
signal: signal,
|
|
personName: person.name,
|
|
),
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_InsightSectionCard(
|
|
title: 'Ideas',
|
|
icon: Icons.lightbulb_outline_rounded,
|
|
children: ideas.isEmpty
|
|
? <Widget>[
|
|
const _SectionEmpty(
|
|
label:
|
|
'No captured ideas yet for this relationship.',
|
|
),
|
|
]
|
|
: ideas
|
|
.map(
|
|
(RelationshipIdea idea) => _InsightRow(
|
|
title: idea.title,
|
|
subtitle: idea.details,
|
|
meta:
|
|
'${idea.type.name.toUpperCase()} • ${_shortDate(idea.createdAt)}',
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_InsightSectionCard(
|
|
title: 'Promotions & Signals',
|
|
icon: Icons.local_offer_outlined,
|
|
children: _signalRows(
|
|
context: context,
|
|
signals: personSignals,
|
|
sourceState: signals,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_InsightSectionCard(
|
|
title: 'Moments',
|
|
icon: Icons.auto_awesome_rounded,
|
|
children: moments.isEmpty
|
|
? <Widget>[
|
|
const _SectionEmpty(
|
|
label: 'No logged moments for this person yet.',
|
|
),
|
|
]
|
|
: moments
|
|
.map(
|
|
(RelationshipMoment moment) => _InsightRow(
|
|
title: moment.title,
|
|
subtitle: moment.summary,
|
|
meta:
|
|
'${moment.type.toUpperCase()} • ${_shortDate(moment.at)}',
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_InsightSectionCard(
|
|
title: 'Reminders',
|
|
icon: Icons.notifications_active_outlined,
|
|
children: reminders.isEmpty
|
|
? <Widget>[
|
|
const _SectionEmpty(
|
|
label:
|
|
'No reminders configured for this relationship.',
|
|
),
|
|
]
|
|
: reminders
|
|
.map(
|
|
(ReminderRule reminder) => _InsightRow(
|
|
title: reminder.title,
|
|
subtitle: reminder.enabled
|
|
? 'Enabled'
|
|
: 'Disabled',
|
|
meta:
|
|
'${reminder.cadence.name.toUpperCase()} • Next ${_shortDate(reminder.nextAt)}',
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_InsightSectionCard(
|
|
title: 'Tasks & Follow-Ups',
|
|
icon: Icons.checklist_rounded,
|
|
children: relatedTasks.isEmpty
|
|
? <Widget>[
|
|
const _SectionEmpty(
|
|
label:
|
|
'No direct follow-up tasks found for this person.',
|
|
),
|
|
]
|
|
: relatedTasks
|
|
.map(
|
|
(DashboardTask task) => _InsightRow(
|
|
title: task.title,
|
|
subtitle: task.description,
|
|
meta:
|
|
'${task.done ? 'DONE' : 'PENDING'} • ${_shortDate(task.when)}',
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (Object error, StackTrace stackTrace) {
|
|
return Center(
|
|
child: Text(
|
|
'Could not load person insights.',
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showPreferenceSignalEvidence(
|
|
BuildContext context, {
|
|
required PersonPreferenceSignal signal,
|
|
required String personName,
|
|
}) {
|
|
return showModalBottomSheet<void>(
|
|
context: context,
|
|
useSafeArea: true,
|
|
showDragHandle: true,
|
|
builder: (BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
'Why this signal?',
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'${signal.label} • ${signal.category} • ${signal.polarity.name.toUpperCase()}',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'For $personName • confidence ${(signal.confidence * 100).round()}%',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (signal.evidenceSnippets.isEmpty)
|
|
const _SectionEmpty(
|
|
label: 'No evidence snippets stored yet for this signal.',
|
|
)
|
|
else
|
|
...signal.evidenceSnippets.map(
|
|
(String snippet) => _InsightRow(
|
|
title: 'Evidence',
|
|
subtitle: snippet,
|
|
meta: 'Source(s): ${signal.sourceApps.join(', ')}',
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Close'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _personProfileCard(
|
|
BuildContext context,
|
|
PersonProfile person,
|
|
bool compact, {
|
|
required ValueChanged<_InsightQuickAction> onQuickAction,
|
|
}) {
|
|
final _RelationshipVisual visual = _relationshipVisualFor(
|
|
person.relationship,
|
|
);
|
|
|
|
return FrostedCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
if (compact)
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Row(
|
|
children: <Widget>[
|
|
_insightAvatar(person: person, visual: visual),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
person.name,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
person.relationship,
|
|
style: Theme.of(context).textTheme.bodyMedium
|
|
?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuButton<_InsightQuickAction>(
|
|
tooltip: 'Quick add',
|
|
onSelected: onQuickAction,
|
|
itemBuilder: (BuildContext context) =>
|
|
const <PopupMenuEntry<_InsightQuickAction>>[
|
|
PopupMenuItem<_InsightQuickAction>(
|
|
value: _InsightQuickAction.capture,
|
|
child: Text('Add Capture'),
|
|
),
|
|
PopupMenuItem<_InsightQuickAction>(
|
|
value: _InsightQuickAction.note,
|
|
child: Text('Add Note'),
|
|
),
|
|
PopupMenuItem<_InsightQuickAction>(
|
|
value: _InsightQuickAction.idea,
|
|
child: Text('Add Idea'),
|
|
),
|
|
PopupMenuItem<_InsightQuickAction>(
|
|
value: _InsightQuickAction.reminder,
|
|
child: Text('Add Reminder'),
|
|
),
|
|
],
|
|
icon: const Icon(Icons.add_circle_outline_rounded),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: <Widget>[
|
|
_miniMetric(
|
|
label: 'Affinity',
|
|
value: '${person.affinityScore}%',
|
|
),
|
|
_miniMetric(
|
|
label: 'Next Moment',
|
|
value: _shortDate(person.nextMoment),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
)
|
|
else
|
|
Row(
|
|
children: <Widget>[
|
|
_insightAvatar(person: person, visual: visual),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
person.name,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
person.relationship,
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_miniMetric(
|
|
label: 'Affinity',
|
|
value: '${person.affinityScore}%',
|
|
),
|
|
const SizedBox(width: 10),
|
|
_miniMetric(
|
|
label: 'Next Moment',
|
|
value: _shortDate(person.nextMoment),
|
|
),
|
|
const SizedBox(width: 8),
|
|
PopupMenuButton<_InsightQuickAction>(
|
|
tooltip: 'Quick add',
|
|
onSelected: onQuickAction,
|
|
itemBuilder: (BuildContext context) =>
|
|
const <PopupMenuEntry<_InsightQuickAction>>[
|
|
PopupMenuItem<_InsightQuickAction>(
|
|
value: _InsightQuickAction.capture,
|
|
child: Text('Add Capture'),
|
|
),
|
|
PopupMenuItem<_InsightQuickAction>(
|
|
value: _InsightQuickAction.note,
|
|
child: Text('Add Note'),
|
|
),
|
|
PopupMenuItem<_InsightQuickAction>(
|
|
value: _InsightQuickAction.idea,
|
|
child: Text('Add Idea'),
|
|
),
|
|
PopupMenuItem<_InsightQuickAction>(
|
|
value: _InsightQuickAction.reminder,
|
|
child: Text('Add Reminder'),
|
|
),
|
|
],
|
|
icon: const Icon(Icons.add_circle_outline_rounded),
|
|
),
|
|
],
|
|
),
|
|
if (person.tags.isNotEmpty) ...<Widget>[
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: person.tags
|
|
.map((String tag) => _TagPill(label: tag))
|
|
.toList(growable: false),
|
|
),
|
|
],
|
|
if (person.notes.trim().isNotEmpty) ...<Widget>[
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
person.notes,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleQuickAction(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
PersonProfile person,
|
|
_InsightQuickAction action,
|
|
) async {
|
|
switch (action) {
|
|
case _InsightQuickAction.capture:
|
|
final String? summary = await _showQuickTextCaptureDialog(
|
|
context,
|
|
title: 'Add Capture',
|
|
hintText: 'What happened in this interaction?',
|
|
);
|
|
if (summary == null) {
|
|
return;
|
|
}
|
|
await ref
|
|
.read(localRepositoryProvider.notifier)
|
|
.addMoment(personId: person.id, summary: summary, type: 'capture');
|
|
return;
|
|
case _InsightQuickAction.note:
|
|
final String? note = await _showQuickTextCaptureDialog(
|
|
context,
|
|
title: 'Add Note',
|
|
hintText: 'Write a quick context note...',
|
|
);
|
|
if (note == null) {
|
|
return;
|
|
}
|
|
await ref
|
|
.read(localRepositoryProvider.notifier)
|
|
.addMoment(personId: person.id, summary: note, type: 'note');
|
|
return;
|
|
case _InsightQuickAction.idea:
|
|
final _InsightQuickIdeaDraft? idea =
|
|
await showDialog<_InsightQuickIdeaDraft>(
|
|
context: context,
|
|
builder: (BuildContext context) =>
|
|
const _InsightQuickIdeaDialog(),
|
|
);
|
|
if (idea == null) {
|
|
return;
|
|
}
|
|
await ref
|
|
.read(localRepositoryProvider.notifier)
|
|
.addIdea(
|
|
type: idea.type,
|
|
title: idea.title,
|
|
details: idea.details,
|
|
personId: person.id,
|
|
);
|
|
return;
|
|
case _InsightQuickAction.reminder:
|
|
final _InsightQuickReminderDraft? reminder =
|
|
await showDialog<_InsightQuickReminderDraft>(
|
|
context: context,
|
|
builder: (BuildContext context) =>
|
|
const _InsightQuickReminderDialog(),
|
|
);
|
|
if (reminder == null) {
|
|
return;
|
|
}
|
|
await ref
|
|
.read(localRepositoryProvider.notifier)
|
|
.addReminder(
|
|
title: reminder.title,
|
|
cadence: reminder.cadence,
|
|
nextAt: reminder.nextAt,
|
|
personId: person.id,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
Future<void> _openQuickAddSheet(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
PersonProfile person,
|
|
) async {
|
|
final _InsightQuickAction? selected =
|
|
await showModalBottomSheet<_InsightQuickAction>(
|
|
context: context,
|
|
showDragHandle: true,
|
|
builder: (BuildContext context) {
|
|
return SafeArea(
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
children: <Widget>[
|
|
ListTile(
|
|
leading: const Icon(Icons.auto_awesome_rounded),
|
|
title: const Text('Add Capture'),
|
|
subtitle: const Text('Log a new moment or interaction'),
|
|
onTap: () =>
|
|
Navigator.of(context).pop(_InsightQuickAction.capture),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.note_add_outlined),
|
|
title: const Text('Add Note'),
|
|
subtitle: const Text('Save a context note for later'),
|
|
onTap: () =>
|
|
Navigator.of(context).pop(_InsightQuickAction.note),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.lightbulb_outline_rounded),
|
|
title: const Text('Add Idea'),
|
|
subtitle: const Text('Gift or event idea'),
|
|
onTap: () =>
|
|
Navigator.of(context).pop(_InsightQuickAction.idea),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.notifications_active_outlined),
|
|
title: const Text('Add Reminder'),
|
|
subtitle: const Text('Schedule a follow-up prompt'),
|
|
onTap: () =>
|
|
Navigator.of(context).pop(_InsightQuickAction.reminder),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
if (selected == null || !context.mounted) {
|
|
return;
|
|
}
|
|
await _handleQuickAction(context, ref, person, selected);
|
|
}
|
|
|
|
Future<String?> _showQuickTextCaptureDialog(
|
|
BuildContext context, {
|
|
required String title,
|
|
required String hintText,
|
|
}) async {
|
|
return showDialog<String>(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return _InsightQuickTextCaptureDialog(title: title, hintText: hintText);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _insightAvatar({
|
|
required PersonProfile person,
|
|
required _RelationshipVisual visual,
|
|
}) {
|
|
return Container(
|
|
width: 56,
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: LinearGradient(
|
|
colors: <Color>[
|
|
_shiftLightness(visual.color, 0.16),
|
|
_shiftLightness(visual.color, -0.12),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
_initials(person.name),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _miniMetric({required String label, required String value}) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF2F8FB),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
label,
|
|
style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
value,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
color: AppTheme.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _signalRows({
|
|
required BuildContext context,
|
|
required List<SignalItem> signals,
|
|
required AsyncValue<SignalsFeed> sourceState,
|
|
}) {
|
|
if (sourceState.isLoading && sourceState.asData == null) {
|
|
return const <Widget>[
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 8),
|
|
child: LinearProgressIndicator(minHeight: 3),
|
|
),
|
|
];
|
|
}
|
|
|
|
if (signals.isEmpty) {
|
|
if (sourceState.hasError) {
|
|
return <Widget>[
|
|
const _SectionEmpty(
|
|
label:
|
|
'Signals unavailable right now. Backend sync can repopulate.',
|
|
),
|
|
];
|
|
}
|
|
return <Widget>[
|
|
const _SectionEmpty(
|
|
label: 'No promotions/signals targeted to this person yet.',
|
|
),
|
|
];
|
|
}
|
|
|
|
return signals
|
|
.map(
|
|
(SignalItem item) => _InsightRow(
|
|
title: item.title,
|
|
subtitle: item.description ?? 'No description',
|
|
meta: '${item.type.toUpperCase()} • ${_shortDate(item.createdAt)}',
|
|
),
|
|
)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
PersonProfile? _findPersonById(List<PersonProfile> people, String id) {
|
|
for (final PersonProfile person in people) {
|
|
if (person.id == id) {
|
|
return person;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
List<DashboardTask> _findTasksForPerson(
|
|
List<DashboardTask> tasks,
|
|
PersonProfile person,
|
|
) {
|
|
final List<String> tokens = person.name
|
|
.toLowerCase()
|
|
.split(' ')
|
|
.where((String token) => token.length > 2)
|
|
.toList(growable: false);
|
|
if (tokens.isEmpty) {
|
|
return const <DashboardTask>[];
|
|
}
|
|
return tasks
|
|
.where((DashboardTask task) {
|
|
final String fullText = '${task.title} ${task.description}'
|
|
.toLowerCase();
|
|
for (final String token in tokens) {
|
|
if (fullText.contains(token)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
})
|
|
.toList(growable: false)
|
|
..sort((DashboardTask a, DashboardTask b) => a.when.compareTo(b.when));
|
|
}
|
|
}
|
|
|
|
int _comparePreferenceSignals(
|
|
PersonPreferenceSignal a,
|
|
PersonPreferenceSignal b,
|
|
) {
|
|
int statusRank(PreferenceSignalStatus status) => switch (status) {
|
|
PreferenceSignalStatus.inferred => 0,
|
|
PreferenceSignalStatus.confirmed => 1,
|
|
PreferenceSignalStatus.dismissed => 2,
|
|
};
|
|
|
|
final int byStatus = statusRank(a.status).compareTo(statusRank(b.status));
|
|
if (byStatus != 0) {
|
|
return byStatus;
|
|
}
|
|
final int byConfidence = b.confidence.compareTo(a.confidence);
|
|
if (byConfidence != 0) {
|
|
return byConfidence;
|
|
}
|
|
return a.label.toLowerCase().compareTo(b.label.toLowerCase());
|
|
}
|
|
|
|
class _InsightPreferenceSignalCard extends StatelessWidget {
|
|
const _InsightPreferenceSignalCard({
|
|
required this.signal,
|
|
required this.compact,
|
|
required this.onConfirm,
|
|
required this.onDismiss,
|
|
required this.onWhy,
|
|
});
|
|
|
|
final PersonPreferenceSignal signal;
|
|
final bool compact;
|
|
final Future<void> Function() onConfirm;
|
|
final Future<void> Function() onDismiss;
|
|
final VoidCallback onWhy;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final Color tone = switch (signal.status) {
|
|
PreferenceSignalStatus.confirmed => const Color(0xFF1D9C66),
|
|
PreferenceSignalStatus.dismissed => const Color(0xFF9A6A00),
|
|
PreferenceSignalStatus.inferred => AppTheme.primary,
|
|
};
|
|
final IconData polarityIcon = switch (signal.polarity) {
|
|
PreferenceSignalPolarity.like => Icons.thumb_up_alt_outlined,
|
|
PreferenceSignalPolarity.dislike => Icons.thumb_down_alt_outlined,
|
|
PreferenceSignalPolarity.neutral => Icons.tune_rounded,
|
|
};
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF5FAFB),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.white),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: <Widget>[
|
|
Icon(polarityIcon, size: 16, color: tone),
|
|
Text(
|
|
signal.label,
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
_PreferenceBadge(label: signal.status.name, color: tone),
|
|
_PreferenceBadge(
|
|
label: '${(signal.confidence * 100).round()}%',
|
|
color: AppTheme.textSecondary,
|
|
filled: false,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'${signal.category.toUpperCase()} • seen ${signal.occurrenceCount}x',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: <Widget>[
|
|
OutlinedButton(onPressed: onWhy, child: const Text('Why?')),
|
|
if (signal.status != PreferenceSignalStatus.confirmed)
|
|
FilledButton.tonal(
|
|
onPressed: () async => onConfirm(),
|
|
child: const Text('Confirm'),
|
|
),
|
|
if (signal.status != PreferenceSignalStatus.dismissed)
|
|
TextButton(
|
|
onPressed: () async => onDismiss(),
|
|
child: const Text('Dismiss'),
|
|
),
|
|
],
|
|
),
|
|
if (!compact && signal.sourceApps.isNotEmpty) ...<Widget>[
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Sources: ${signal.sourceApps.join(', ')}',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PreferenceBadge extends StatelessWidget {
|
|
const _PreferenceBadge({
|
|
required this.label,
|
|
required this.color,
|
|
this.filled = true,
|
|
});
|
|
|
|
final String label;
|
|
final Color color;
|
|
final bool filled;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: filled ? color.withValues(alpha: 0.12) : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(99),
|
|
border: Border.all(color: color.withValues(alpha: filled ? 0.2 : 0.35)),
|
|
),
|
|
child: Text(
|
|
label.toUpperCase(),
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: color),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InsightQuickIdeaDraft {
|
|
const _InsightQuickIdeaDraft({
|
|
required this.type,
|
|
required this.title,
|
|
required this.details,
|
|
});
|
|
|
|
final IdeaType type;
|
|
final String title;
|
|
final String details;
|
|
}
|
|
|
|
class _InsightQuickIdeaDialog extends StatefulWidget {
|
|
const _InsightQuickIdeaDialog();
|
|
|
|
@override
|
|
State<_InsightQuickIdeaDialog> createState() =>
|
|
_InsightQuickIdeaDialogState();
|
|
}
|
|
|
|
class _InsightQuickIdeaDialogState extends State<_InsightQuickIdeaDialog> {
|
|
IdeaType _type = IdeaType.gift;
|
|
final TextEditingController _titleController = TextEditingController();
|
|
final TextEditingController _detailsController = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_titleController.dispose();
|
|
_detailsController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Add Idea'),
|
|
content: SingleChildScrollView(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
SegmentedButton<IdeaType>(
|
|
segments: const <ButtonSegment<IdeaType>>[
|
|
ButtonSegment<IdeaType>(
|
|
value: IdeaType.gift,
|
|
label: Text('Gift'),
|
|
),
|
|
ButtonSegment<IdeaType>(
|
|
value: IdeaType.event,
|
|
label: Text('Event'),
|
|
),
|
|
],
|
|
selected: <IdeaType>{_type},
|
|
onSelectionChanged: (Set<IdeaType> values) {
|
|
setState(() {
|
|
_type = values.first;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _titleController,
|
|
decoration: const InputDecoration(labelText: 'Title'),
|
|
),
|
|
TextField(
|
|
controller: _detailsController,
|
|
maxLines: 3,
|
|
decoration: const InputDecoration(labelText: 'Details'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final String title = _titleController.text.trim();
|
|
if (title.isEmpty) {
|
|
return;
|
|
}
|
|
Navigator.of(context).pop(
|
|
_InsightQuickIdeaDraft(
|
|
type: _type,
|
|
title: title,
|
|
details: _detailsController.text.trim(),
|
|
),
|
|
);
|
|
},
|
|
child: const Text('Add'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InsightQuickReminderDraft {
|
|
const _InsightQuickReminderDraft({
|
|
required this.title,
|
|
required this.cadence,
|
|
required this.nextAt,
|
|
});
|
|
|
|
final String title;
|
|
final ReminderCadence cadence;
|
|
final DateTime nextAt;
|
|
}
|
|
|
|
class _InsightQuickReminderDialog extends StatefulWidget {
|
|
const _InsightQuickReminderDialog();
|
|
|
|
@override
|
|
State<_InsightQuickReminderDialog> createState() =>
|
|
_InsightQuickReminderDialogState();
|
|
}
|
|
|
|
class _InsightQuickReminderDialogState
|
|
extends State<_InsightQuickReminderDialog> {
|
|
final TextEditingController _titleController = TextEditingController();
|
|
ReminderCadence _cadence = ReminderCadence.weekly;
|
|
DateTime _nextAt = DateTime.now().add(const Duration(days: 1));
|
|
|
|
@override
|
|
void dispose() {
|
|
_titleController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Add Reminder'),
|
|
content: SingleChildScrollView(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
TextField(
|
|
controller: _titleController,
|
|
decoration: const InputDecoration(labelText: 'Title'),
|
|
),
|
|
const SizedBox(height: 10),
|
|
DropdownButtonFormField<ReminderCadence>(
|
|
initialValue: _cadence,
|
|
decoration: const InputDecoration(labelText: 'Cadence'),
|
|
items: ReminderCadence.values
|
|
.map(
|
|
(ReminderCadence cadence) =>
|
|
DropdownMenuItem<ReminderCadence>(
|
|
value: cadence,
|
|
child: Text(cadence.name),
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
onChanged: (ReminderCadence? value) {
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_cadence = value;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
children: <Widget>[
|
|
Expanded(
|
|
child: Text(
|
|
'Next: ${_nextAt.year}-${_nextAt.month.toString().padLeft(2, '0')}-${_nextAt.day.toString().padLeft(2, '0')} ${_nextAt.hour.toString().padLeft(2, '0')}:${_nextAt.minute.toString().padLeft(2, '0')}',
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
final DateTime now = DateTime.now();
|
|
final DateTime? date = await showDatePicker(
|
|
context: context,
|
|
firstDate: now,
|
|
lastDate: now.add(const Duration(days: 3650)),
|
|
initialDate: _nextAt,
|
|
);
|
|
if (date == null || !context.mounted) {
|
|
return;
|
|
}
|
|
final TimeOfDay? time = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay.fromDateTime(_nextAt),
|
|
);
|
|
if (time == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_nextAt = DateTime(
|
|
date.year,
|
|
date.month,
|
|
date.day,
|
|
time.hour,
|
|
time.minute,
|
|
);
|
|
});
|
|
},
|
|
child: const Text('Change'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final String title = _titleController.text.trim();
|
|
if (title.isEmpty) {
|
|
return;
|
|
}
|
|
Navigator.of(context).pop(
|
|
_InsightQuickReminderDraft(
|
|
title: title,
|
|
cadence: _cadence,
|
|
nextAt: _nextAt,
|
|
),
|
|
);
|
|
},
|
|
child: const Text('Add'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InsightQuickTextCaptureDialog extends StatefulWidget {
|
|
const _InsightQuickTextCaptureDialog({
|
|
required this.title,
|
|
required this.hintText,
|
|
});
|
|
|
|
final String title;
|
|
final String hintText;
|
|
|
|
@override
|
|
State<_InsightQuickTextCaptureDialog> createState() =>
|
|
_InsightQuickTextCaptureDialogState();
|
|
}
|
|
|
|
class _InsightQuickTextCaptureDialogState
|
|
extends State<_InsightQuickTextCaptureDialog> {
|
|
late final TextEditingController _controller;
|
|
bool _attemptedSubmit = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = TextEditingController();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final String value = _controller.text.trim();
|
|
return AlertDialog(
|
|
title: Text(widget.title),
|
|
content: TextField(
|
|
controller: _controller,
|
|
minLines: 2,
|
|
maxLines: 4,
|
|
autofocus: true,
|
|
onChanged: (_) {
|
|
if (_attemptedSubmit) {
|
|
setState(() {});
|
|
}
|
|
},
|
|
decoration: InputDecoration(
|
|
hintText: widget.hintText,
|
|
errorText: _attemptedSubmit && value.isEmpty ? 'Required' : null,
|
|
),
|
|
),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final String submitted = _controller.text.trim();
|
|
if (submitted.isEmpty) {
|
|
setState(() {
|
|
_attemptedSubmit = true;
|
|
});
|
|
return;
|
|
}
|
|
Navigator.of(context).pop(submitted);
|
|
},
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InsightSectionCard extends StatelessWidget {
|
|
const _InsightSectionCard({
|
|
required this.title,
|
|
required this.icon,
|
|
required this.children,
|
|
});
|
|
|
|
final String title;
|
|
final IconData icon;
|
|
final List<Widget> children;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FrostedCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 6,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: <Widget>[
|
|
Icon(icon, size: 18, color: AppTheme.primary),
|
|
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
...children,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InsightRow extends StatelessWidget {
|
|
const _InsightRow({
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.meta,
|
|
});
|
|
|
|
final String title;
|
|
final String subtitle;
|
|
final String meta;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF5FAFB),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(title, style: Theme.of(context).textTheme.titleSmall),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
subtitle,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
meta,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionEmpty extends StatelessWidget {
|
|
const _SectionEmpty({required this.label});
|
|
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF5FAFB),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TagPill extends StatelessWidget {
|
|
const _TagPill({required this.label});
|
|
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFEFF7FA),
|
|
borderRadius: BorderRadius.circular(99),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatCard extends StatelessWidget {
|
|
const _StatCard({
|
|
required this.label,
|
|
required this.value,
|
|
required this.accent,
|
|
required this.compact,
|
|
});
|
|
|
|
final String label;
|
|
final String value;
|
|
final Color accent;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FrostedCard(
|
|
padding: const EdgeInsets.all(16),
|
|
child: SizedBox(
|
|
width: compact ? double.infinity : 188,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Container(
|
|
height: 8,
|
|
width: 40,
|
|
decoration: BoxDecoration(
|
|
color: accent,
|
|
borderRadius: BorderRadius.circular(99),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
value,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TaskRow extends StatelessWidget {
|
|
const _TaskRow({
|
|
required this.task,
|
|
required this.onToggleDone,
|
|
required this.compact,
|
|
});
|
|
|
|
final DashboardTask task;
|
|
final VoidCallback onToggleDone;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF5FAFB),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
InkWell(
|
|
onTap: onToggleDone,
|
|
borderRadius: BorderRadius.circular(20),
|
|
child: Icon(
|
|
task.done
|
|
? Icons.check_circle_rounded
|
|
: Icons.radio_button_unchecked_rounded,
|
|
color: task.done
|
|
? const Color(0xFF1D9C66)
|
|
: const Color(0xFF1AB6C8),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
task.title,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
decoration: task.done ? TextDecoration.lineThrough : null,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
task.description,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (!compact) ...<Widget>[
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
_shortDate(task.when),
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RelationshipVisual {
|
|
const _RelationshipVisual({
|
|
required this.label,
|
|
required this.icon,
|
|
required this.color,
|
|
});
|
|
|
|
final String label;
|
|
final IconData icon;
|
|
final Color color;
|
|
}
|
|
|
|
const _RelationshipVisual _spouseVisual = _RelationshipVisual(
|
|
label: 'Spouse',
|
|
icon: Icons.favorite_rounded,
|
|
color: Color(0xFFE0557B),
|
|
);
|
|
|
|
const _RelationshipVisual _fianceVisual = _RelationshipVisual(
|
|
label: 'Fiance',
|
|
icon: Icons.diamond_rounded,
|
|
color: Color(0xFFCB6CF2),
|
|
);
|
|
|
|
const _RelationshipVisual _partnerVisual = _RelationshipVisual(
|
|
label: 'Partner',
|
|
icon: Icons.favorite_border_rounded,
|
|
color: Color(0xFFE67A5A),
|
|
);
|
|
|
|
const _RelationshipVisual _bestieVisual = _RelationshipVisual(
|
|
label: 'Bestie',
|
|
icon: Icons.emoji_emotions_rounded,
|
|
color: Color(0xFF8E6BFF),
|
|
);
|
|
|
|
const _RelationshipVisual _friendVisual = _RelationshipVisual(
|
|
label: 'Friend',
|
|
icon: Icons.groups_rounded,
|
|
color: Color(0xFF3F8CFF),
|
|
);
|
|
|
|
const _RelationshipVisual _familyVisual = _RelationshipVisual(
|
|
label: 'Family',
|
|
icon: Icons.family_restroom_rounded,
|
|
color: Color(0xFF2FB67D),
|
|
);
|
|
|
|
const _RelationshipVisual _sisterVisual = _RelationshipVisual(
|
|
label: 'Sister',
|
|
icon: Icons.female_rounded,
|
|
color: Color(0xFFFF6EA9),
|
|
);
|
|
|
|
const _RelationshipVisual _brotherVisual = _RelationshipVisual(
|
|
label: 'Brother',
|
|
icon: Icons.male_rounded,
|
|
color: Color(0xFF4A7DFF),
|
|
);
|
|
|
|
const _RelationshipVisual _otherVisual = _RelationshipVisual(
|
|
label: 'Connection',
|
|
icon: Icons.hub_rounded,
|
|
color: Color(0xFF5F7280),
|
|
);
|
|
|
|
_RelationshipVisual _relationshipVisualFor(String relationship) {
|
|
final String value = relationship.toLowerCase();
|
|
|
|
if (_containsAny(value, <String>['spouse', 'wife', 'husband'])) {
|
|
return _spouseVisual;
|
|
}
|
|
if (_containsAny(value, <String>['fiance', 'engaged'])) {
|
|
return _fianceVisual;
|
|
}
|
|
if (_containsAny(value, <String>[
|
|
'girlfriend',
|
|
'boyfriend',
|
|
'partner',
|
|
'gf',
|
|
'bf',
|
|
])) {
|
|
return _partnerVisual;
|
|
}
|
|
if (_containsAny(value, <String>['bestie', 'best friend', 'bff'])) {
|
|
return _bestieVisual;
|
|
}
|
|
if (_containsAny(value, <String>['sister', 'sis'])) {
|
|
return _sisterVisual;
|
|
}
|
|
if (_containsAny(value, <String>['brother', 'bro'])) {
|
|
return _brotherVisual;
|
|
}
|
|
if (_containsAny(value, <String>[
|
|
'family',
|
|
'mother',
|
|
'father',
|
|
'mom',
|
|
'dad',
|
|
'parent',
|
|
'aunt',
|
|
'uncle',
|
|
'cousin',
|
|
'grand',
|
|
])) {
|
|
return _familyVisual;
|
|
}
|
|
if (_containsAny(value, <String>['friend', 'pal', 'buddy', 'mate'])) {
|
|
return _friendVisual;
|
|
}
|
|
return _otherVisual;
|
|
}
|
|
|
|
bool _containsAny(String value, List<String> needles) {
|
|
for (final String needle in needles) {
|
|
if (value.contains(needle)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Color _shiftLightness(Color color, double delta) {
|
|
final HSLColor hsl = HSLColor.fromColor(color);
|
|
final double next = (hsl.lightness + delta).clamp(0.0, 1.0);
|
|
return hsl.withLightness(next).toColor();
|
|
}
|
|
|
|
String _initials(String name) {
|
|
final List<String> parts = name
|
|
.split(' ')
|
|
.where((String part) => part.isNotEmpty)
|
|
.toList(growable: false);
|
|
if (parts.isEmpty) {
|
|
return '?';
|
|
}
|
|
|
|
if (parts.length == 1) {
|
|
return parts.first
|
|
.substring(0, math.min(2, parts.first.length))
|
|
.toUpperCase();
|
|
}
|
|
|
|
return '${parts.first[0]}${parts[1][0]}'.toUpperCase();
|
|
}
|
|
|
|
String _shortName(String name) {
|
|
final List<String> parts = name
|
|
.split(' ')
|
|
.where((String part) => part.isNotEmpty)
|
|
.toList(growable: false);
|
|
if (parts.isEmpty) {
|
|
return 'Unknown';
|
|
}
|
|
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',
|
|
'Tue',
|
|
'Wed',
|
|
'Thu',
|
|
'Fri',
|
|
'Sat',
|
|
'Sun',
|
|
];
|
|
final String weekday = weekdays[value.weekday - 1];
|
|
final String day = value.day.toString().padLeft(2, '0');
|
|
return '$weekday $day';
|
|
}
|