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 prices; final List 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 _buildSignalLines( List prices, List 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() .toList(); } EventSignal? _nearestSignal(DateTime priceDate, List 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 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}'; }