From e3151e71d051d341dfbb40c1758f05882dc89e38 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Mon, 23 Feb 2026 15:04:36 +0100 Subject: [PATCH] Split graph into preview and explorer modes --- docs/progress.md | 38 ++ lib/features/dashboard/dashboard_view.dart | 463 +++++++++++++----- .../dashboard_graph_interactions_test.dart | 15 + 3 files changed, 399 insertions(+), 117 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index 99d8d20..5743dab 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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 diff --git a/lib/features/dashboard/dashboard_view.dart b/lib/features/dashboard/dashboard_view.dart index 1fc26e6..40e4ed3 100644 --- a/lib/features/dashboard/dashboard_view.dart +++ b/lib/features/dashboard/dashboard_view.dart @@ -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: [ - Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, + Row( children: [ - Text( - 'Relationship Graph', - style: Theme.of(context).textTheme.titleLarge, + Expanded( + child: Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + 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: [ - 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 source) { - final Set seen = {}; - 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 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( - MaterialPageRoute( - 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: [ + 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: [ + 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: [ + 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: [ + 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 people; + final bool compact; + final _GraphCanvasMode mode; + final ValueChanged 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: [ + 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: [ + 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 source) { + final Set seen = {}; + 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 people) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => + _RelationshipGraphExplorerPage(people: people), + ), + ); +} + +void _openPersonInsights(BuildContext context, PersonProfile person) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return _PersonInsightsPage(personId: person.id); + }, + ), + ); +} + class _GraphLayout { const _GraphLayout({ required this.center, diff --git a/test/features/dashboard/dashboard_graph_interactions_test.dart b/test/features/dashboard/dashboard_graph_interactions_test.dart index bd63f6b..25e97be 100644 --- a/test/features/dashboard/dashboard_graph_interactions_test.dart +++ b/test/features/dashboard/dashboard_graph_interactions_test.dart @@ -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('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); }, ); }