318 lines
11 KiB
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';
|
|
}
|