Auger/lib/widgets/stock_price_chart.dart

258 lines
8.6 KiB
Dart

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