Flutter App

This commit is contained in:
2025-12-14 21:53:46 -05:00
parent 383e2e07bd
commit 7ed7a2470d
108 changed files with 7077 additions and 130 deletions

View File

@@ -0,0 +1,344 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/price_provider.dart';
class AlertsPanel extends StatefulWidget {
const AlertsPanel({super.key});
@override
State<AlertsPanel> createState() => _AlertsPanelState();
}
class _AlertsPanelState extends State<AlertsPanel> {
final _auecController = TextEditingController();
final _priceController = TextEditingController();
String _selectedPreset = '1T';
double _auecAmount = 1000000000000; // 1 trillion AUEC
bool _showCustomInput = false;
// Preset AUEC amounts
static const Map<String, double> _presetAmounts = {
'10T': 10000000000000,
'5T': 5000000000000,
'1T': 1000000000000,
'750B': 750000000000,
'500B': 500000000000,
'250B': 250000000000,
'Other': 0, // Special case for custom input
};
@override
void dispose() {
_auecController.dispose();
_priceController.dispose();
super.dispose();
}
void _updateAmount(String preset) {
setState(() {
_selectedPreset = preset;
if (preset == 'Other') {
_showCustomInput = true;
_auecController.text = _auecAmount.toStringAsFixed(0);
} else {
_showCustomInput = false;
_auecAmount = _presetAmounts[preset]!;
}
});
}
void _setCustomAmount() {
final amount = double.tryParse(_auecController.text);
if (amount != null && amount > 0) {
setState(() {
_auecAmount = amount;
});
}
}
@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: [
const Text(
'Price Alerts',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
const SizedBox(height: 15),
Row(
children: [
// AUEC amount selector
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Dropdown for preset amounts
Container(
height: 40,
decoration: BoxDecoration(
color: const Color(0xFF2A2F4A),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFF50E3C2), width: 1),
),
child: Center(
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedPreset,
dropdownColor: const Color(0xFF2A2F4A),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontFamily: 'monospace',
),
alignment: AlignmentDirectional.center,
items: _presetAmounts.keys.map((String preset) {
return DropdownMenuItem<String>(
value: preset,
alignment: AlignmentDirectional.center,
child: Center(
child: Text(
preset,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontFamily: 'monospace',
),
textAlign: TextAlign.center,
),
),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
_updateAmount(newValue);
}
},
),
),
),
),
if (_showCustomInput) ...[
const SizedBox(width: 8),
Container(
width: 120,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFF2A2F4A),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFF50E3C2), width: 1),
),
child: TextField(
controller: _auecController,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontFamily: 'monospace',
),
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
hintText: 'Enter amount',
hintStyle: TextStyle(
color: Color(0xFF888888),
fontSize: 12,
),
),
textAlign: TextAlign.center,
textAlignVertical: TextAlignVertical.center,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
onSubmitted: (_) => _setCustomAmount(),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _setCustomAmount,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF50E3C2),
foregroundColor: const Color(0xFF0A0E27),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
minimumSize: const Size(0, 40),
),
child: const Text(
'Set',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
],
),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: _priceController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Max USD Price',
hintStyle: const TextStyle(color: Color(0xFF888888)),
filled: true,
fillColor: const Color(0xFF2A2F4A),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: Color(0xFF50E3C2)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: Color(0xFF50E3C2)),
),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
],
),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: () async {
final price = double.tryParse(_priceController.text);
if (_auecAmount > 0 && price != null && price > 0) {
await context.read<PriceProvider>().addAlert(_auecAmount, price);
_priceController.clear();
// Reset to default preset
setState(() {
_selectedPreset = '1T';
_auecAmount = 1000000000000;
_showCustomInput = false;
_auecController.clear();
});
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF50E3C2),
foregroundColor: const Color(0xFF0A0E27),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
),
child: const Text(
'Add Alert',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 15),
Consumer<PriceProvider>(
builder: (context, provider, child) {
if (provider.alerts.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: Text(
'No alerts set. Add an alert to get notified when prices meet your criteria.',
style: TextStyle(color: Color(0xFF888888)),
textAlign: TextAlign.center,
),
),
);
}
return Column(
children: provider.alerts.map((alert) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF2A2F4A),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: alert.enabled
? const Color(0xFF50E3C2)
: const Color(0xFF888888),
width: 2,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${NumberFormat('#,###').format(alert.auecAmount)} AUEC for \$${alert.maxPrice.toStringAsFixed(2)}',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
'\$${(alert.maxPrice / (alert.auecAmount / 1000000)).toStringAsFixed(9)} per 1M AUEC',
style: const TextStyle(
color: Color(0xFF888888),
fontSize: 12,
),
),
],
),
),
ElevatedButton(
onPressed: () => provider.toggleAlert(alert.id),
style: ElevatedButton.styleFrom(
backgroundColor: alert.enabled
? const Color(0xFF50E3C2)
: const Color(0xFF888888),
foregroundColor: const Color(0xFF0A0E27),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
),
child: Text(
alert.enabled ? 'Enabled' : 'Disabled',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () => provider.deleteAlert(alert.id),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFF6B9D),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
),
child: const Text(
'Delete',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}).toList(),
);
},
),
],
),
);
}
}

View File

@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
class LoadingScreen extends StatefulWidget {
final String message;
final bool showLogo;
const LoadingScreen({
super.key,
this.message = 'Loading...',
this.showLogo = true,
});
@override
State<LoadingScreen> createState() => _LoadingScreenState();
}
class _LoadingScreenState extends State<LoadingScreen>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
);
_animation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
_controller.repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0A0E27),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.showLogo) ...[
// App logo with fade animation
FadeTransition(
opacity: _animation,
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: const Color(0xFF50E3C2),
width: 2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: Image.asset(
'assets/logo.png',
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: const Color(0xFF1A1F3A),
child: const Icon(
Icons.currency_exchange,
color: Color(0xFF50E3C2),
size: 60,
),
);
},
),
),
),
),
const SizedBox(height: 32),
// App title
const Text(
'rmtPocketWatcher',
style: TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
const SizedBox(height: 8),
const Text(
'Lambda Banking Conglomerate',
style: TextStyle(
color: Color(0xFF888888),
fontSize: 14,
fontFamily: 'monospace',
),
),
const SizedBox(height: 48),
],
// Loading indicator
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: const Color(0xFF50E3C2).withOpacity(0.3),
width: 2,
),
),
child: const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF50E3C2)),
strokeWidth: 3,
),
),
const SizedBox(height: 24),
// Loading message
Text(
widget.message,
style: const TextStyle(
color: Color(0xFF888888),
fontSize: 16,
fontFamily: 'monospace',
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Animated dots
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Text(
'' * ((_controller.value * 3).floor() + 1).clamp(1, 3),
style: const TextStyle(
color: Color(0xFF50E3C2),
fontSize: 20,
fontFamily: 'monospace',
),
);
},
),
],
),
),
);
}
}
// Splash screen for app startup
class SplashScreen extends StatefulWidget {
final VoidCallback onInitializationComplete;
const SplashScreen({
super.key,
required this.onInitializationComplete,
});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
String _currentMessage = 'Initializing...';
@override
void initState() {
super.initState();
_initializeApp();
}
Future<void> _initializeApp() async {
// Simulate initialization steps
setState(() => _currentMessage = 'Loading configuration...');
await Future.delayed(const Duration(milliseconds: 800));
setState(() => _currentMessage = 'Connecting to services...');
await Future.delayed(const Duration(milliseconds: 600));
setState(() => _currentMessage = 'Setting up notifications...');
await Future.delayed(const Duration(milliseconds: 400));
setState(() => _currentMessage = 'Ready!');
await Future.delayed(const Duration(milliseconds: 300));
widget.onInitializationComplete();
}
@override
Widget build(BuildContext context) {
return LoadingScreen(
message: _currentMessage,
showLogo: true,
);
}
}

View 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',
),
),
],
),
);
},
),
],
),
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
class PriceStatsCard extends StatelessWidget {
final String title;
final String value;
final String? subtitle;
final Color? valueColor;
final double? fontSize;
const PriceStatsCard({
super.key,
required this.title,
required this.value,
this.subtitle,
this.valueColor,
this.fontSize,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: const Color(0xFF1A1F3A),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
color: Color(0xFF888888),
fontSize: 12,
),
),
const SizedBox(height: 5),
Text(
value,
style: TextStyle(
color: valueColor ?? Colors.white,
fontSize: fontSize ?? 18,
fontWeight: FontWeight.bold,
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: const TextStyle(
color: Color(0xFF888888),
fontSize: 12,
),
),
],
],
),
);
}
}

View File

@@ -0,0 +1,329 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/price_provider.dart';
class VendorTable extends StatefulWidget {
const VendorTable({super.key});
@override
State<VendorTable> createState() => _VendorTableState();
}
class _VendorTableState extends State<VendorTable> {
final _customAmountController = TextEditingController();
String _selectedPreset = '1T';
double _customAmount = 1000000000000; // 1 trillion AUEC
bool _showCustomInput = false;
// Preset AUEC amounts
static const Map<String, double> _presetAmounts = {
'10T': 10000000000000,
'5T': 5000000000000,
'1T': 1000000000000,
'750B': 750000000000,
'500B': 500000000000,
'250B': 250000000000,
'Other': 0, // Special case for custom input
};
@override
void dispose() {
_customAmountController.dispose();
super.dispose();
}
void _updateAmount(String preset) {
setState(() {
_selectedPreset = preset;
if (preset == 'Other') {
_showCustomInput = true;
_customAmountController.text = _customAmount.toStringAsFixed(0);
} else {
_showCustomInput = false;
_customAmount = _presetAmounts[preset]!;
}
});
}
void _setCustomAmount() {
final amount = double.tryParse(_customAmountController.text);
if (amount != null && amount > 0) {
setState(() {
_customAmount = amount;
});
}
}
@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: [
Consumer<PriceProvider>(
builder: (context, provider, child) {
final count = provider.latestPrice?.allPrices.length ?? 0;
return Text(
'Current Listings ($count)',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
);
},
),
// AUEC amount selector
Row(
children: [
// Dropdown for preset amounts
Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 8),
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: _selectedPreset,
dropdownColor: const Color(0xFF2A2F4A),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontFamily: 'monospace',
),
alignment: AlignmentDirectional.center,
items: _presetAmounts.keys.map((String preset) {
return DropdownMenuItem<String>(
value: preset,
alignment: AlignmentDirectional.center,
child: Text(
preset,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontFamily: 'monospace',
),
textAlign: TextAlign.center,
),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
_updateAmount(newValue);
}
},
),
),
),
if (_showCustomInput) ...[
const SizedBox(width: 8),
Container(
width: 120,
height: 32,
decoration: BoxDecoration(
color: const Color(0xFF2A2F4A),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFF50E3C2), width: 1),
),
child: TextField(
controller: _customAmountController,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontFamily: 'monospace',
),
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
hintText: 'Enter amount',
hintStyle: TextStyle(
color: Color(0xFF888888),
fontSize: 11,
),
),
textAlignVertical: TextAlignVertical.center,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
onSubmitted: (_) => _setCustomAmount(),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _setCustomAmount,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF50E3C2),
foregroundColor: const Color(0xFF0A0E27),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
minimumSize: const Size(0, 32),
),
child: const Text(
'Set',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
],
),
const SizedBox(height: 16),
Consumer<PriceProvider>(
builder: (context, provider, child) {
if (provider.latestPrice == null) {
return const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: Text(
'Loading vendor data...',
style: TextStyle(color: Color(0xFF888888)),
),
),
);
}
final prices = provider.latestPrice!.allPrices;
final sortedPrices = List.from(prices)
..sort((a, b) => a.pricePerMillion.compareTo(b.pricePerMillion));
return SizedBox(
width: double.infinity, // Force full width
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: MediaQuery.of(context).size.width - 64, // Account for padding
),
child: DataTable(
headingRowColor: WidgetStateProperty.all(const Color(0xFF2A2F4A)),
dataRowColor: WidgetStateProperty.all(const Color(0xFF1A1F3A)),
headingRowHeight: 40,
dataRowMinHeight: 36,
dataRowMaxHeight: 36,
columnSpacing: 32,
columns: [
const DataColumn(
label: Text(
'Platform',
style: TextStyle(
color: Color(0xFF888888),
fontWeight: FontWeight.bold,
fontSize: 12,
fontFamily: 'monospace',
),
),
),
const DataColumn(
label: Text(
'Seller',
style: TextStyle(
color: Color(0xFF888888),
fontWeight: FontWeight.bold,
fontSize: 12,
fontFamily: 'monospace',
),
),
),
const DataColumn(
label: Text(
'Price/1M AUEC',
style: TextStyle(
color: Color(0xFF888888),
fontWeight: FontWeight.bold,
fontSize: 12,
fontFamily: 'monospace',
),
),
),
DataColumn(
label: Text(
'Price for ${NumberFormat('#,###').format(_customAmount)} AUEC',
style: const TextStyle(
color: Color(0xFF888888),
fontWeight: FontWeight.bold,
fontSize: 12,
fontFamily: 'monospace',
),
),
),
],
rows: sortedPrices.map((price) {
final totalPrice = (_customAmount / 1000000) * price.pricePerMillion;
return DataRow(
cells: [
DataCell(
Text(
price.platform,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontFamily: 'monospace',
),
),
),
DataCell(
Text(
price.sellerName,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontFamily: 'monospace',
),
),
),
DataCell(
Text(
'\$${price.pricePerMillion.toStringAsFixed(10)}',
style: const TextStyle(
color: Color(0xFF50E3C2),
fontWeight: FontWeight.bold,
fontSize: 12,
fontFamily: 'monospace',
),
),
),
DataCell(
Text(
'\$${totalPrice.toStringAsFixed(2)}',
style: const TextStyle(
color: Color(0xFF50E3C2),
fontWeight: FontWeight.bold,
fontSize: 12,
fontFamily: 'monospace',
),
),
),
],
);
}).toList(),
),
),
),
);
},
),
],
),
);
}
}