Add graph-first dashboard with relationship type nodes
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user