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
+38
View File
@@ -7,6 +7,44 @@ Updated: 2026-02-22
- After every sensible code/documentation change set, create a git commit as
the last step so the next agent session can pick up from clean checkpoints.
## Latest Milestone (2026-02-23): Relationship Graph Preview + Full-Screen Explorer
Reworked the graph interaction model to be mobile-friendly on the dashboard and
still support smooth pan/zoom exploration.
- `lib/features/dashboard/dashboard_view.dart`
- refactored graph rendering into a shared canvas widget with two modes:
- `preview` (dashboard, scroll-first)
- `explorer` (full-screen, pan/zoom-first)
- dashboard graph card is now a scroll-safe preview:
- keeps node long-press for `Person Insights`
- disables pan/zoom interaction on the dashboard canvas
- adds explicit `Explore` / `Open Explorer` CTA button
- updated helper copy to clarify dashboard preview vs explorer behavior
- added full-screen `Relationship Graph` explorer page:
- pan enabled
- pinch-to-zoom enabled
- `Reset view` control
- same long-press-to-open-person-insights behavior
- shared legend display
- extracted shared graph helpers (legend + navigation helpers) to keep the
preview and explorer behavior consistent
- `test/features/dashboard/dashboard_graph_interactions_test.dart`
- extended coverage to verify:
- dashboard preview renders `Explore` CTA
- explorer page opens from dashboard
- `Reset view` action is available and works
- returning to dashboard still works
- UX decision captured in implementation
- dashboard remains scroll-first on mobile (no hidden pan-mode toggle)
- graph exploration happens in a dedicated full-screen context
- Validation
- `flutter analyze` -> pass
- `flutter test` -> pass
## Latest Milestone (2026-02-22): Quick-Capture Dialog Regression Test (People)
Added explicit widget-test coverage so controller lifecycle issues in quick
+267 -38
View File
@@ -138,11 +138,15 @@ 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(
Row(
children: <Widget>[
Expanded(
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
@@ -162,11 +166,22 @@ class _RelationshipGraphCard extends StatelessWidget {
),
child: Text(
'${people.length} nodes',
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: AppTheme.primary),
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),
@@ -188,6 +203,197 @@ class _RelationshipGraphCard extends StatelessWidget {
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(
@@ -196,8 +402,9 @@ class _RelationshipGraphCard extends StatelessWidget {
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),
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
),
),
);
@@ -209,12 +416,16 @@ class _RelationshipGraphCard extends StatelessWidget {
compact: compact,
);
final bool explorer = mode == _GraphCanvasMode.explorer;
return InteractiveViewer(
minScale: 0.65,
maxScale: 2.8,
boundaryMargin: const EdgeInsets.all(220),
panEnabled: true,
scaleEnabled: true,
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,
@@ -232,8 +443,7 @@ class _RelationshipGraphCard extends StatelessWidget {
(_GraphNode node) => _GraphBubble(
node: node,
compact: compact,
onLongPress: () =>
_openPersonInsights(context, node.person),
onLongPress: () => onOpenPersonInsights(node.person),
),
),
_CenterNode(center: layout.center, compact: compact),
@@ -252,9 +462,42 @@ class _RelationshipGraphCard extends StatelessWidget {
),
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),
),
],
),
),
),
],
@@ -262,33 +505,11 @@ class _RelationshipGraphCard extends StatelessWidget {
),
);
},
),
),
),
const SizedBox(height: 8),
Text(
'Pinch to zoom, drag to pan, long-press a person to open insights.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: _collectLegend(people)
.map(
(_RelationshipVisual visual) =>
_RelationshipLegendPill(visual: visual),
)
.toList(growable: false),
),
],
),
);
}
}
List<_RelationshipVisual> _collectLegend(List<PersonProfile> source) {
List<_RelationshipVisual> _collectGraphLegend(List<PersonProfile> source) {
final Set<String> seen = <String>{};
final List<_RelationshipVisual> result = <_RelationshipVisual>[];
@@ -310,6 +531,15 @@ class _RelationshipGraphCard extends StatelessWidget {
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>(
@@ -319,7 +549,6 @@ class _RelationshipGraphCard extends StatelessWidget {
),
);
}
}
class _GraphLayout {
const _GraphLayout({
@@ -55,6 +55,7 @@ void main() {
expect(find.text('Relationship Graph'), findsOneWidget);
expect(find.byType(InteractiveViewer), findsOneWidget);
expect(find.text('Explore'), findsOneWidget);
final Finder avaNode = find.byKey(
const ValueKey<String>('graph-node-p-ava'),
@@ -101,6 +102,20 @@ void main() {
await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(find.text('Relationship Graph'), findsOneWidget);
await tester.tap(find.widgetWithText(OutlinedButton, 'Explore'));
await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(find.text('Relationship Graph'), findsWidgets);
expect(find.byTooltip('Reset view'), findsOneWidget);
await tester.tap(find.byTooltip('Reset view'));
await tester.pumpAndSettle(const Duration(milliseconds: 250));
await tester.tap(find.byTooltip('Back'));
await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(find.text('Relationship Graph'), findsOneWidget);
},
);
}