Split graph into preview and explorer modes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
@@ -308,9 +529,18 @@ class _RelationshipGraphCard extends StatelessWidget {
|
||||
result.add(_otherVisual);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
void _openPersonInsights(BuildContext context, PersonProfile person) {
|
||||
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) {
|
||||
@@ -318,7 +548,6 @@ class _RelationshipGraphCard extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user