diff --git a/docs/progress.md b/docs/progress.md index 779a4f4..c09f9f7 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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 diff --git a/lib/features/dashboard/dashboard_view.dart b/lib/features/dashboard/dashboard_view.dart index 28e2381..474ef78 100644 --- a/lib/features/dashboard/dashboard_view.dart +++ b/lib/features/dashboard/dashboard_view.dart @@ -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,17 +88,24 @@ class DashboardView extends ConsumerWidget { style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 14), - ...tasks.map( - (DashboardTask task) => _TaskRow( - task: task, - compact: compact, - onToggleDone: () { - ref - .read(localRepositoryProvider.notifier) - .toggleTaskDone(task.id); - }, + 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, + 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 people; + final bool compact; + + @override + Widget build(BuildContext context) { + return FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + 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: [ + 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: [ + 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 source) { + final Set seen = {}; + 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 people, + required Size size, + required bool compact, + }) { + final int maxNodes = compact ? 10 : 16; + final List 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: [ + 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: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: diameter, + height: diameter, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + _shiftLightness(node.visual.color, 0.16), + _shiftLightness(node.visual.color, -0.12), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + 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) ...[ + 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(0xFF1AB6C8), Color(0xFF286DE0)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + 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: [ + 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: [ + 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, ['spouse', 'wife', 'husband'])) { + return _spouseVisual; + } + if (_containsAny(value, ['fiance', 'engaged'])) { + return _fianceVisual; + } + if (_containsAny(value, [ + 'girlfriend', + 'boyfriend', + 'partner', + 'gf', + 'bf', + ])) { + return _partnerVisual; + } + if (_containsAny(value, ['bestie', 'best friend', 'bff'])) { + return _bestieVisual; + } + if (_containsAny(value, ['sister', 'sis'])) { + return _sisterVisual; + } + if (_containsAny(value, ['brother', 'bro'])) { + return _brotherVisual; + } + if (_containsAny(value, [ + 'family', + 'mother', + 'father', + 'mom', + 'dad', + 'parent', + 'aunt', + 'uncle', + 'cousin', + 'grand', + ])) { + return _familyVisual; + } + if (_containsAny(value, ['friend', 'pal', 'buddy', 'mate'])) { + return _friendVisual; + } + return _otherVisual; +} + +bool _containsAny(String value, List 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 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 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 weekdays = [ 'Mon',