Flutter App
This commit is contained in:
670
flutter_app/lib/widgets/price_chart.dart
Normal file
670
flutter_app/lib/widgets/price_chart.dart
Normal file
@@ -0,0 +1,670 @@
|
||||
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<PriceChart> createState() => _PriceChartState();
|
||||
}
|
||||
|
||||
class _PriceChartState extends State<PriceChart> {
|
||||
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<int> 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<int> 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<PriceProvider>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<PriceProvider>(
|
||||
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<PriceProvider>(
|
||||
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<int, Map<String, double>> 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 = <FlSpot>[];
|
||||
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<LineBarSpot> 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<PriceProvider>(
|
||||
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<PriceProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.historyData == null || provider.historyData!.prices.isEmpty) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
// Get first and last timestamps for display
|
||||
final groupedData = <int, Map<String, double>>{};
|
||||
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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user