Add interactive graph gestures and person insights drilldown
This commit is contained in:
@@ -2,6 +2,38 @@
|
|||||||
|
|
||||||
Updated: 2026-02-17
|
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
|
## Latest Milestone (2026-02-17): Obsidian-Style Relationship Graph on Dashboard
|
||||||
|
|
||||||
Reworked the initial Dashboard section to lead with a relationship network
|
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_models.dart';
|
||||||
import 'package:relationship_saver/features/local/local_repository.dart';
|
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||||
import 'package:relationship_saver/features/shared/frosted_card.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 {
|
class DashboardView extends ConsumerWidget {
|
||||||
const DashboardView({super.key});
|
const DashboardView({super.key});
|
||||||
@@ -207,7 +209,16 @@ class _RelationshipGraphCard extends StatelessWidget {
|
|||||||
compact: compact,
|
compact: compact,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Stack(
|
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>[
|
children: <Widget>[
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
@@ -218,8 +229,12 @@ class _RelationshipGraphCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
...layout.nodes.map(
|
...layout.nodes.map(
|
||||||
(_GraphNode node) =>
|
(_GraphNode node) => _GraphBubble(
|
||||||
_GraphBubble(node: node, compact: compact),
|
node: node,
|
||||||
|
compact: compact,
|
||||||
|
onLongPress: () =>
|
||||||
|
_openPersonInsights(context, node.person),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_CenterNode(center: layout.center, compact: compact),
|
_CenterNode(center: layout.center, compact: compact),
|
||||||
if (layout.hiddenCount > 0)
|
if (layout.hiddenCount > 0)
|
||||||
@@ -243,11 +258,20 @@ 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),
|
const SizedBox(height: 10),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
@@ -285,6 +309,16 @@ class _RelationshipGraphCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
return result;
|
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 {
|
class _GraphLayout {
|
||||||
@@ -474,10 +508,15 @@ class _RelationshipEdgePainter extends CustomPainter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _GraphBubble extends StatelessWidget {
|
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 _GraphNode node;
|
||||||
final bool compact;
|
final bool compact;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -489,6 +528,10 @@ class _GraphBubble extends StatelessWidget {
|
|||||||
top: node.position.dy - (diameter / 2),
|
top: node.position.dy - (diameter / 2),
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: '${node.person.name} • ${node.visual.label}',
|
message: '${node.person.name} • ${node.visual.label}',
|
||||||
|
child: GestureDetector(
|
||||||
|
key: ValueKey<String>('graph-node-${node.person.id}'),
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onLongPress: onLongPress,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: labelWidth,
|
width: labelWidth,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -521,7 +564,8 @@ class _GraphBubble extends StatelessWidget {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
_initials(node.person.name),
|
_initials(node.person.name),
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
@@ -565,6 +609,7 @@ class _GraphBubble extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
class _StatCard extends StatelessWidget {
|
||||||
const _StatCard({
|
const _StatCard({
|
||||||
required this.label,
|
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