258 lines
8.6 KiB
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}';
|
|
}
|