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'; import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/local_repository.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 { const DashboardView({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final AsyncValue localData = ref.watch( localRepositoryProvider, ); return localData.when( data: (LocalDataState data) { final DashboardSummary summary = data.summary(); final List tasks = data.tasks; return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final bool compact = constraints.maxWidth < 720; return SingleChildScrollView( padding: EdgeInsets.fromLTRB( compact ? 16 : 28, compact ? 16 : 24, compact ? 16 : 28, compact ? 24 : 36, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Today', style: Theme.of(context).textTheme.headlineMedium, ), const SizedBox(height: 8), Text( '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: 18), _RelationshipGraphCard(people: data.people, compact: compact), const SizedBox(height: 20), Wrap( spacing: 12, runSpacing: 12, children: [ _StatCard( label: 'Active People', value: '${summary.activePeople}', accent: const Color(0xFF16B8CA), compact: compact, ), _StatCard( label: 'Upcoming Plans', value: '${summary.upcomingPlans}', accent: const Color(0xFF2B7FFF), compact: compact, ), _StatCard( label: 'Pending Ideas', value: '${summary.pendingIdeas}', accent: const Color(0xFFFFA447), compact: compact, ), _StatCard( label: 'Weekly Rhythm', value: '${summary.weeklyConsistency}%', accent: const Color(0xFF30B66A), compact: compact, ), ], ), const SizedBox(height: 24), FrostedCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Next Moves', 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, compact: compact, onToggleDone: () { ref .read(localRepositoryProvider.notifier) .toggleTaskDone(task.id); }, ), ), ], ), ), ], ), ); }, ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (Object error, StackTrace stackTrace) { return Center( child: Text( 'Could not load local data.', style: Theme.of(context).textTheme.bodyLarge, ), ); }, ); } } class _RelationshipGraphCard extends StatelessWidget { const _RelationshipGraphCard({required this.people, required this.compact}); final List people; final bool compact; @override Widget build(BuildContext context) { final List<_RelationshipVisual> legend = _collectGraphLegend(people); return FrostedCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: 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(width: 8), OutlinedButton.icon( onPressed: people.isEmpty ? null : () => _openGraphExplorer(context, people), icon: const Icon(Icons.open_in_full_rounded, size: 18), label: Text(compact ? 'Explore' : 'Open Explorer'), ), ], ), 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) { return _RelationshipGraphCanvas( people: people, compact: compact, mode: _GraphCanvasMode.preview, onOpenPersonInsights: (PersonProfile person) => _openPersonInsights(context, person), ); }, ), ), ), const SizedBox(height: 8), Text( 'Dashboard preview is scroll-first. Long-press a person for insights, or open the explorer to pan and zoom.', style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, children: legend .map( (_RelationshipVisual visual) => _RelationshipLegendPill(visual: visual), ) .toList(growable: false), ), ], ), ); } } enum _GraphCanvasMode { preview, explorer } class _RelationshipGraphExplorerPage extends StatefulWidget { const _RelationshipGraphExplorerPage({required this.people}); final List people; @override State<_RelationshipGraphExplorerPage> createState() => _RelationshipGraphExplorerPageState(); } class _RelationshipGraphExplorerPageState extends State<_RelationshipGraphExplorerPage> { late final TransformationController _controller; @override void initState() { super.initState(); _controller = TransformationController(); } @override void dispose() { _controller.dispose(); super.dispose(); } void _resetView() { _controller.value = Matrix4.identity(); } @override Widget build(BuildContext context) { final bool compact = MediaQuery.sizeOf(context).width < 760; final List<_RelationshipVisual> legend = _collectGraphLegend(widget.people); return Scaffold( appBar: AppBar( title: const Text('Relationship Graph'), actions: [ IconButton( tooltip: 'Reset view', onPressed: _resetView, icon: const Icon(Icons.center_focus_strong_rounded), ), ], ), body: Padding( padding: EdgeInsets.fromLTRB( compact ? 12 : 18, compact ? 12 : 14, compact ? 12 : 18, compact ? 14 : 20, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.76), borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.white.withValues(alpha: 0.85)), ), child: Wrap( spacing: 10, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text( 'Explorer mode: pan + pinch to zoom. Long-press a person to open insights.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTheme.textSecondary, ), ), FilledButton.tonalIcon( onPressed: _resetView, icon: const Icon(Icons.refresh_rounded, size: 18), label: const Text('Reset View'), ), ], ), ), const SizedBox(height: 12), Expanded( child: Container( decoration: BoxDecoration( gradient: LinearGradient( colors: [ const Color(0xFFEAF3F8), const Color(0xFFF7FAFC), Colors.white.withValues(alpha: 0.92), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(24), border: Border.all( color: Colors.white.withValues(alpha: 0.82), ), ), child: ClipRRect( borderRadius: BorderRadius.circular(24), child: _RelationshipGraphCanvas( people: widget.people, compact: compact, mode: _GraphCanvasMode.explorer, transformationController: _controller, onOpenPersonInsights: (PersonProfile person) => _openPersonInsights(context, person), ), ), ), ), const SizedBox(height: 10), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: legend .map( (_RelationshipVisual visual) => Padding( padding: const EdgeInsets.only(right: 8), child: _RelationshipLegendPill(visual: visual), ), ) .toList(growable: false), ), ), ], ), ), ); } } class _RelationshipGraphCanvas extends StatelessWidget { const _RelationshipGraphCanvas({ required this.people, required this.compact, required this.mode, required this.onOpenPersonInsights, this.transformationController, }); final List people; final bool compact; final _GraphCanvasMode mode; final ValueChanged onOpenPersonInsights; final TransformationController? transformationController; @override Widget build(BuildContext context) { return 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, ); final bool explorer = mode == _GraphCanvasMode.explorer; return InteractiveViewer( transformationController: transformationController, minScale: explorer ? 0.55 : 1.0, maxScale: explorer ? 3.2 : 1.0, boundaryMargin: explorer ? const EdgeInsets.all(260) : EdgeInsets.zero, panEnabled: explorer, scaleEnabled: explorer, child: SizedBox( width: constraints.maxWidth, height: constraints.maxHeight, child: Stack( children: [ Positioned.fill( child: CustomPaint( painter: _RelationshipEdgePainter( center: layout.center, edges: layout.edges, ), ), ), ...layout.nodes.map( (_GraphNode node) => _GraphBubble( node: node, compact: compact, onLongPress: () => onOpenPersonInsights(node.person), ), ), _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, ), ), ), ), if (!explorer) Positioned( right: 12, bottom: 12, child: Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.88), borderRadius: BorderRadius.circular(99), border: Border.all(color: Colors.white), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.swipe_up_alt_rounded, size: 14, color: AppTheme.textSecondary, ), const SizedBox(width: 6), Text( 'Scroll page', style: Theme.of(context).textTheme.labelLarge ?.copyWith(color: AppTheme.textSecondary), ), ], ), ), ), ], ), ), ); }, ); } } List<_RelationshipVisual> _collectGraphLegend(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; } void _openGraphExplorer(BuildContext context, List people) { Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) => _RelationshipGraphExplorerPage(people: people), ), ); } void _openPersonInsights(BuildContext context, PersonProfile person) { Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) { return _PersonInsightsPage(personId: person.id); }, ), ); } 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, required this.onLongPress, }); final _GraphNode node; final bool compact; final VoidCallback onLongPress; @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: GestureDetector( key: ValueKey('graph-node-${node.person.id}'), behavior: HitTestBehavior.translucent, onLongPress: onLongPress, 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), ), ], ), ); } } enum _InsightQuickAction { capture, note, idea, reminder } class _PersonInsightsPage extends ConsumerWidget { const _PersonInsightsPage({required this.personId}); final String personId; @override Widget build(BuildContext context, WidgetRef ref) { final AsyncValue localData = ref.watch( localRepositoryProvider, ); final AsyncValue signals = ref.watch( signalsControllerProvider, ); final LocalDataState? localValue = localData.asData?.value; final PersonProfile? quickAddPerson = localValue == null ? null : _findPersonById(localValue.people, personId); return Scaffold( appBar: AppBar(title: const Text('Person Insights')), floatingActionButton: quickAddPerson == null ? null : FloatingActionButton( tooltip: 'Add to person', onPressed: () => _openQuickAddSheet(context, ref, quickAddPerson), child: const Icon(Icons.add_rounded), ), 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 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 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 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 preferenceSignals = data.preferenceSignals .where( (PersonPreferenceSignal signal) => signal.personId == person.id, ) .toList(growable: false) ..sort(_comparePreferenceSignals); final List relatedTasks = _findTasksForPerson( data.tasks, person, ); final List personSignals = signals.asData?.value.items .where((SignalItem item) => item.personId == person.id) .toList(growable: false) ?? const []; 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: [ _personProfileCard( context, person, compact, onQuickAction: (_InsightQuickAction action) => _handleQuickAction(context, ref, person, action), ), const SizedBox(height: 14), _InsightSectionCard( title: 'Chat-derived preferences', icon: Icons.psychology_alt_outlined, children: preferenceSignals.isEmpty ? [ const _SectionEmpty( label: 'No inferred preferences yet. Share chat messages to build context gradually.', ), ] : preferenceSignals .map( ( PersonPreferenceSignal signal, ) => _InsightPreferenceSignalCard( signal: signal, compact: compact, onConfirm: () async { await ref .read( localRepositoryProvider.notifier, ) .confirmPreferenceSignal(signal.id); }, onDismiss: () async { await ref .read( localRepositoryProvider.notifier, ) .dismissPreferenceSignal(signal.id); }, onWhy: () => _showPreferenceSignalEvidence( context, signal: signal, personName: person.name, ), ), ) .toList(growable: false), ), const SizedBox(height: 12), _InsightSectionCard( title: 'Ideas', icon: Icons.lightbulb_outline_rounded, children: ideas.isEmpty ? [ 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 ? [ 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 ? [ 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 ? [ 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, ), ); }, ), ); } Future _showPreferenceSignalEvidence( BuildContext context, { required PersonPreferenceSignal signal, required String personName, }) { return showModalBottomSheet( context: context, useSafeArea: true, showDragHandle: true, builder: (BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Why this signal?', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 6), Text( '${signal.label} • ${signal.category} • ${signal.polarity.name.toUpperCase()}', style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 4), Text( 'For $personName • confidence ${(signal.confidence * 100).round()}%', style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 12), if (signal.evidenceSnippets.isEmpty) const _SectionEmpty( label: 'No evidence snippets stored yet for this signal.', ) else ...signal.evidenceSnippets.map( (String snippet) => _InsightRow( title: 'Evidence', subtitle: snippet, meta: 'Source(s): ${signal.sourceApps.join(', ')}', ), ), const SizedBox(height: 8), Align( alignment: Alignment.centerRight, child: TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Close'), ), ), ], ), ); }, ); } Widget _personProfileCard( BuildContext context, PersonProfile person, bool compact, { required ValueChanged<_InsightQuickAction> onQuickAction, }) { final _RelationshipVisual visual = _relationshipVisualFor( person.relationship, ); return FrostedCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (compact) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ _insightAvatar(person: person, visual: visual), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ 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), ), ], ), ), PopupMenuButton<_InsightQuickAction>( tooltip: 'Quick add', onSelected: onQuickAction, itemBuilder: (BuildContext context) => const >[ PopupMenuItem<_InsightQuickAction>( value: _InsightQuickAction.capture, child: Text('Add Capture'), ), PopupMenuItem<_InsightQuickAction>( value: _InsightQuickAction.note, child: Text('Add Note'), ), PopupMenuItem<_InsightQuickAction>( value: _InsightQuickAction.idea, child: Text('Add Idea'), ), PopupMenuItem<_InsightQuickAction>( value: _InsightQuickAction.reminder, child: Text('Add Reminder'), ), ], icon: const Icon(Icons.add_circle_outline_rounded), ), ], ), const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, children: [ _miniMetric( label: 'Affinity', value: '${person.affinityScore}%', ), _miniMetric( label: 'Next Moment', value: _shortDate(person.nextMoment), ), ], ), ], ) else Row( children: [ _insightAvatar(person: person, visual: visual), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ 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), ), const SizedBox(width: 8), PopupMenuButton<_InsightQuickAction>( tooltip: 'Quick add', onSelected: onQuickAction, itemBuilder: (BuildContext context) => const >[ PopupMenuItem<_InsightQuickAction>( value: _InsightQuickAction.capture, child: Text('Add Capture'), ), PopupMenuItem<_InsightQuickAction>( value: _InsightQuickAction.note, child: Text('Add Note'), ), PopupMenuItem<_InsightQuickAction>( value: _InsightQuickAction.idea, child: Text('Add Idea'), ), PopupMenuItem<_InsightQuickAction>( value: _InsightQuickAction.reminder, child: Text('Add Reminder'), ), ], icon: const Icon(Icons.add_circle_outline_rounded), ), ], ), if (person.tags.isNotEmpty) ...[ 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) ...[ const SizedBox(height: 12), Text( person.notes, style: Theme.of( context, ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), ), ], ], ), ); } Future _handleQuickAction( BuildContext context, WidgetRef ref, PersonProfile person, _InsightQuickAction action, ) async { switch (action) { case _InsightQuickAction.capture: final String? summary = await _showQuickTextCaptureDialog( context, title: 'Add Capture', hintText: 'What happened in this interaction?', ); if (summary == null) { return; } await ref .read(localRepositoryProvider.notifier) .addMoment(personId: person.id, summary: summary, type: 'capture'); return; case _InsightQuickAction.note: final String? note = await _showQuickTextCaptureDialog( context, title: 'Add Note', hintText: 'Write a quick context note...', ); if (note == null) { return; } await ref .read(localRepositoryProvider.notifier) .addMoment(personId: person.id, summary: note, type: 'note'); return; case _InsightQuickAction.idea: final _InsightQuickIdeaDraft? idea = await showDialog<_InsightQuickIdeaDraft>( context: context, builder: (BuildContext context) => const _InsightQuickIdeaDialog(), ); if (idea == null) { return; } await ref .read(localRepositoryProvider.notifier) .addIdea( type: idea.type, title: idea.title, details: idea.details, personId: person.id, ); return; case _InsightQuickAction.reminder: final _InsightQuickReminderDraft? reminder = await showDialog<_InsightQuickReminderDraft>( context: context, builder: (BuildContext context) => const _InsightQuickReminderDialog(), ); if (reminder == null) { return; } await ref .read(localRepositoryProvider.notifier) .addReminder( title: reminder.title, cadence: reminder.cadence, nextAt: reminder.nextAt, personId: person.id, ); return; } } Future _openQuickAddSheet( BuildContext context, WidgetRef ref, PersonProfile person, ) async { final _InsightQuickAction? selected = await showModalBottomSheet<_InsightQuickAction>( context: context, showDragHandle: true, builder: (BuildContext context) { return SafeArea( child: ListView( shrinkWrap: true, children: [ ListTile( leading: const Icon(Icons.auto_awesome_rounded), title: const Text('Add Capture'), subtitle: const Text('Log a new moment or interaction'), onTap: () => Navigator.of(context).pop(_InsightQuickAction.capture), ), ListTile( leading: const Icon(Icons.note_add_outlined), title: const Text('Add Note'), subtitle: const Text('Save a context note for later'), onTap: () => Navigator.of(context).pop(_InsightQuickAction.note), ), ListTile( leading: const Icon(Icons.lightbulb_outline_rounded), title: const Text('Add Idea'), subtitle: const Text('Gift or event idea'), onTap: () => Navigator.of(context).pop(_InsightQuickAction.idea), ), ListTile( leading: const Icon(Icons.notifications_active_outlined), title: const Text('Add Reminder'), subtitle: const Text('Schedule a follow-up prompt'), onTap: () => Navigator.of(context).pop(_InsightQuickAction.reminder), ), ], ), ); }, ); if (selected == null || !context.mounted) { return; } await _handleQuickAction(context, ref, person, selected); } Future _showQuickTextCaptureDialog( BuildContext context, { required String title, required String hintText, }) async { return showDialog( context: context, builder: (BuildContext context) { return _InsightQuickTextCaptureDialog(title: title, hintText: hintText); }, ); } Widget _insightAvatar({ required PersonProfile person, required _RelationshipVisual visual, }) { return Container( width: 56, height: 56, decoration: BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( colors: [ _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: [ 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 _signalRows({ required BuildContext context, required List signals, required AsyncValue sourceState, }) { if (sourceState.isLoading && sourceState.asData == null) { return const [ Padding( padding: EdgeInsets.symmetric(vertical: 8), child: LinearProgressIndicator(minHeight: 3), ), ]; } if (signals.isEmpty) { if (sourceState.hasError) { return [ const _SectionEmpty( label: 'Signals unavailable right now. Backend sync can repopulate.', ), ]; } return [ 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 people, String id) { for (final PersonProfile person in people) { if (person.id == id) { return person; } } return null; } List _findTasksForPerson( List tasks, PersonProfile person, ) { final List tokens = person.name .toLowerCase() .split(' ') .where((String token) => token.length > 2) .toList(growable: false); if (tokens.isEmpty) { return const []; } 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)); } } int _comparePreferenceSignals( PersonPreferenceSignal a, PersonPreferenceSignal b, ) { int statusRank(PreferenceSignalStatus status) => switch (status) { PreferenceSignalStatus.inferred => 0, PreferenceSignalStatus.confirmed => 1, PreferenceSignalStatus.dismissed => 2, }; final int byStatus = statusRank(a.status).compareTo(statusRank(b.status)); if (byStatus != 0) { return byStatus; } final int byConfidence = b.confidence.compareTo(a.confidence); if (byConfidence != 0) { return byConfidence; } return a.label.toLowerCase().compareTo(b.label.toLowerCase()); } class _InsightPreferenceSignalCard extends StatelessWidget { const _InsightPreferenceSignalCard({ required this.signal, required this.compact, required this.onConfirm, required this.onDismiss, required this.onWhy, }); final PersonPreferenceSignal signal; final bool compact; final Future Function() onConfirm; final Future Function() onDismiss; final VoidCallback onWhy; @override Widget build(BuildContext context) { final Color tone = switch (signal.status) { PreferenceSignalStatus.confirmed => const Color(0xFF1D9C66), PreferenceSignalStatus.dismissed => const Color(0xFF9A6A00), PreferenceSignalStatus.inferred => AppTheme.primary, }; final IconData polarityIcon = switch (signal.polarity) { PreferenceSignalPolarity.like => Icons.thumb_up_alt_outlined, PreferenceSignalPolarity.dislike => Icons.thumb_down_alt_outlined, PreferenceSignalPolarity.neutral => Icons.tune_rounded, }; 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), border: Border.all(color: Colors.white), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Icon(polarityIcon, size: 16, color: tone), Text( signal.label, style: Theme.of(context).textTheme.titleSmall, ), _PreferenceBadge(label: signal.status.name, color: tone), _PreferenceBadge( label: '${(signal.confidence * 100).round()}%', color: AppTheme.textSecondary, filled: false, ), ], ), const SizedBox(height: 6), Text( '${signal.category.toUpperCase()} • seen ${signal.occurrenceCount}x', style: Theme.of( context, ).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ OutlinedButton(onPressed: onWhy, child: const Text('Why?')), if (signal.status != PreferenceSignalStatus.confirmed) FilledButton.tonal( onPressed: () async => onConfirm(), child: const Text('Confirm'), ), if (signal.status != PreferenceSignalStatus.dismissed) TextButton( onPressed: () async => onDismiss(), child: const Text('Dismiss'), ), ], ), if (!compact && signal.sourceApps.isNotEmpty) ...[ const SizedBox(height: 6), Text( 'Sources: ${signal.sourceApps.join(', ')}', style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), ], ], ), ), ); } } class _PreferenceBadge extends StatelessWidget { const _PreferenceBadge({ required this.label, required this.color, this.filled = true, }); final String label; final Color color; final bool filled; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: filled ? color.withValues(alpha: 0.12) : Colors.transparent, borderRadius: BorderRadius.circular(99), border: Border.all(color: color.withValues(alpha: filled ? 0.2 : 0.35)), ), child: Text( label.toUpperCase(), style: Theme.of(context).textTheme.labelSmall?.copyWith(color: color), ), ); } } class _InsightQuickIdeaDraft { const _InsightQuickIdeaDraft({ required this.type, required this.title, required this.details, }); final IdeaType type; final String title; final String details; } class _InsightQuickIdeaDialog extends StatefulWidget { const _InsightQuickIdeaDialog(); @override State<_InsightQuickIdeaDialog> createState() => _InsightQuickIdeaDialogState(); } class _InsightQuickIdeaDialogState extends State<_InsightQuickIdeaDialog> { IdeaType _type = IdeaType.gift; final TextEditingController _titleController = TextEditingController(); final TextEditingController _detailsController = TextEditingController(); @override void dispose() { _titleController.dispose(); _detailsController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Add Idea'), content: SingleChildScrollView( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( mainAxisSize: MainAxisSize.min, children: [ SegmentedButton( segments: const >[ ButtonSegment( value: IdeaType.gift, label: Text('Gift'), ), ButtonSegment( value: IdeaType.event, label: Text('Event'), ), ], selected: {_type}, onSelectionChanged: (Set values) { setState(() { _type = values.first; }); }, ), const SizedBox(height: 10), TextField( controller: _titleController, decoration: const InputDecoration(labelText: 'Title'), ), TextField( controller: _detailsController, maxLines: 3, decoration: const InputDecoration(labelText: 'Details'), ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { final String title = _titleController.text.trim(); if (title.isEmpty) { return; } Navigator.of(context).pop( _InsightQuickIdeaDraft( type: _type, title: title, details: _detailsController.text.trim(), ), ); }, child: const Text('Add'), ), ], ); } } class _InsightQuickReminderDraft { const _InsightQuickReminderDraft({ required this.title, required this.cadence, required this.nextAt, }); final String title; final ReminderCadence cadence; final DateTime nextAt; } class _InsightQuickReminderDialog extends StatefulWidget { const _InsightQuickReminderDialog(); @override State<_InsightQuickReminderDialog> createState() => _InsightQuickReminderDialogState(); } class _InsightQuickReminderDialogState extends State<_InsightQuickReminderDialog> { final TextEditingController _titleController = TextEditingController(); ReminderCadence _cadence = ReminderCadence.weekly; DateTime _nextAt = DateTime.now().add(const Duration(days: 1)); @override void dispose() { _titleController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Add Reminder'), content: SingleChildScrollView( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _titleController, decoration: const InputDecoration(labelText: 'Title'), ), const SizedBox(height: 10), DropdownButtonFormField( initialValue: _cadence, decoration: const InputDecoration(labelText: 'Cadence'), items: ReminderCadence.values .map( (ReminderCadence cadence) => DropdownMenuItem( value: cadence, child: Text(cadence.name), ), ) .toList(growable: false), onChanged: (ReminderCadence? value) { if (value == null) { return; } setState(() { _cadence = value; }); }, ), const SizedBox(height: 10), Row( children: [ Expanded( child: Text( 'Next: ${_nextAt.year}-${_nextAt.month.toString().padLeft(2, '0')}-${_nextAt.day.toString().padLeft(2, '0')} ${_nextAt.hour.toString().padLeft(2, '0')}:${_nextAt.minute.toString().padLeft(2, '0')}', ), ), TextButton( onPressed: () async { final DateTime now = DateTime.now(); final DateTime? date = await showDatePicker( context: context, firstDate: now, lastDate: now.add(const Duration(days: 3650)), initialDate: _nextAt, ); if (date == null || !context.mounted) { return; } final TimeOfDay? time = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(_nextAt), ); if (time == null) { return; } setState(() { _nextAt = DateTime( date.year, date.month, date.day, time.hour, time.minute, ); }); }, child: const Text('Change'), ), ], ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { final String title = _titleController.text.trim(); if (title.isEmpty) { return; } Navigator.of(context).pop( _InsightQuickReminderDraft( title: title, cadence: _cadence, nextAt: _nextAt, ), ); }, child: const Text('Add'), ), ], ); } } class _InsightQuickTextCaptureDialog extends StatefulWidget { const _InsightQuickTextCaptureDialog({ required this.title, required this.hintText, }); final String title; final String hintText; @override State<_InsightQuickTextCaptureDialog> createState() => _InsightQuickTextCaptureDialogState(); } class _InsightQuickTextCaptureDialogState extends State<_InsightQuickTextCaptureDialog> { late final TextEditingController _controller; bool _attemptedSubmit = false; @override void initState() { super.initState(); _controller = TextEditingController(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final String value = _controller.text.trim(); return AlertDialog( title: Text(widget.title), content: TextField( controller: _controller, minLines: 2, maxLines: 4, autofocus: true, onChanged: (_) { if (_attemptedSubmit) { setState(() {}); } }, decoration: InputDecoration( hintText: widget.hintText, errorText: _attemptedSubmit && value.isEmpty ? 'Required' : null, ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { final String submitted = _controller.text.trim(); if (submitted.isEmpty) { setState(() { _attemptedSubmit = true; }); return; } Navigator.of(context).pop(submitted); }, child: const Text('Save'), ), ], ); } } class _InsightSectionCard extends StatelessWidget { const _InsightSectionCard({ required this.title, required this.icon, required this.children, }); final String title; final IconData icon; final List children; @override Widget build(BuildContext context) { return FrostedCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 8, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [ 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: [ 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 { const _StatCard({ required this.label, required this.value, required this.accent, required this.compact, }); final String label; final String value; final Color accent; final bool compact; @override Widget build(BuildContext context) { return FrostedCard( padding: const EdgeInsets.all(16), child: SizedBox( width: compact ? double.infinity : 188, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( height: 8, width: 40, decoration: BoxDecoration( color: accent, borderRadius: BorderRadius.circular(99), ), ), const SizedBox(height: 16), Text( value, style: Theme.of( context, ).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 4), Text( label, style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), ), ], ), ), ); } } class _TaskRow extends StatelessWidget { const _TaskRow({ required this.task, required this.onToggleDone, required this.compact, }); final DashboardTask task; final VoidCallback onToggleDone; final bool compact; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: const Color(0xFFF5FAFB), borderRadius: BorderRadius.circular(16), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ InkWell( onTap: onToggleDone, borderRadius: BorderRadius.circular(20), child: Icon( task.done ? Icons.check_circle_rounded : Icons.radio_button_unchecked_rounded, color: task.done ? const Color(0xFF1D9C66) : const Color(0xFF1AB6C8), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( task.title, style: Theme.of(context).textTheme.titleMedium?.copyWith( decoration: task.done ? TextDecoration.lineThrough : null, ), ), const SizedBox(height: 4), Text( task.description, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTheme.textSecondary, ), ), ], ), ), if (!compact) ...[ const SizedBox(width: 12), Text( _shortDate(task.when), style: Theme.of( context, ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), ), ], ], ), ), ); } } 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', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun', ]; final String weekday = weekdays[value.weekday - 1]; final String day = value.day.toString().padLeft(2, '0'); return '$weekday $day'; }