Refactor project structure and enhance stock watchlist functionality

This commit is contained in:
ImBenji
2026-04-21 12:09:16 +01:00
parent 491bf2bbea
commit ed617b7498
41 changed files with 6566 additions and 1289 deletions
+251
View File
@@ -0,0 +1,251 @@
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
Color contentBgColor(BuildContext context) {
final theme = Theme.of(context);
final h = HSLColor.fromColor(theme.colorScheme.border).hue;
final dark = theme.brightness == Brightness.dark;
return dark
? HSLColor.fromAHSL(1, h, 0.35, 0.13).toColor()
: HSLColor.fromAHSL(1, h, 0.30, 0.88).toColor();
}
// The main shell. replaces Scaffold in all pages.
class AugorShell extends StatelessWidget {
const AugorShell({
super.key,
required this.child,
required this.titleTag,
this.headerLeading = const [],
this.headerTrailing = const [],
this.statusLeft,
this.statusRight,
});
final Widget child;
final String titleTag;
final List<Widget> headerLeading;
final List<Widget> headerTrailing;
final Widget? statusLeft;
final Widget? statusRight;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final monoStyle = GoogleFonts.ibmPlexMono(
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── header ──────────────────────────────────────────────────────────
Container(
decoration: BoxDecoration(
color: theme.colorScheme.background,
border: Border(bottom: BorderSide(color: theme.colorScheme.border, width: 1)),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
child: Row(
children: headerLeading,
),
),
const Spacer(),
...headerTrailing,
// title tag box
Container(
color: theme.colorScheme.border,
constraints: const BoxConstraints(minWidth: 100),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 10),
child: RichText(
text: TextSpan(text: titleTag.toUpperCase(), style: monoStyle),
),
),
const SizedBox(width: 64), // mac traffic lights gap
],
),
),
),
// ── content ─────────────────────────────────────────────────────────
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(
color: contentBgColor(context),
child: child,
),
const Positioned.fill(
child: IgnorePointer(
child: CustomPaint(painter: _InsetShadowPainter()),
),
),
],
),
),
// ── footer / status bar ─────────────────────────────────────────────
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.colorScheme.background,
border: Border(top: BorderSide(color: theme.colorScheme.border, width: 1)),
),
child: Row(
children: [
Expanded(
child: statusLeft ?? const SizedBox.shrink(),
),
Expanded(
child: Center(
child: _NavItems(),
),
),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: statusRight ?? const SizedBox.shrink(),
),
),
],
),
),
],
);
}
}
// shared footer nav — home and settings links
class _NavItems extends StatelessWidget {
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).uri.toString();
final theme = Theme.of(context);
final monoStyle = GoogleFonts.ibmPlexMono(
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
);
Widget navItem(String label, String path) {
final active = location == path || (path == '/' && location == '/');
return GestureDetector(
onTap: () => GoRouter.of(context).go(path),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: active ? theme.colorScheme.border : Colors.transparent,
borderRadius: BorderRadius.circular(3),
),
child: RichText(
text: TextSpan(
text: label,
style: monoStyle.copyWith(
color: active
? theme.colorScheme.foreground
: theme.colorScheme.mutedForeground,
),
),
),
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
navItem('Home', '/'),
const SizedBox(width: 2),
navItem('Settings', '/settings'),
],
);
}
}
// footer status text helper — use this for statusLeft / statusRight
class StatusText extends StatelessWidget {
const StatusText(this.text, {super.key});
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return RichText(
text: TextSpan(
text: text,
style: GoogleFonts.ibmPlexMono(
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
),
),
);
}
}
class _InsetShadowPainter extends CustomPainter {
const _InsetShadowPainter();
@override
void paint(Canvas canvas, Size size) {
const blur = 12.0;
final rect = Offset.zero & size;
canvas.save();
canvas.clipRect(rect);
final innerRect = rect.deflate(24);
final ringClip = Path()
..addRect(rect)
..addRect(innerRect)
..fillType = PathFillType.evenOdd;
canvas.clipPath(ringClip);
final path = Path()
..addRect(rect.inflate(blur * 2))
..addRect(rect)
..fillType = PathFillType.evenOdd;
final paint = Paint()
..color = const Color(0x55000000)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, blur);
canvas.drawPath(path, paint);
canvas.restore();
}
@override
bool shouldRepaint(covariant CustomPainter old) => false;
}
+318
View File
@@ -0,0 +1,318 @@
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';
}
+1 -53
View File
@@ -1,53 +1 @@
import 'package:go_router/go_router.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class ProjNavBar extends StatelessWidget {
static final Map<String, int> _pageIndex = {
"home": 0,
"settings": 1
};
late final int selectedIndex;
ProjNavBar({super.key, String currentPage = "home"}) {;
selectedIndex = _pageIndex[currentPage] ?? 0;
}
@override
Widget build(BuildContext context) {
// TODO: implement build
return NavigationBar(
index: selectedIndex,
onSelected: (index) {
if (index == 0) {
GoRouter.of(context).go("/");
} else if (index == 1) {
GoRouter.of(context).go("/settings");
}
},
children: [
NavigationItem(
label: Text(
"Home"
),
child: Icon(
LucideIcons.house
),
),
NavigationItem(
label: Text(
"Settings"
),
child: Icon(
LucideIcons.settings
),
)
],
);
}
}
// deprecated — navigation is now handled by AugorShell footer
+239
View File
@@ -0,0 +1,239 @@
import "dart:math" as math;
import "package:shadcn_flutter/shadcn_flutter.dart";
class PanelField {
const PanelField({
required this.section,
required this.label,
required this.child,
this.enabled = true,
});
final String section;
final Widget label;
final Widget child;
final bool enabled;
}
// auto groups fields by section string
class PanelList extends StatelessWidget {
const PanelList({super.key, required this.fields});
final List<PanelField> fields;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final Map<String, List<PanelField>> grouped = {};
for (final f in fields) {
grouped.putIfAbsent(f.section, () => []).add(f);
}
final entries = grouped.entries.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < entries.length; i++) ...[
PanelSection(
title: entries[i].key,
children: entries[i].value
.map((f) => PanelRow(label: f.label, child: f.child, enabled: f.enabled))
.toList(),
),
if (i < entries.length - 1)
Divider(color: theme.colorScheme.border, height: 1),
],
Divider(color: theme.colorScheme.border, height: 1),
const SizedBox(height: 10),
],
);
}
}
class PanelSection extends StatefulWidget {
const PanelSection({
super.key,
required this.title,
required this.children,
});
final String title;
final List<Widget> children;
@override
State<PanelSection> createState() => _PanelSectionState();
}
class _PanelSectionState extends State<PanelSection> {
bool _expanded = true;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
behavior: HitTestBehavior.opaque,
child: ColoredBox(
color: theme.colorScheme.secondary,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
AnimatedRotation(
turns: _expanded ? 0.0 : -0.25,
duration: const Duration(milliseconds: 120),
child: Icon(LucideIcons.chevronDown, size: 12, color: theme.colorScheme.mutedForeground),
),
const SizedBox(width: 4),
Text(
widget.title,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
letterSpacing: 0.4,
),
),
],
),
),
),
),
Divider(color: theme.colorScheme.border, height: 1),
if (_expanded)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < widget.children.length; i++) ...[
widget.children[i],
if (i < widget.children.length - 1)
Divider(color: theme.colorScheme.border, height: 1),
],
],
),
],
);
}
}
class PanelRow extends StatelessWidget {
const PanelRow({super.key, required this.label, required this.child, this.enabled = true});
final Widget label;
final Widget child;
final bool enabled;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 38),
child: IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 120,
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: DefaultTextStyle.merge(
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.mutedForeground,
decoration: !enabled ? TextDecoration.lineThrough : null,
decorationThickness: !enabled ? 3 : null,
),
child: label,
),
),
),
),
VerticalDivider(color: theme.colorScheme.border, width: 1),
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: child,
),
),
if (!enabled)
Positioned.fill(
child: GestureDetector(
onTap: () {},
behavior: HitTestBehavior.opaque,
child: CustomPaint(
painter: _DisabledStripePainter(theme.colorScheme.border),
),
),
),
],
),
),
],
),
),
),
);
}
}
class _DisabledStripePainter extends CustomPainter {
const _DisabledStripePainter(this.color);
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color.withValues(alpha: 1)
..strokeWidth = 1.0
..style = PaintingStyle.stroke;
const spacing = 4.0;
final diag = math.sqrt(size.width * size.width + size.height * size.height);
final count = (diag / spacing).ceil() + 2;
canvas.save();
canvas.clipRect(Offset.zero & size);
canvas.drawRect(Rect.fromLTWH(1, 1, size.width - 3, size.height - 2), paint);
canvas.translate(0, size.height);
canvas.rotate(-math.pi / 4);
for (int i = -count; i <= count; i++) {
final x = i * spacing;
canvas.drawLine(Offset(x, -diag), Offset(x, diag), paint);
}
canvas.restore();
}
@override
bool shouldRepaint(_DisabledStripePainter old) => old.color != color;
}
+214
View File
@@ -0,0 +1,214 @@
import "dart:math";
import "package:capstone_project/models/event_signal.dart";
import "package:capstone_project/utils/signal_buckets.dart";
import "package:fl_chart/fl_chart.dart";
import "package:google_fonts/google_fonts.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
const Color _growthColor = Color(0xFF16a34a);
const Color _declineColor = Color(0xFFdc2626);
const Color _neutralColor = Color(0xFF888888);
// one day in milliseconds — used to convert between DateTime and the chart's x axis
const double _msPerDay = 1000 * 60 * 60 * 24;
// Scatter of signals: X is the signal date, Y is the impact, color is direction,
// opacity scales with probability (low-credibility signals fade into the back).
// No line connecting them — signals are discrete events, not a time series.
class SignalProbabilityChart extends StatelessWidget {
final List<EventSignal> signals;
final double height;
final bool compact;
const SignalProbabilityChart({
super.key,
required this.signals,
this.height = 200,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (signals.isEmpty) {
return Container(
width: double.infinity,
height: height,
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: theme.colorScheme.border, width: 1),
),
alignment: Alignment.center,
child: Text("No signal history yet").muted.small,
);
}
// sort so index ordering lines up with the spots list (used for tooltip lookup)
final sorted = [...signals]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final xs = sorted.map((s) => _toDays(s.createdAt)).toList();
final rawMinX = xs.reduce(min);
final rawMaxX = xs.reduce(max);
// keep a minimum visual span so a single-date cluster doesn't collapse the axis
final span = (rawMaxX - rawMinX) < 1.0 ? 1.0 : (rawMaxX - rawMinX);
final xPad = span * 0.06;
final minX = rawMinX - xPad;
final maxX = rawMaxX + xPad;
final spots = <ScatterSpot>[];
for (final s in sorted) {
final base = _directionColor(s.direction);
// probability drives the alpha: 0.0 → 0.30, 1.0 → 0.95
final alpha = 0.30 + (s.probability.clamp(0.0, 1.0) * 0.65);
spots.add(
ScatterSpot(
_toDays(s.createdAt),
s.impact,
dotPainter: FlDotCirclePainter(
radius: 5,
color: base.withValues(alpha: alpha),
strokeWidth: 1,
strokeColor: theme.colorScheme.background,
),
),
);
}
final labelStyle = GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w500,
color: theme.colorScheme.mutedForeground,
);
return SizedBox(
width: double.infinity,
height: height,
child: ScatterChart(
ScatterChartData(
minX: minX,
maxX: maxX,
minY: 0,
maxY: 1,
scatterSpots: spots,
gridData: FlGridData(
show: !compact,
drawVerticalLine: false,
horizontalInterval: 0.25,
getDrawingHorizontalLine: (_) => FlLine(
color: theme.colorScheme.border,
strokeWidth: 1,
),
),
borderData: FlBorderData(
show: !compact,
border: Border.all(color: theme.colorScheme.border, width: 1),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: !compact,
reservedSize: 22,
interval: _xTickInterval(minX, maxX),
getTitlesWidget: (value, meta) {
final d = DateTime.fromMillisecondsSinceEpoch(
(value * _msPerDay).round(),
);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: RichText(text: TextSpan(text: _shortDate(d), style: labelStyle)),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: !compact,
reservedSize: 42,
interval: 0.25,
getTitlesWidget: (value, meta) => RichText(
text: TextSpan(
text: "${(value * 100).round()}%",
style: labelStyle,
),
),
),
),
),
scatterTouchData: compact
? ScatterTouchData(enabled: false)
: ScatterTouchData(
enabled: true,
touchTooltipData: ScatterTouchTooltipData(
getTooltipColor: (_) => theme.colorScheme.background,
tooltipBorder: BorderSide(color: theme.colorScheme.border, width: 1),
tooltipPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
maxContentWidth: 220,
getTooltipItems: (ScatterSpot touched) {
// match back to the source signal by x+y — ScatterSpot equality
// isn't guaranteed to line up with our original instances
final idx = sorted.indexWhere((s) =>
_toDays(s.createdAt) == touched.x && s.impact == touched.y);
if (idx < 0) return null;
final s = sorted[idx];
return ScatterTooltipItem(
"${_shortDate(s.createdAt)}\n"
"impact ${(s.impact * 100).toStringAsFixed(0)}% · ${s.direction}\n"
"probability ${(s.probability * 100).toStringAsFixed(0)}%",
textStyle: GoogleFonts.ibmPlexMono(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
),
textAlign: TextAlign.left,
);
},
),
),
),
),
);
}
}
double _toDays(DateTime d) => d.millisecondsSinceEpoch / _msPerDay;
Color _directionColor(String direction) => switch (direction) {
"positive" => _growthColor,
"negative" => _declineColor,
_ => _neutralColor,
};
// pick a sensible x-axis tick interval (in days) based on the visible range
double _xTickInterval(double minX, double maxX) {
final days = (maxX - minX).abs();
if (days <= 7) return 1;
if (days <= 30) return 7;
if (days <= 90) return 14;
if (days <= 365) return 30;
return (days / 6).ceilToDouble();
}
String _shortDate(DateTime d) {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return "${months[d.month - 1]} ${d.day}";
}
+258
View File
@@ -0,0 +1,258 @@
import 'dart:math';
import 'package:capstone_project/models/event_signal.dart';
import 'package:capstone_project/services/stock_price_service.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
const Color _growthColor = Color(0xFF16a34a);
const Color _declineColor = Color(0xFFdc2626);
class StockPriceChart extends StatelessWidget {
final List<StockPricePoint> prices;
final List<EventSignal> signals;
final double height;
final bool compact;
const StockPriceChart({
super.key,
required this.prices,
this.signals = const [],
this.height = 220,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (prices.isEmpty) {
return Container(
width: double.infinity,
height: height,
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: theme.colorScheme.border, width: 1),
),
alignment: Alignment.center,
child: Text('No price history available').muted.small,
);
}
final sortedPrices = [...prices]..sort((a, b) => a.date.compareTo(b.date));
final spots = List.generate(
sortedPrices.length,
(i) => FlSpot(i.toDouble(), sortedPrices[i].close),
);
final minPrice = sortedPrices.map((p) => p.close).reduce(min);
final maxPrice = sortedPrices.map((p) => p.close).reduce(max);
final pad = max((maxPrice - minPrice) * 0.15, maxPrice * 0.02);
final lineColor = sortedPrices.last.close >= sortedPrices.first.close ? _growthColor : _declineColor;
final labelStyle = GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w500,
color: theme.colorScheme.mutedForeground,
);
return SizedBox(
width: double.infinity,
height: height,
child: LineChart(
LineChartData(
minY: minPrice - pad,
maxY: maxPrice + pad,
minX: 0,
maxX: spots.length > 1 ? (spots.length - 1).toDouble() : 1,
gridData: FlGridData(
show: !compact,
drawVerticalLine: false,
horizontalInterval: _priceInterval(minPrice, maxPrice),
getDrawingHorizontalLine: (_) => FlLine(
color: theme.colorScheme.border,
strokeWidth: 1,
),
),
borderData: FlBorderData(
show: !compact,
border: Border.all(color: theme.colorScheme.border, width: 1),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: !compact,
reservedSize: 22,
interval: _xInterval(sortedPrices.length),
getTitlesWidget: (value, meta) {
final index = value.round();
if (index < 0 || index >= sortedPrices.length) return const SizedBox.shrink();
if ((value - index).abs() > 0.01) return const SizedBox.shrink();
final d = sortedPrices[index].date;
return Padding(
padding: const EdgeInsets.only(top: 4),
child: RichText(text: TextSpan(text: _shortDate(d), style: labelStyle)),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: !compact,
reservedSize: 52,
interval: _priceInterval(minPrice, maxPrice),
getTitlesWidget: (value, meta) => RichText(
text: TextSpan(text: '\$${value.toStringAsFixed(0)}', style: labelStyle),
),
),
),
),
lineTouchData: compact
? LineTouchData(enabled: false)
: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (_) => theme.colorScheme.background,
tooltipBorder: BorderSide(color: theme.colorScheme.border, width: 1),
tooltipPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
getTooltipItems: (touchedSpots) {
return touchedSpots.map((spot) {
final index = spot.x.round();
if (index < 0 || index >= sortedPrices.length) return null;
final d = sortedPrices[index].date;
return LineTooltipItem(
'${_shortDate(d)}\n\$${spot.y.toStringAsFixed(2)}',
GoogleFonts.ibmPlexMono(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
),
);
}).toList();
},
),
),
extraLinesData: ExtraLinesData(
extraLinesOnTop: true,
horizontalLines: [],
verticalLines: compact ? [] : _buildSignalLines(sortedPrices, signals, theme),
),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
color: lineColor,
barWidth: compact ? 2.5 : 2,
belowBarData: compact
? BarAreaData(show: false)
: BarAreaData(show: true, color: lineColor.withValues(alpha: 0.10)),
dotData: FlDotData(
show: !compact,
getDotPainter: (spot, percent, barData, index) {
final matchingSignal = _nearestSignal(sortedPrices[index].date, signals);
if (matchingSignal == null) {
return FlDotCirclePainter(radius: 0, color: Colors.transparent);
}
return FlDotCirclePainter(
radius: 4,
color: _signalColor(matchingSignal.direction),
strokeWidth: 1.5,
strokeColor: theme.colorScheme.background,
);
},
),
),
],
),
),
);
}
List<VerticalLine> _buildSignalLines(
List<StockPricePoint> prices,
List<EventSignal> signals,
dynamic theme,
) {
return signals
.map((signal) {
final idx = _nearestPriceIndex(prices, signal.createdAt);
if (idx == null) return null;
return VerticalLine(
x: idx.toDouble(),
color: _signalColor(signal.direction).withValues(alpha: 0.4),
strokeWidth: 1,
dashArray: [4, 4],
);
})
.whereType<VerticalLine>()
.toList();
}
EventSignal? _nearestSignal(DateTime priceDate, List<EventSignal> signals) {
EventSignal? nearest;
Duration? smallest;
for (final s in signals) {
final diff = s.createdAt.difference(priceDate).abs();
if (smallest == null || diff < smallest) {
smallest = diff;
nearest = s;
}
}
return (smallest != null && smallest <= const Duration(days: 2)) ? nearest : null;
}
int? _nearestPriceIndex(List<StockPricePoint> prices, DateTime target) {
if (prices.isEmpty) return null;
int idx = 0;
Duration nearest = prices.first.date.difference(target).abs();
for (int i = 1; i < prices.length; i++) {
final diff = prices[i].date.difference(target).abs();
if (diff < nearest) {
nearest = diff;
idx = i;
}
}
return nearest <= const Duration(days: 7) ? idx : null;
}
double _priceInterval(double min, double max) {
final range = max - min;
if (range <= 1) return 0.25;
if (range <= 5) return 1;
if (range <= 20) return 5;
if (range <= 100) return 20;
return range / 4;
}
double _xInterval(int length) {
if (length <= 7) return 1;
if (length <= 14) return 3;
return (length / 4).ceilToDouble();
}
Color _signalColor(String direction) => switch (direction) {
'positive' => _growthColor,
'negative' => _declineColor,
_ => const Color(0xFF888888),
};
}
String _shortDate(DateTime d) {
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return '${months[d.month - 1]} ${d.day}';
}