import 'package:capstone_project/models/event_signal.dart'; import 'package:capstone_project/utils/signal_buckets.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; class EventSignalCard extends StatelessWidget { final EventSignal signal; const EventSignalCard({super.key, required this.signal}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final signalColor = switch (signal.direction) { 'positive' => const Color(0xFF16a34a), 'negative' => const Color(0xFFdc2626), _ => theme.colorScheme.mutedForeground, }; final signalBg = switch (signal.direction) { 'positive' => const Color(0xFF16a34a).withValues(alpha: 0.12), 'negative' => const Color(0xFFdc2626).withValues(alpha: 0.12), _ => theme.colorScheme.border.withValues(alpha: 0.4), }; return Container( decoration: BoxDecoration( color: theme.colorScheme.background.withValues(alpha: 0.8), border: Border.all(color: theme.colorScheme.border, width: 1), borderRadius: BorderRadius.circular(4), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.12), blurRadius: 4, spreadRadius: 2, ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // header row Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: theme.colorScheme.border, width: 1)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(signal.eventSummary).semiBold, const Gap(4), RichText( text: TextSpan( text: _formatDate(signal.createdAt), style: GoogleFonts.ibmPlexMono( fontSize: 10, height: 1, fontWeight: FontWeight.w500, color: theme.colorScheme.mutedForeground, ), ), ), ], ), ), const Gap(12), Row( mainAxisSize: MainAxisSize.min, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: theme.colorScheme.border.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(4), border: Border.all(color: theme.colorScheme.border, width: 1), ), child: RichText( text: TextSpan( text: signal.nature, style: GoogleFonts.ibmPlexMono( fontSize: 10, height: 1, fontWeight: FontWeight.w600, color: theme.colorScheme.mutedForeground, ), ), ), ), const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: signalBg, borderRadius: BorderRadius.circular(4), border: Border.all(color: signalColor.withValues(alpha: 0.3), width: 1), ), child: RichText( text: TextSpan( text: signal.direction, style: GoogleFonts.ibmPlexMono( fontSize: 10, height: 1, fontWeight: FontWeight.w600, color: signalColor, ), ), ), ), ], ), ], ), ), // probability + impact as bucketed labels. the raw percentages // pretended to more precision than the llm actually gives us. Padding( padding: const EdgeInsets.fromLTRB(14, 10, 14, 0), child: Row( children: [ _BucketRow( label: 'CREDIBILITY', bucket: bucketLabel(signal.probability), color: signalColor, ), const SizedBox(width: 20), _BucketRow( label: 'IMPACT', bucket: bucketLabel(signal.impact), color: signalColor, ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(14, 10, 14, 0), child: Text(signal.rationale).small, ), // articles if (signal.articles.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(14, 12, 14, 0), child: Divider(color: Theme.of(context).colorScheme.border, height: 1), ), Padding( padding: const EdgeInsets.fromLTRB(14, 8, 14, 0), child: RichText( text: TextSpan( text: 'SOURCES ${signal.articles.length}', style: GoogleFonts.ibmPlexMono( fontSize: 10, height: 1, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.mutedForeground, ), ), ), ), for (final article in signal.articles) _ArticleLink(article: article), ], const SizedBox(height: 14), ], ), ); } } class _ArticleLink extends StatefulWidget { final dynamic article; // FeedItem const _ArticleLink({required this.article}); @override State<_ArticleLink> createState() => _ArticleLinkState(); } class _ArticleLinkState extends State<_ArticleLink> { bool _hovered = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); final hasLink = widget.article.link.isNotEmpty; return MouseRegion( cursor: hasLink ? SystemMouseCursors.click : MouseCursor.defer, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: hasLink ? () => launchUrl(Uri.parse(widget.article.link), mode: LaunchMode.externalApplication) : null, child: Padding( padding: const EdgeInsets.fromLTRB(14, 6, 14, 0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(top: 2), child: Icon( LucideIcons.externalLink, size: 10, color: _hovered ? theme.colorScheme.foreground : theme.colorScheme.mutedForeground, ), ), const SizedBox(width: 6), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.article.title, style: TextStyle( fontSize: 11, color: _hovered ? theme.colorScheme.foreground : theme.colorScheme.mutedForeground, decoration: _hovered ? TextDecoration.underline : TextDecoration.none, decorationColor: theme.colorScheme.mutedForeground, ), ), if (widget.article.source != null) ...[ const SizedBox(height: 2), RichText( text: TextSpan( text: widget.article.source, style: GoogleFonts.ibmPlexMono( fontSize: 9, height: 1, fontWeight: FontWeight.w500, color: theme.colorScheme.mutedForeground.withValues(alpha: 0.6), ), ), ), ], ], ), ), ], ), ), ), ); } } // small two-piece label: "LABEL Value". kept compact so we can stack // credibility and impact on one line without the card feeling cramped. class _BucketRow extends StatelessWidget { final String label; final String bucket; final Color color; const _BucketRow({ required this.label, required this.bucket, required this.color, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Row( mainAxisSize: MainAxisSize.min, children: [ RichText( text: TextSpan( text: label, style: GoogleFonts.ibmPlexMono( fontSize: 10, height: 1, fontWeight: FontWeight.w600, color: theme.colorScheme.mutedForeground, ), ), ), const SizedBox(width: 8), RichText( text: TextSpan( text: bucket.toUpperCase(), style: GoogleFonts.ibmPlexMono( fontSize: 10, height: 1, fontWeight: FontWeight.w700, color: color, ), ), ), ], ); } } String _formatDate(DateTime dt) { final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; final h = dt.hour.toString().padLeft(2, '0'); final m = dt.minute.toString().padLeft(2, '0'); return '${months[dt.month - 1]} ${dt.day}, ${dt.year} $h:$m'; }