Split graph into preview and explorer modes

This commit is contained in:
Rijad Zuzo
2026-02-23 15:04:36 +01:00
parent 69f91ce2b9
commit e3151e71d0
3 changed files with 399 additions and 117 deletions
+346 -117
View File
@@ -138,34 +138,49 @@ class _RelationshipGraphCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final List<_RelationshipVisual> legend = _collectGraphLegend(people);
return FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
Row(
children: <Widget>[
Text(
'Relationship Graph',
style: Theme.of(context).textTheme.titleLarge,
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,
),
),
),
],
),
),
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'),
),
],
),
@@ -189,77 +204,12 @@ class _RelationshipGraphCard extends StatelessWidget {
borderRadius: BorderRadius.circular(24),
child: 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(
return _RelationshipGraphCanvas(
people: people,
size: constraints.biggest,
compact: compact,
);
return InteractiveViewer(
minScale: 0.65,
maxScale: 2.8,
boundaryMargin: const EdgeInsets.all(220),
panEnabled: true,
scaleEnabled: true,
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: () =>
_openPersonInsights(context, 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),
),
),
),
],
),
),
mode: _GraphCanvasMode.preview,
onOpenPersonInsights: (PersonProfile person) =>
_openPersonInsights(context, person),
);
},
),
@@ -267,7 +217,7 @@ class _RelationshipGraphCard extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'Pinch to zoom, drag to pan, long-press a person to open insights.',
'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),
@@ -276,7 +226,7 @@ class _RelationshipGraphCard extends StatelessWidget {
Wrap(
spacing: 8,
runSpacing: 8,
children: _collectLegend(people)
children: legend
.map(
(_RelationshipVisual visual) =>
_RelationshipLegendPill(visual: visual),
@@ -287,40 +237,319 @@ class _RelationshipGraphCard extends StatelessWidget {
),
);
}
}
List<_RelationshipVisual> _collectLegend(List<PersonProfile> source) {
final Set<String> seen = <String>{};
final List<_RelationshipVisual> result = <_RelationshipVisual>[];
enum _GraphCanvasMode { preview, explorer }
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;
}
}
class _RelationshipGraphExplorerPage extends StatefulWidget {
const _RelationshipGraphExplorerPage({required this.people});
if (result.isEmpty) {
result.add(_otherVisual);
}
return result;
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();
}
void _openPersonInsights(BuildContext context, PersonProfile person) {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) {
return _PersonInsightsPage(personId: person.id);
},
@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,