Auger/lib/widgets/event_signal_card.dart

318 lines
11 KiB
Dart

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';
}