Add graph-first dashboard with relationship type nodes

This commit is contained in:
Rijad Zuzo
2026-02-18 00:05:07 +01:00
parent 9bb484e95d
commit f27b08501d
2 changed files with 731 additions and 12 deletions
+27
View File
@@ -2,6 +2,33 @@
Updated: 2026-02-17 Updated: 2026-02-17
## Latest Milestone (2026-02-17): Obsidian-Style Relationship Graph on Dashboard
Reworked the initial Dashboard section to lead with a relationship network
visual:
- Replaced the old top summary-only start with a graph-first hero:
- `lib/features/dashboard/dashboard_view.dart`
- new `Relationship Graph` card renders:
- center `YOU` node
- surrounding person bubbles from local people data
- custom-painted connection edges (radial + mesh links)
- relationship-type icon badges + color-coded edge/node styling
- Added relationship-type visual mapping for labels such as:
- spouse, fiance, partner/girlfriend/boyfriend, friend, bestie, family,
sister, brother, fallback connection
- Added adaptive layout behavior:
- compact/mobile and wide/web radius/node limits
- hidden-node indicator for large lists
- relationship legend chips under the graph
- Preserved existing dashboard flow below the graph:
- stats cards and `Next Moves` tasks remain available
Validation for this milestone:
- `flutter analyze` -> pass
- `flutter test` -> pass
## Latest Milestone (2026-02-17): Connectivity Event-Driven Background Sync ## Latest Milestone (2026-02-17): Connectivity Event-Driven Background Sync
Implemented online-transition-triggered sync so background sync reacts faster Implemented online-transition-triggered sync so background sync reacts faster
+704 -12
View File
@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart'; import 'package:relationship_saver/core/config/app_theme.dart';
@@ -38,12 +40,14 @@ class DashboardView extends ConsumerWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Focus on consistency over intensity. Small moments compound.', 'See the relationship graph first, then execute the highest-leverage next moves.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary, color: AppTheme.textSecondary,
), ),
), ),
const SizedBox(height: 22), const SizedBox(height: 18),
_RelationshipGraphCard(people: data.people, compact: compact),
const SizedBox(height: 20),
Wrap( Wrap(
spacing: 12, spacing: 12,
runSpacing: 12, runSpacing: 12,
@@ -84,17 +88,24 @@ class DashboardView extends ConsumerWidget {
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
...tasks.map( if (tasks.isEmpty)
(DashboardTask task) => _TaskRow( Text(
task: task, 'No tasks yet. Add moments, ideas, or reminders to generate follow-through actions.',
compact: compact, style: Theme.of(context).textTheme.bodyLarge
onToggleDone: () { ?.copyWith(color: AppTheme.textSecondary),
ref )
.read(localRepositoryProvider.notifier) else
.toggleTaskDone(task.id); ...tasks.map(
}, (DashboardTask task) => _TaskRow(
task: task,
compact: compact,
onToggleDone: () {
ref
.read(localRepositoryProvider.notifier)
.toggleTaskDone(task.id);
},
),
), ),
),
], ],
), ),
), ),
@@ -117,6 +128,530 @@ class DashboardView extends ConsumerWidget {
} }
} }
class _RelationshipGraphCard extends StatelessWidget {
const _RelationshipGraphCard({required this.people, required this.compact});
final List<PersonProfile> people;
final bool compact;
@override
Widget build(BuildContext context) {
return FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
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),
),
),
],
),
const SizedBox(height: 12),
Container(
height: compact ? 330 : 410,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: <Color>[
const Color(0xFFEAF3F8),
const Color(0xFFF7FAFC),
Colors.white.withValues(alpha: 0.9),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white.withValues(alpha: 0.8)),
),
child: ClipRRect(
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(
people: people,
size: constraints.biggest,
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),
),
),
),
],
);
},
),
),
),
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) {
final Set<String> seen = <String>{};
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;
}
}
class _GraphLayout {
const _GraphLayout({
required this.center,
required this.nodes,
required this.edges,
required this.hiddenCount,
});
final Offset center;
final List<_GraphNode> nodes;
final List<_GraphEdge> edges;
final int hiddenCount;
static _GraphLayout build({
required List<PersonProfile> people,
required Size size,
required bool compact,
}) {
final int maxNodes = compact ? 10 : 16;
final List<PersonProfile> visiblePeople = people
.take(maxNodes)
.toList(growable: false);
final int hiddenCount = people.length - visiblePeople.length;
final Offset center = Offset(size.width / 2, size.height / 2);
if (visiblePeople.isEmpty) {
return _GraphLayout(
center: center,
nodes: const <_GraphNode>[],
edges: const <_GraphEdge>[],
hiddenCount: hiddenCount,
);
}
final double minSide = math.min(size.width, size.height);
final int total = visiblePeople.length;
final int innerCount = total <= 8 ? total : 8;
final int outerCount = total - innerCount;
final double innerRadius = minSide * (compact ? 0.28 : 0.31);
final double outerRadius = minSide * (compact ? 0.39 : 0.43);
final List<_GraphNode> nodes = <_GraphNode>[];
for (int i = 0; i < total; i += 1) {
final bool inOuterRing = i >= innerCount;
final int ringCount = inOuterRing ? outerCount : innerCount;
final int ringIndex = inOuterRing ? i - innerCount : i;
final double radius = inOuterRing ? outerRadius : innerRadius;
final double phaseOffset = inOuterRing ? math.pi / ringCount : 0;
final double angle =
(-math.pi / 2) +
phaseOffset +
((2 * math.pi * ringIndex) / ringCount);
final Offset position = Offset(
center.dx + radius * math.cos(angle),
center.dy + radius * math.sin(angle),
);
final PersonProfile person = visiblePeople[i];
final _RelationshipVisual visual = _relationshipVisualFor(
person.relationship,
);
nodes.add(_GraphNode(person: person, visual: visual, position: position));
}
final List<_GraphEdge> edges = <_GraphEdge>[];
for (final _GraphNode node in nodes) {
edges.add(
_GraphEdge(
start: center,
end: node.position,
color: node.visual.color.withValues(alpha: 0.46),
width: compact ? 1.7 : 2.0,
),
);
}
if (nodes.length > 2) {
for (int i = 0; i < nodes.length; i += 1) {
final _GraphNode current = nodes[i];
final _GraphNode next = nodes[(i + 1) % nodes.length];
final Color color =
Color.lerp(current.visual.color, next.visual.color, 0.5) ??
AppTheme.accent;
edges.add(
_GraphEdge(
start: current.position,
end: next.position,
color: color.withValues(alpha: 0.2),
width: 1.1,
),
);
if (nodes.length >= 7 && i.isEven) {
final _GraphNode jump = nodes[(i + 3) % nodes.length];
edges.add(
_GraphEdge(
start: current.position,
end: jump.position,
color: current.visual.color.withValues(alpha: 0.1),
width: 1.0,
),
);
}
}
}
return _GraphLayout(
center: center,
nodes: nodes,
edges: edges,
hiddenCount: hiddenCount,
);
}
}
class _GraphNode {
const _GraphNode({
required this.person,
required this.visual,
required this.position,
});
final PersonProfile person;
final _RelationshipVisual visual;
final Offset position;
}
class _GraphEdge {
const _GraphEdge({
required this.start,
required this.end,
required this.color,
required this.width,
});
final Offset start;
final Offset end;
final Color color;
final double width;
}
class _RelationshipEdgePainter extends CustomPainter {
const _RelationshipEdgePainter({required this.center, required this.edges});
final Offset center;
final List<_GraphEdge> edges;
@override
void paint(Canvas canvas, Size size) {
final Paint centerGlow = Paint()
..shader =
RadialGradient(
colors: <Color>[
AppTheme.accent.withValues(alpha: 0.15),
Colors.transparent,
],
).createShader(
Rect.fromCircle(
center: center,
radius: math.min(size.width, size.height) * 0.36,
),
);
canvas.drawCircle(
center,
math.min(size.width, size.height) * 0.36,
centerGlow,
);
for (final _GraphEdge edge in edges) {
final Paint paint = Paint()
..color = edge.color
..strokeWidth = edge.width
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;
canvas.drawLine(edge.start, edge.end, paint);
}
}
@override
bool shouldRepaint(covariant _RelationshipEdgePainter oldDelegate) {
return oldDelegate.edges != edges || oldDelegate.center != center;
}
}
class _GraphBubble extends StatelessWidget {
const _GraphBubble({required this.node, required this.compact});
final _GraphNode node;
final bool compact;
@override
Widget build(BuildContext context) {
final double diameter = compact ? 52 : 62;
final double labelWidth = compact ? 66 : 86;
return Positioned(
left: node.position.dx - (diameter / 2),
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,
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,
),
),
),
],
),
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,
),
),
],
],
),
),
),
);
}
}
class _CenterNode extends StatelessWidget {
const _CenterNode({required this.center, required this.compact});
final Offset center;
final bool compact;
@override
Widget build(BuildContext context) {
final double diameter = compact ? 74 : 86;
return Positioned(
left: center.dx - (diameter / 2),
top: center.dy - (diameter / 2),
child: Container(
width: diameter,
height: diameter,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
colors: <Color>[Color(0xFF1AB6C8), Color(0xFF286DE0)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: <BoxShadow>[
BoxShadow(
color: const Color(0xFF1AB6C8).withValues(alpha: 0.26),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(Icons.favorite_rounded, color: Colors.white, size: 18),
const SizedBox(height: 2),
Text(
'YOU',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
),
),
],
),
),
);
}
}
class _RelationshipLegendPill extends StatelessWidget {
const _RelationshipLegendPill({required this.visual});
final _RelationshipVisual visual;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: visual.color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(99),
border: Border.all(color: visual.color.withValues(alpha: 0.18)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(visual.icon, size: 14, color: visual.color),
const SizedBox(width: 6),
Text(
visual.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,
@@ -241,6 +776,163 @@ class _TaskRow extends StatelessWidget {
} }
} }
class _RelationshipVisual {
const _RelationshipVisual({
required this.label,
required this.icon,
required this.color,
});
final String label;
final IconData icon;
final Color color;
}
const _RelationshipVisual _spouseVisual = _RelationshipVisual(
label: 'Spouse',
icon: Icons.favorite_rounded,
color: Color(0xFFE0557B),
);
const _RelationshipVisual _fianceVisual = _RelationshipVisual(
label: 'Fiance',
icon: Icons.diamond_rounded,
color: Color(0xFFCB6CF2),
);
const _RelationshipVisual _partnerVisual = _RelationshipVisual(
label: 'Partner',
icon: Icons.favorite_border_rounded,
color: Color(0xFFE67A5A),
);
const _RelationshipVisual _bestieVisual = _RelationshipVisual(
label: 'Bestie',
icon: Icons.emoji_emotions_rounded,
color: Color(0xFF8E6BFF),
);
const _RelationshipVisual _friendVisual = _RelationshipVisual(
label: 'Friend',
icon: Icons.groups_rounded,
color: Color(0xFF3F8CFF),
);
const _RelationshipVisual _familyVisual = _RelationshipVisual(
label: 'Family',
icon: Icons.family_restroom_rounded,
color: Color(0xFF2FB67D),
);
const _RelationshipVisual _sisterVisual = _RelationshipVisual(
label: 'Sister',
icon: Icons.female_rounded,
color: Color(0xFFFF6EA9),
);
const _RelationshipVisual _brotherVisual = _RelationshipVisual(
label: 'Brother',
icon: Icons.male_rounded,
color: Color(0xFF4A7DFF),
);
const _RelationshipVisual _otherVisual = _RelationshipVisual(
label: 'Connection',
icon: Icons.hub_rounded,
color: Color(0xFF5F7280),
);
_RelationshipVisual _relationshipVisualFor(String relationship) {
final String value = relationship.toLowerCase();
if (_containsAny(value, <String>['spouse', 'wife', 'husband'])) {
return _spouseVisual;
}
if (_containsAny(value, <String>['fiance', 'engaged'])) {
return _fianceVisual;
}
if (_containsAny(value, <String>[
'girlfriend',
'boyfriend',
'partner',
'gf',
'bf',
])) {
return _partnerVisual;
}
if (_containsAny(value, <String>['bestie', 'best friend', 'bff'])) {
return _bestieVisual;
}
if (_containsAny(value, <String>['sister', 'sis'])) {
return _sisterVisual;
}
if (_containsAny(value, <String>['brother', 'bro'])) {
return _brotherVisual;
}
if (_containsAny(value, <String>[
'family',
'mother',
'father',
'mom',
'dad',
'parent',
'aunt',
'uncle',
'cousin',
'grand',
])) {
return _familyVisual;
}
if (_containsAny(value, <String>['friend', 'pal', 'buddy', 'mate'])) {
return _friendVisual;
}
return _otherVisual;
}
bool _containsAny(String value, List<String> needles) {
for (final String needle in needles) {
if (value.contains(needle)) {
return true;
}
}
return false;
}
Color _shiftLightness(Color color, double delta) {
final HSLColor hsl = HSLColor.fromColor(color);
final double next = (hsl.lightness + delta).clamp(0.0, 1.0);
return hsl.withLightness(next).toColor();
}
String _initials(String name) {
final List<String> parts = name
.split(' ')
.where((String part) => part.isNotEmpty)
.toList(growable: false);
if (parts.isEmpty) {
return '?';
}
if (parts.length == 1) {
return parts.first
.substring(0, math.min(2, parts.first.length))
.toUpperCase();
}
return '${parts.first[0]}${parts[1][0]}'.toUpperCase();
}
String _shortName(String name) {
final List<String> parts = name
.split(' ')
.where((String part) => part.isNotEmpty)
.toList(growable: false);
if (parts.isEmpty) {
return 'Unknown';
}
return parts.first;
}
String _shortDate(DateTime value) { String _shortDate(DateTime value) {
const List<String> weekdays = <String>[ const List<String> weekdays = <String>[
'Mon', 'Mon',