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 - 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. 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) ## Latest Milestone (2026-02-22): Quick-Capture Dialog Regression Test (People)
Added explicit widget-test coverage so controller lifecycle issues in quick Added explicit widget-test coverage so controller lifecycle issues in quick
+267 -38
View File
@@ -138,11 +138,15 @@ class _RelationshipGraphCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final List<_RelationshipVisual> legend = _collectGraphLegend(people);
return FrostedCard( return FrostedCard(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Wrap( Row(
children: <Widget>[
Expanded(
child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
@@ -162,11 +166,22 @@ class _RelationshipGraphCard extends StatelessWidget {
), ),
child: Text( child: Text(
'${people.length} nodes', '${people.length} nodes',
style: Theme.of( style: Theme.of(context).textTheme.labelLarge?.copyWith(
context, color: AppTheme.primary,
).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), const SizedBox(height: 12),
@@ -188,6 +203,197 @@ class _RelationshipGraphCard extends StatelessWidget {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
child: LayoutBuilder( 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) { builder: (BuildContext context, BoxConstraints constraints) {
if (people.isEmpty) { if (people.isEmpty) {
return Center( return Center(
@@ -196,8 +402,9 @@ class _RelationshipGraphCard extends StatelessWidget {
child: Text( child: Text(
'No people yet. Add your first relationship to start the graph.', 'No people yet. Add your first relationship to start the graph.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge style: Theme.of(
?.copyWith(color: AppTheme.textSecondary), context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
), ),
), ),
); );
@@ -209,12 +416,16 @@ class _RelationshipGraphCard extends StatelessWidget {
compact: compact, compact: compact,
); );
final bool explorer = mode == _GraphCanvasMode.explorer;
return InteractiveViewer( return InteractiveViewer(
minScale: 0.65, transformationController: transformationController,
maxScale: 2.8, minScale: explorer ? 0.55 : 1.0,
boundaryMargin: const EdgeInsets.all(220), maxScale: explorer ? 3.2 : 1.0,
panEnabled: true, boundaryMargin: explorer
scaleEnabled: true, ? const EdgeInsets.all(260)
: EdgeInsets.zero,
panEnabled: explorer,
scaleEnabled: explorer,
child: SizedBox( child: SizedBox(
width: constraints.maxWidth, width: constraints.maxWidth,
height: constraints.maxHeight, height: constraints.maxHeight,
@@ -232,8 +443,7 @@ class _RelationshipGraphCard extends StatelessWidget {
(_GraphNode node) => _GraphBubble( (_GraphNode node) => _GraphBubble(
node: node, node: node,
compact: compact, compact: compact,
onLongPress: () => onLongPress: () => onOpenPersonInsights(node.person),
_openPersonInsights(context, node.person),
), ),
), ),
_CenterNode(center: layout.center, compact: compact), _CenterNode(center: layout.center, compact: compact),
@@ -252,9 +462,42 @@ class _RelationshipGraphCard extends StatelessWidget {
), ),
child: Text( child: Text(
'+${layout.hiddenCount} more', '+${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 style: Theme.of(context).textTheme.labelLarge
?.copyWith(color: AppTheme.textSecondary), ?.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 Set<String> seen = <String>{};
final List<_RelationshipVisual> result = <_RelationshipVisual>[]; final List<_RelationshipVisual> result = <_RelationshipVisual>[];
@@ -310,6 +531,15 @@ class _RelationshipGraphCard extends StatelessWidget {
return result; 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) { void _openPersonInsights(BuildContext context, PersonProfile person) {
Navigator.of(context).push<void>( Navigator.of(context).push<void>(
MaterialPageRoute<void>( MaterialPageRoute<void>(
@@ -319,7 +549,6 @@ class _RelationshipGraphCard extends StatelessWidget {
), ),
); );
} }
}
class _GraphLayout { class _GraphLayout {
const _GraphLayout({ const _GraphLayout({
@@ -55,6 +55,7 @@ void main() {
expect(find.text('Relationship Graph'), findsOneWidget); expect(find.text('Relationship Graph'), findsOneWidget);
expect(find.byType(InteractiveViewer), findsOneWidget); expect(find.byType(InteractiveViewer), findsOneWidget);
expect(find.text('Explore'), findsOneWidget);
final Finder avaNode = find.byKey( final Finder avaNode = find.byKey(
const ValueKey<String>('graph-node-p-ava'), const ValueKey<String>('graph-node-p-ava'),
@@ -101,6 +102,20 @@ void main() {
await tester.pumpAndSettle(const Duration(milliseconds: 500)); await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(find.text('Relationship Graph'), findsOneWidget); 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);
}, },
); );
} }