Files
rely/lib/features/dashboard/presentation/dashboard_view.dart
T
2026-05-19 19:17:35 +02:00

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';
}