import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; import 'package:provider/provider.dart'; import 'package:fl_chart/fl_chart.dart'; import '../providers/price_provider.dart'; import 'loading_screen.dart'; class PriceChart extends StatefulWidget { const PriceChart({super.key}); @override State createState() => _PriceChartState(); } class _PriceChartState extends State { double _yAxisMax = 0.001; // Default Y-axis maximum double _baseYAxisMax = 0.001; // Base maximum from data // X-axis zoom and pan state double _xZoomLevel = 1.0; // 1.0 = full view, 2.0 = 50% view, etc. double _xCenterPoint = 0.5; // 0.0 = leftmost, 1.0 = rightmost int _totalDataPoints = 0; static const timeRanges = [ {'label': '6H', 'value': '6h'}, {'label': '24H', 'value': '24h'}, {'label': '3D', 'value': '3d'}, {'label': '7D', 'value': '7d'}, {'label': '1M', 'value': '1mo'}, {'label': 'YTD', 'value': 'ytd'}, ]; static const colors = [ Color(0xFF50E3C2), // Cyan Color(0xFFFF6B9D), // Pink Color(0xFFFFC658), // Yellow Color(0xFF82CA9D), // Green Color(0xFF8884D8), // Purple Color(0xFFFF7C7C), // Red Color(0xFFA28FD0), // Light Purple Color(0xFFF5A623), // Orange Color(0xFF4A90E2), // Blue Color(0xFF7ED321), // Lime Color(0xFFD0021B), // Dark Red Color(0xFFF8E71C), // Bright Yellow ]; // Helper method to determine if hour marks should be shown bool _shouldShowHourMarks(List visibleTimestamps) { if (visibleTimestamps.length < 2) return false; // Calculate time span of visible data final firstTime = DateTime.fromMillisecondsSinceEpoch(visibleTimestamps.first); final lastTime = DateTime.fromMillisecondsSinceEpoch(visibleTimestamps.last); final timeSpanHours = lastTime.difference(firstTime).inHours; // Show hour marks if viewing less than 3 days and zoomed in enough return timeSpanHours <= 72 && _xZoomLevel >= 2.0; } // Helper method to calculate appropriate X-axis interval double _calculateXAxisInterval(int visibleDataPoints) { if (visibleDataPoints <= 10) return 1.0; if (visibleDataPoints <= 50) return (visibleDataPoints / 5).ceilToDouble(); if (visibleDataPoints <= 200) return (visibleDataPoints / 8).ceilToDouble(); return (visibleDataPoints / 10).ceilToDouble(); } // Helper method to calculate grid interval for vertical lines double _calculateGridInterval(List visibleTimestamps) { if (visibleTimestamps.length <= 10) return 1.0; final showHours = _shouldShowHourMarks(visibleTimestamps); if (showHours) { // More frequent grid lines when showing hours return (visibleTimestamps.length / 12).ceilToDouble(); } else { // Standard grid lines return (visibleTimestamps.length / 6).ceilToDouble(); } } @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: const Color(0xFF1A1F3A), borderRadius: BorderRadius.circular(4), border: Border.all(color: const Color(0xFF50E3C2), width: 1), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'Price History', style: TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, fontFamily: 'monospace', ), ), Consumer( builder: (context, provider, child) { return Container( height: 32, decoration: BoxDecoration( color: const Color(0xFF2A2F4A), borderRadius: BorderRadius.circular(4), border: Border.all(color: const Color(0xFF50E3C2), width: 1), ), child: DropdownButtonHideUnderline( child: DropdownButton( value: provider.selectedRange, dropdownColor: const Color(0xFF2A2F4A), style: const TextStyle( color: Colors.white, fontSize: 12, fontFamily: 'monospace', ), icon: const Icon(Icons.arrow_drop_down, color: Color(0xFF50E3C2)), items: timeRanges.map((range) { return DropdownMenuItem( value: range['value'], child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( range['label']!, style: const TextStyle( color: Colors.white, fontSize: 12, fontFamily: 'monospace', ), ), ), ); }).toList(), onChanged: (String? newValue) { if (newValue != null) { provider.fetchHistory(newValue); } }, ), ), ); }, ), ], ), const SizedBox(height: 16), // Seller legend (Bloomberg style) - Vertical scrollable list Consumer( builder: (context, provider, child) { if (provider.historyData == null || provider.historyData!.prices.isEmpty) { return const SizedBox(); } final sellers = provider.historyData!.prices .map((p) => p.seller) .toSet() .toList(); // Calculate needed height more precisely // Each seller name is ~10px font + 8px spacing = 18px per row // With wrap layout, calculate rows needed based on available width final screenWidth = MediaQuery.of(context).size.width; final availableWidth = screenWidth - 64; // Account for padding and margins // Estimate average seller name width (10px font * ~8 chars + dot + spacing = ~100px) final estimatedItemWidth = 100.0; final itemsPerRow = (availableWidth / estimatedItemWidth).floor().clamp(1, sellers.length); final rowsNeeded = (sellers.length / itemsPerRow).ceil(); // Calculate height: rows * (font + spacing) + padding final neededHeight = (rowsNeeded * 18.0) + 16; // 16 for container padding final maxHeight = MediaQuery.of(context).size.height * 0.3; // Reduced to 30% final containerHeight = neededHeight.clamp(40.0, maxHeight); // Min 40px return Container( height: containerHeight, padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: const Color(0xFF2A2F4A), // Lighter gray background borderRadius: BorderRadius.circular(4), ), child: Center( child: Scrollbar( thumbVisibility: containerHeight >= maxHeight, // Show scrollbar if content exceeds max height child: SingleChildScrollView( child: Wrap( alignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, spacing: 8, // Further reduced spacing runSpacing: 2, // Further reduced vertical spacing children: sellers.asMap().entries.map((entry) { final index = entry.key; final seller = entry.value; final color = colors[index % colors.length]; return Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 6, // Reduced from 8 height: 6, // Reduced from 8 decoration: BoxDecoration( color: color, shape: BoxShape.circle, ), ), const SizedBox(width: 3), // Reduced from 4 Text( seller, style: TextStyle( color: color, fontSize: 9, // Reduced from 10 for more compact layout fontFamily: 'monospace', fontWeight: FontWeight.bold, ), ), ], ); }).toList(), ), ), ), ), ); }, ), const SizedBox(height: 16), Consumer( builder: (context, provider, child) { if (provider.isHistoryLoading) { return const SizedBox( height: 250, child: LoadingScreen( message: 'Loading chart data...', showLogo: false, ), ); } if (provider.historyData == null || provider.historyData!.prices.isEmpty) { return const SizedBox( height: 250, child: Center( child: Text( 'No chart data available', style: TextStyle(color: Color(0xFF888888)), ), ), ); } // Group prices by timestamp and seller final Map> groupedData = {}; for (final price in provider.historyData!.prices) { final timestamp = price.timestamp.millisecondsSinceEpoch; groupedData.putIfAbsent(timestamp, () => {}); groupedData[timestamp]![price.seller] = price.price; } // Convert to chart data final sortedTimestamps = groupedData.keys.toList()..sort(); _totalDataPoints = sortedTimestamps.length; // Calculate X-axis view window based on zoom and center final viewWidth = (_totalDataPoints / _xZoomLevel).round(); final centerIndex = (_xCenterPoint * _totalDataPoints).round(); final startIndex = (centerIndex - viewWidth ~/ 2).clamp(0, _totalDataPoints - viewWidth); final endIndex = (startIndex + viewWidth).clamp(viewWidth, _totalDataPoints); final visibleTimestamps = sortedTimestamps.sublist(startIndex, endIndex); // Get unique sellers for line creation final sellers = provider.historyData!.prices .map((p) => p.seller) .toSet() .toList(); // Create line data for each seller (using visible timestamps) final lineBarsData = sellers.asMap().entries.map((entry) { final index = entry.key; final seller = entry.value; final color = colors[index % colors.length]; final spots = []; for (int i = 0; i < visibleTimestamps.length; i++) { final timestamp = visibleTimestamps[i]; final price = groupedData[timestamp]![seller]; if (price != null) { spots.add(FlSpot(i.toDouble(), price)); } } return LineChartBarData( spots: spots, isCurved: false, color: color, barWidth: 2, isStrokeCapRound: false, dotData: const FlDotData(show: false), ); }).toList(); // Calculate base Y-axis max from data final allPrices = provider.historyData!.prices.map((p) => p.price).toList(); final maxPrice = allPrices.reduce((a, b) => a > b ? a : b); // Set base Y-axis max if not set if (_baseYAxisMax == 0.001) { _baseYAxisMax = maxPrice * 1.1; // Add 10% padding _yAxisMax = _baseYAxisMax; } return Container( height: 250, // Reduced from 300 for more compact layout decoration: BoxDecoration( color: const Color(0xFF0A0E27), borderRadius: BorderRadius.circular(4), ), child: ClipRRect( borderRadius: BorderRadius.circular(4), child: GestureDetector( onDoubleTap: () { // Reset zoom on double tap setState(() { _yAxisMax = _baseYAxisMax; }); }, child: Listener( onPointerSignal: (pointerSignal) { if (pointerSignal is PointerScrollEvent) { setState(() { // Scroll up = zoom in (decrease Y max), scroll down = zoom out (increase Y max) final delta = pointerSignal.scrollDelta.dy; final zoomFactor = delta > 0 ? 1.1 : 0.9; // Zoom sensitivity _yAxisMax *= zoomFactor; // Clamp Y-axis max to reasonable bounds final minY = maxPrice * 0.1; // Don't zoom in too much final maxY = maxPrice * 10; // Don't zoom out too much _yAxisMax = _yAxisMax.clamp(minY, maxY); }); } }, child: LineChart( LineChartData( backgroundColor: const Color(0xFF0A0E27), minY: 0, maxY: _yAxisMax, gridData: FlGridData( show: true, drawVerticalLine: true, verticalInterval: _calculateGridInterval(visibleTimestamps), getDrawingHorizontalLine: (value) { return FlLine( color: const Color(0xFF2A2F4A), strokeWidth: 1, ); }, getDrawingVerticalLine: (value) { // Different line styles based on zoom level final showHours = _shouldShowHourMarks(visibleTimestamps); return FlLine( color: showHours ? const Color(0xFF3A3F5A) : const Color(0xFF2A2F4A), strokeWidth: showHours ? 0.5 : 1, ); }, ), titlesData: FlTitlesData( show: true, rightTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), topTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 45, // Reduced for more compact layout interval: _calculateXAxisInterval(visibleTimestamps.length), getTitlesWidget: (value, meta) { if (value.toInt() >= visibleTimestamps.length) { return const Text(''); } final timestamp = visibleTimestamps[value.toInt()]; final date = DateTime.fromMillisecondsSinceEpoch(timestamp); // Determine if we should show hour marks based on zoom level final showHours = _shouldShowHourMarks(visibleTimestamps); return Padding( padding: const EdgeInsets.only(top: 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( '${date.month}/${date.day}', style: const TextStyle( color: Color(0xFF888888), fontSize: 10, fontFamily: 'monospace', ), ), if (showHours) ...[ const SizedBox(height: 2), Text( '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}', style: const TextStyle( color: Color(0xFF50E3C2), fontSize: 9, fontFamily: 'monospace', fontWeight: FontWeight.bold, ), ), ], ], ), ); }, ), ), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: MediaQuery.of(context).size.width > 600, // Hide on mobile reservedSize: MediaQuery.of(context).size.width > 600 ? 70 : 0, // No space on mobile getTitlesWidget: (value, meta) { return Text( value >= 1 ? '\$${value.toStringAsFixed(2)}' : value >= 0.01 ? '\$${value.toStringAsFixed(4)}' : value >= 0.0001 ? '\$${value.toStringAsFixed(6)}' : '\$${value.toStringAsFixed(8)}', // Use more decimal places instead of scientific notation style: const TextStyle( color: Color(0xFF888888), fontSize: 9, fontFamily: 'monospace', ), ); }, ), ), ), borderData: FlBorderData( show: true, border: Border.all(color: const Color(0xFF2A2F4A)), ), lineBarsData: lineBarsData, clipData: const FlClipData.all(), // Clip chart lines to bounds lineTouchData: LineTouchData( enabled: true, touchTooltipData: LineTouchTooltipData( getTooltipColor: (touchedSpot) => const Color(0xFF2A2F4A), tooltipRoundedRadius: 4, tooltipPadding: const EdgeInsets.all(8), getTooltipItems: (List touchedBarSpots) { return touchedBarSpots.map((barSpot) { final seller = sellers[barSpot.barIndex]; final price = barSpot.y; final timestamp = visibleTimestamps[barSpot.x.toInt()]; final date = DateTime.fromMillisecondsSinceEpoch(timestamp); return LineTooltipItem( '$seller\n\$${price >= 1 ? price.toStringAsFixed(2) : price >= 0.01 ? price.toStringAsFixed(4) : price >= 0.0001 ? price.toStringAsFixed(6) : price.toStringAsFixed(8)}\n${date.month}/${date.day} ${date.hour}:${date.minute.toString().padLeft(2, '0')}', TextStyle( color: colors[barSpot.barIndex % colors.length], fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'monospace', ), ); }).toList(); }, ), touchCallback: (FlTouchEvent event, LineTouchResponse? touchResponse) { // Handle touch events if needed }, handleBuiltInTouches: true, ), ), ), ), ), ), ); }, ), const SizedBox(height: 12), // Reduced from 16 // X-axis zoom controls Consumer( builder: (context, provider, child) { if (provider.historyData == null || provider.historyData!.prices.isEmpty) { return const SizedBox(); } return Wrap( alignment: WrapAlignment.center, spacing: 4, // Reduced spacing runSpacing: 8, children: [ // Zoom out button IconButton( onPressed: _xZoomLevel > 1.0 ? () { setState(() { _xZoomLevel = (_xZoomLevel / 1.5).clamp(1.0, 10.0); }); } : null, icon: const Icon(Icons.zoom_out, color: Color(0xFF50E3C2), size: 18), // Smaller icon style: IconButton.styleFrom( backgroundColor: const Color(0xFF2A2F4A), disabledBackgroundColor: const Color(0xFF1A1F3A), minimumSize: const Size(32, 32), // Smaller buttons ), ), // Left navigation IconButton( onPressed: _xCenterPoint > 0.1 ? () { setState(() { final step = 0.1 / _xZoomLevel; _xCenterPoint = (_xCenterPoint - step).clamp(0.0, 1.0); }); } : null, icon: const Icon(Icons.chevron_left, color: Color(0xFF50E3C2), size: 18), style: IconButton.styleFrom( backgroundColor: const Color(0xFF2A2F4A), disabledBackgroundColor: const Color(0xFF1A1F3A), minimumSize: const Size(32, 32), ), ), // Zoom level indicator Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), // More compact decoration: BoxDecoration( color: const Color(0xFF2A2F4A), borderRadius: BorderRadius.circular(4), ), child: Text( '${(_xZoomLevel * 100).toInt()}%', style: const TextStyle( color: Color(0xFF50E3C2), fontSize: 10, // Smaller font fontFamily: 'monospace', fontWeight: FontWeight.bold, ), ), ), // Right navigation IconButton( onPressed: _xCenterPoint < 0.9 ? () { setState(() { final step = 0.1 / _xZoomLevel; _xCenterPoint = (_xCenterPoint + step).clamp(0.0, 1.0); }); } : null, icon: const Icon(Icons.chevron_right, color: Color(0xFF50E3C2), size: 18), style: IconButton.styleFrom( backgroundColor: const Color(0xFF2A2F4A), disabledBackgroundColor: const Color(0xFF1A1F3A), minimumSize: const Size(32, 32), ), ), // Zoom in button IconButton( onPressed: _xZoomLevel < 10.0 ? () { setState(() { _xZoomLevel = (_xZoomLevel * 1.5).clamp(1.0, 10.0); }); } : null, icon: const Icon(Icons.zoom_in, color: Color(0xFF50E3C2), size: 18), style: IconButton.styleFrom( backgroundColor: const Color(0xFF2A2F4A), disabledBackgroundColor: const Color(0xFF1A1F3A), minimumSize: const Size(32, 32), ), ), // Reset button ElevatedButton.icon( onPressed: () { setState(() { _xZoomLevel = 1.0; _xCenterPoint = 0.5; _yAxisMax = _baseYAxisMax; }); }, icon: const Icon(Icons.refresh, size: 14), // Smaller icon label: const Text('Reset', style: TextStyle(fontSize: 10)), // Shorter text, smaller font style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF2A2F4A), foregroundColor: const Color(0xFF50E3C2), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), // More compact minimumSize: const Size(0, 32), // Smaller height ), ), ], ); }, ), const SizedBox(height: 12), // Reduced from 16 // Timeline scrubber (Bloomberg style) Consumer( builder: (context, provider, child) { if (provider.historyData == null || provider.historyData!.prices.isEmpty) { return const SizedBox(); } // Get first and last timestamps for display final groupedData = >{}; for (final price in provider.historyData!.prices) { final timestamp = price.timestamp.millisecondsSinceEpoch; groupedData.putIfAbsent(timestamp, () => {}); } final sortedTimestamps = groupedData.keys.toList()..sort(); final firstDate = DateTime.fromMillisecondsSinceEpoch(sortedTimestamps.first); final lastDate = DateTime.fromMillisecondsSinceEpoch(sortedTimestamps.last); return Container( height: 40, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( color: const Color(0xFF2A2F4A), // Lighter gray background borderRadius: BorderRadius.circular(4), ), child: Row( children: [ Text( '${firstDate.month}/${firstDate.day}', style: const TextStyle( color: Color(0xFF888888), fontSize: 10, fontFamily: 'monospace', ), ), Expanded( child: Slider( value: _xCenterPoint, onChanged: (value) { setState(() { _xCenterPoint = value; }); }, activeColor: const Color(0xFF50E3C2), inactiveColor: const Color(0xFF1A1F3A), ), ), Text( '${lastDate.month}/${lastDate.day}', style: const TextStyle( color: Color(0xFF888888), fontSize: 10, fontFamily: 'monospace', ), ), ], ), ); }, ), ], ), ); } }