Add interactive graph gestures and person insights drilldown
This commit is contained in:
@@ -2,6 +2,38 @@
|
||||
|
||||
Updated: 2026-02-17
|
||||
|
||||
## Latest Milestone (2026-02-17): Interactive Relationship Graph + Person Insights Drilldown
|
||||
|
||||
Upgraded the dashboard graph from static visualization to interactive
|
||||
navigation:
|
||||
|
||||
- Graph interactions:
|
||||
- `lib/features/dashboard/dashboard_view.dart`
|
||||
- graph area now supports:
|
||||
- pan by drag gesture
|
||||
- pinch-to-zoom scaling
|
||||
- long-press on person bubbles
|
||||
- Long-press drilldown behavior:
|
||||
- opens a dedicated person insights page with back navigation to the graph
|
||||
- page sections are category-separated:
|
||||
- Ideas
|
||||
- Promotions & Signals
|
||||
- Moments
|
||||
- Reminders
|
||||
- Tasks & Follow-Ups
|
||||
- includes profile summary (relationship type, affinity, tags, notes)
|
||||
- Added interaction test coverage:
|
||||
- `test/features/dashboard/dashboard_graph_interactions_test.dart`
|
||||
- verifies:
|
||||
- graph contains interactive viewer
|
||||
- long-press on a person node opens insights screen
|
||||
- back button returns to graph
|
||||
|
||||
Validation for this milestone:
|
||||
|
||||
- `flutter analyze` -> pass
|
||||
- `flutter test` -> pass
|
||||
|
||||
## Latest Milestone (2026-02-17): Obsidian-Style Relationship Graph on Dashboard
|
||||
|
||||
Reworked the initial Dashboard section to lead with a relationship network
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||
import 'package:relationship_saver/features/signals/signals_controller.dart';
|
||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||
|
||||
class DashboardView extends ConsumerWidget {
|
||||
const DashboardView({super.key});
|
||||
@@ -207,47 +209,69 @@ class _RelationshipGraphCard extends StatelessWidget {
|
||||
compact: compact,
|
||||
);
|
||||
|
||||
return 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),
|
||||
),
|
||||
_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),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
@@ -285,6 +309,16 @@ class _RelationshipGraphCard extends StatelessWidget {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void _openPersonInsights(BuildContext context, PersonProfile person) {
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (BuildContext context) {
|
||||
return _PersonInsightsPage(personId: person.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GraphLayout {
|
||||
@@ -474,10 +508,15 @@ class _RelationshipEdgePainter extends CustomPainter {
|
||||
}
|
||||
|
||||
class _GraphBubble extends StatelessWidget {
|
||||
const _GraphBubble({required this.node, required this.compact});
|
||||
const _GraphBubble({
|
||||
required this.node,
|
||||
required this.compact,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
final _GraphNode node;
|
||||
final bool compact;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -489,79 +528,85 @@ class _GraphBubble extends StatelessWidget {
|
||||
top: node.position.dy - (diameter / 2),
|
||||
child: Tooltip(
|
||||
message: '${node.person.name} • ${node.visual.label}',
|
||||
child: SizedBox(
|
||||
width: labelWidth,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: diameter,
|
||||
height: diameter,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: <Color>[
|
||||
_shiftLightness(node.visual.color, 0.16),
|
||||
_shiftLightness(node.visual.color, -0.12),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: node.visual.color.withValues(alpha: 0.24),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_initials(node.person.name),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: -2,
|
||||
top: -2,
|
||||
child: Container(
|
||||
width: compact ? 20 : 22,
|
||||
height: compact ? 20 : 22,
|
||||
child: GestureDetector(
|
||||
key: ValueKey<String>('graph-node-${node.person.id}'),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onLongPress: onLongPress,
|
||||
child: SizedBox(
|
||||
width: labelWidth,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: diameter,
|
||||
height: diameter,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white,
|
||||
border: Border.all(
|
||||
color: node.visual.color.withValues(alpha: 0.8),
|
||||
gradient: LinearGradient(
|
||||
colors: <Color>[
|
||||
_shiftLightness(node.visual.color, 0.16),
|
||||
_shiftLightness(node.visual.color, -0.12),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: node.visual.color.withValues(alpha: 0.24),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_initials(node.person.name),
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: -2,
|
||||
top: -2,
|
||||
child: Container(
|
||||
width: compact ? 20 : 22,
|
||||
height: compact ? 20 : 22,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white,
|
||||
border: Border.all(
|
||||
color: node.visual.color.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
node.visual.icon,
|
||||
size: compact ? 12 : 13,
|
||||
color: node.visual.color,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
node.visual.icon,
|
||||
size: compact ? 12 : 13,
|
||||
color: node.visual.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!compact) ...<Widget>[
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
_shortName(node.person.name),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!compact) ...<Widget>[
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
_shortName(node.person.name),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -652,6 +697,583 @@ class _RelationshipLegendPill extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _PersonInsightsPage extends ConsumerWidget {
|
||||
const _PersonInsightsPage({required this.personId});
|
||||
|
||||
final String personId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final AsyncValue<LocalDataState> localData = ref.watch(
|
||||
localRepositoryProvider,
|
||||
);
|
||||
final AsyncValue<SignalsFeed> signals = ref.watch(
|
||||
signalsControllerProvider,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Person Insights')),
|
||||
body: localData.when(
|
||||
data: (LocalDataState data) {
|
||||
final PersonProfile? person = _findPersonById(data.people, personId);
|
||||
if (person == null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Person was not found in local data.',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final List<RelationshipMoment> moments =
|
||||
data.moments
|
||||
.where(
|
||||
(RelationshipMoment moment) => moment.personId == person.id,
|
||||
)
|
||||
.toList(growable: false)
|
||||
..sort(
|
||||
(RelationshipMoment a, RelationshipMoment b) =>
|
||||
b.at.compareTo(a.at),
|
||||
);
|
||||
final List<RelationshipIdea> ideas =
|
||||
data.ideas
|
||||
.where((RelationshipIdea idea) => idea.personId == person.id)
|
||||
.toList(growable: false)
|
||||
..sort(
|
||||
(RelationshipIdea a, RelationshipIdea b) =>
|
||||
b.createdAt.compareTo(a.createdAt),
|
||||
);
|
||||
final List<ReminderRule> reminders =
|
||||
data.reminders
|
||||
.where(
|
||||
(ReminderRule reminder) => reminder.personId == person.id,
|
||||
)
|
||||
.toList(growable: false)
|
||||
..sort(
|
||||
(ReminderRule a, ReminderRule b) =>
|
||||
a.nextAt.compareTo(b.nextAt),
|
||||
);
|
||||
final List<DashboardTask> relatedTasks = _findTasksForPerson(
|
||||
data.tasks,
|
||||
person,
|
||||
);
|
||||
|
||||
final List<SignalItem> personSignals =
|
||||
signals.asData?.value.items
|
||||
.where((SignalItem item) => item.personId == person.id)
|
||||
.toList(growable: false) ??
|
||||
const <SignalItem>[];
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final bool compact = constraints.maxWidth < 760;
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
compact ? 16 : 24,
|
||||
compact ? 16 : 20,
|
||||
compact ? 16 : 24,
|
||||
compact ? 24 : 30,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_personProfileCard(context, person, compact),
|
||||
const SizedBox(height: 14),
|
||||
_InsightSectionCard(
|
||||
title: 'Ideas',
|
||||
icon: Icons.lightbulb_outline_rounded,
|
||||
children: ideas.isEmpty
|
||||
? <Widget>[
|
||||
const _SectionEmpty(
|
||||
label:
|
||||
'No captured ideas yet for this relationship.',
|
||||
),
|
||||
]
|
||||
: ideas
|
||||
.map(
|
||||
(RelationshipIdea idea) => _InsightRow(
|
||||
title: idea.title,
|
||||
subtitle: idea.details,
|
||||
meta:
|
||||
'${idea.type.name.toUpperCase()} • ${_shortDate(idea.createdAt)}',
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_InsightSectionCard(
|
||||
title: 'Promotions & Signals',
|
||||
icon: Icons.local_offer_outlined,
|
||||
children: _signalRows(
|
||||
context: context,
|
||||
signals: personSignals,
|
||||
sourceState: signals,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_InsightSectionCard(
|
||||
title: 'Moments',
|
||||
icon: Icons.auto_awesome_rounded,
|
||||
children: moments.isEmpty
|
||||
? <Widget>[
|
||||
const _SectionEmpty(
|
||||
label: 'No logged moments for this person yet.',
|
||||
),
|
||||
]
|
||||
: moments
|
||||
.map(
|
||||
(RelationshipMoment moment) => _InsightRow(
|
||||
title: moment.title,
|
||||
subtitle: moment.summary,
|
||||
meta:
|
||||
'${moment.type.toUpperCase()} • ${_shortDate(moment.at)}',
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_InsightSectionCard(
|
||||
title: 'Reminders',
|
||||
icon: Icons.notifications_active_outlined,
|
||||
children: reminders.isEmpty
|
||||
? <Widget>[
|
||||
const _SectionEmpty(
|
||||
label:
|
||||
'No reminders configured for this relationship.',
|
||||
),
|
||||
]
|
||||
: reminders
|
||||
.map(
|
||||
(ReminderRule reminder) => _InsightRow(
|
||||
title: reminder.title,
|
||||
subtitle: reminder.enabled
|
||||
? 'Enabled'
|
||||
: 'Disabled',
|
||||
meta:
|
||||
'${reminder.cadence.name.toUpperCase()} • Next ${_shortDate(reminder.nextAt)}',
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_InsightSectionCard(
|
||||
title: 'Tasks & Follow-Ups',
|
||||
icon: Icons.checklist_rounded,
|
||||
children: relatedTasks.isEmpty
|
||||
? <Widget>[
|
||||
const _SectionEmpty(
|
||||
label:
|
||||
'No direct follow-up tasks found for this person.',
|
||||
),
|
||||
]
|
||||
: relatedTasks
|
||||
.map(
|
||||
(DashboardTask task) => _InsightRow(
|
||||
title: task.title,
|
||||
subtitle: task.description,
|
||||
meta:
|
||||
'${task.done ? 'DONE' : 'PENDING'} • ${_shortDate(task.when)}',
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (Object error, StackTrace stackTrace) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Could not load person insights.',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _personProfileCard(
|
||||
BuildContext context,
|
||||
PersonProfile person,
|
||||
bool compact,
|
||||
) {
|
||||
final _RelationshipVisual visual = _relationshipVisualFor(
|
||||
person.relationship,
|
||||
);
|
||||
|
||||
return FrostedCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
if (compact)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
_insightAvatar(person: person, visual: visual),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
person.name,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
person.relationship,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: <Widget>[
|
||||
_miniMetric(
|
||||
label: 'Affinity',
|
||||
value: '${person.affinityScore}%',
|
||||
),
|
||||
_miniMetric(
|
||||
label: 'Next Moment',
|
||||
value: _shortDate(person.nextMoment),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: <Widget>[
|
||||
_insightAvatar(person: person, visual: visual),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
person.name,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
person.relationship,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_miniMetric(
|
||||
label: 'Affinity',
|
||||
value: '${person.affinityScore}%',
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_miniMetric(
|
||||
label: 'Next Moment',
|
||||
value: _shortDate(person.nextMoment),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (person.tags.isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: person.tags
|
||||
.map((String tag) => _TagPill(label: tag))
|
||||
.toList(growable: false),
|
||||
),
|
||||
],
|
||||
if (person.notes.trim().isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
person.notes,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _insightAvatar({
|
||||
required PersonProfile person,
|
||||
required _RelationshipVisual visual,
|
||||
}) {
|
||||
return Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: <Color>[
|
||||
_shiftLightness(visual.color, 0.16),
|
||||
_shiftLightness(visual.color, -0.12),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_initials(person.name),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _miniMetric({required String label, required String value}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF2F8FB),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _signalRows({
|
||||
required BuildContext context,
|
||||
required List<SignalItem> signals,
|
||||
required AsyncValue<SignalsFeed> sourceState,
|
||||
}) {
|
||||
if (sourceState.isLoading && sourceState.asData == null) {
|
||||
return const <Widget>[
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: LinearProgressIndicator(minHeight: 3),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (signals.isEmpty) {
|
||||
if (sourceState.hasError) {
|
||||
return <Widget>[
|
||||
const _SectionEmpty(
|
||||
label:
|
||||
'Signals unavailable right now. Backend sync can repopulate.',
|
||||
),
|
||||
];
|
||||
}
|
||||
return <Widget>[
|
||||
const _SectionEmpty(
|
||||
label: 'No promotions/signals targeted to this person yet.',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return signals
|
||||
.map(
|
||||
(SignalItem item) => _InsightRow(
|
||||
title: item.title,
|
||||
subtitle: item.description ?? 'No description',
|
||||
meta: '${item.type.toUpperCase()} • ${_shortDate(item.createdAt)}',
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
PersonProfile? _findPersonById(List<PersonProfile> people, String id) {
|
||||
for (final PersonProfile person in people) {
|
||||
if (person.id == id) {
|
||||
return person;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<DashboardTask> _findTasksForPerson(
|
||||
List<DashboardTask> tasks,
|
||||
PersonProfile person,
|
||||
) {
|
||||
final List<String> tokens = person.name
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.where((String token) => token.length > 2)
|
||||
.toList(growable: false);
|
||||
if (tokens.isEmpty) {
|
||||
return const <DashboardTask>[];
|
||||
}
|
||||
return tasks
|
||||
.where((DashboardTask task) {
|
||||
final String fullText = '${task.title} ${task.description}'
|
||||
.toLowerCase();
|
||||
for (final String token in tokens) {
|
||||
if (fullText.contains(token)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.toList(growable: false)
|
||||
..sort((DashboardTask a, DashboardTask b) => a.when.compareTo(b.when));
|
||||
}
|
||||
}
|
||||
|
||||
class _InsightSectionCard extends StatelessWidget {
|
||||
const _InsightSectionCard({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FrostedCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(icon, size: 18, color: AppTheme.primary),
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InsightRow extends StatelessWidget {
|
||||
const _InsightRow({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.meta,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String meta;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5FAFB),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(title, style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
meta,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionEmpty extends StatelessWidget {
|
||||
const _SectionEmpty({required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5FAFB),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TagPill extends StatelessWidget {
|
||||
const _TagPill({required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF7FA),
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatCard extends StatelessWidget {
|
||||
const _StatCard({
|
||||
required this.label,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/features/dashboard/dashboard_view.dart';
|
||||
import 'package:relationship_saver/features/local/storage/local_data_store_in_memory.dart';
|
||||
import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart';
|
||||
import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart';
|
||||
import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'graph supports pan/zoom shell and opens person insights on long press',
|
||||
(WidgetTester tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(390, 844));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
|
||||
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: const Scaffold(body: DashboardView()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 600));
|
||||
|
||||
expect(find.text('Relationship Graph'), findsOneWidget);
|
||||
expect(find.byType(InteractiveViewer), findsOneWidget);
|
||||
|
||||
final Finder avaNode = find.byKey(
|
||||
const ValueKey<String>('graph-node-p-ava'),
|
||||
);
|
||||
expect(avaNode, findsOneWidget);
|
||||
|
||||
await tester.longPress(avaNode);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 700));
|
||||
|
||||
expect(find.text('Person Insights'), findsOneWidget);
|
||||
expect(find.text('Promotions & Signals'), findsOneWidget);
|
||||
expect(find.text('Ideas'), findsOneWidget);
|
||||
expect(find.text('Moments'), findsOneWidget);
|
||||
expect(find.text('Reminders'), findsOneWidget);
|
||||
|
||||
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