Add graph-first dashboard with relationship type nodes
This commit is contained in:
@@ -2,6 +2,33 @@
|
||||
|
||||
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
|
||||
|
||||
Implemented online-transition-triggered sync so background sync reacts faster
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
@@ -38,12 +40,14 @@ class DashboardView extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
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(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
const SizedBox(height: 18),
|
||||
_RelationshipGraphCard(people: data.people, compact: compact),
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
@@ -84,6 +88,13 @@ class DashboardView extends ConsumerWidget {
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
if (tasks.isEmpty)
|
||||
Text(
|
||||
'No tasks yet. Add moments, ideas, or reminders to generate follow-through actions.',
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(color: AppTheme.textSecondary),
|
||||
)
|
||||
else
|
||||
...tasks.map(
|
||||
(DashboardTask task) => _TaskRow(
|
||||
task: task,
|
||||
@@ -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 {
|
||||
const _StatCard({
|
||||
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) {
|
||||
const List<String> weekdays = <String>[
|
||||
'Mon',
|
||||
|
||||
Reference in New Issue
Block a user