Flutter App
This commit is contained in:
86
flutter_app/lib/main.dart
Normal file
86
flutter_app/lib/main.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'providers/price_provider.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'widgets/loading_screen.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Load environment variables
|
||||
await dotenv.load(fileName: ".env");
|
||||
|
||||
// Initialize notification service
|
||||
await NotificationService().initialize();
|
||||
await NotificationService().requestPermissions();
|
||||
|
||||
// Initialize window manager for desktop only (not web)
|
||||
if (!kIsWeb && (defaultTargetPlatform == TargetPlatform.windows ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS ||
|
||||
defaultTargetPlatform == TargetPlatform.linux)) {
|
||||
await windowManager.ensureInitialized();
|
||||
|
||||
WindowOptions windowOptions = const WindowOptions(
|
||||
size: Size(1400, 900),
|
||||
minimumSize: Size(1000, 700),
|
||||
center: true,
|
||||
backgroundColor: Color(0xFF0A0E27),
|
||||
skipTaskbar: false,
|
||||
titleBarStyle: TitleBarStyle.hidden,
|
||||
title: 'rmtPocketWatcher',
|
||||
);
|
||||
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
});
|
||||
}
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
State<MyApp> createState() => _MyAppState();
|
||||
}
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
bool _isInitialized = false;
|
||||
|
||||
void _onInitializationComplete() {
|
||||
setState(() {
|
||||
_isInitialized = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => PriceProvider(),
|
||||
child: MaterialApp(
|
||||
title: 'rmtPocketWatcher',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: Color(0xFF50E3C2), // Cyan accent
|
||||
secondary: Color(0xFF50E3C2),
|
||||
surface: Color(0xFF1A1F3A), // Main background
|
||||
onSurface: Colors.white,
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFF0A0E27),
|
||||
useMaterial3: true,
|
||||
fontFamily: 'monospace', // Terminal-style font
|
||||
),
|
||||
home: _isInitialized
|
||||
? const HomeScreen()
|
||||
: SplashScreen(onInitializationComplete: _onInitializationComplete),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
flutter_app/lib/models/price_data.dart
Normal file
162
flutter_app/lib/models/price_data.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
class PriceData {
|
||||
final String id;
|
||||
final String platform;
|
||||
final String sellerName;
|
||||
final double pricePerMillion;
|
||||
final DateTime timestamp;
|
||||
final String? url;
|
||||
|
||||
PriceData({
|
||||
required this.id,
|
||||
required this.platform,
|
||||
required this.sellerName,
|
||||
required this.pricePerMillion,
|
||||
required this.timestamp,
|
||||
this.url,
|
||||
});
|
||||
|
||||
factory PriceData.fromJson(Map<String, dynamic> json) {
|
||||
return PriceData(
|
||||
id: json['id'] as String,
|
||||
platform: json['platform'] as String,
|
||||
sellerName: (json['sellerName'] as String?) ?? 'Unknown',
|
||||
pricePerMillion: (json['pricePerMillion'] as num).toDouble(),
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
url: json['url'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'platform': platform,
|
||||
'sellerName': sellerName,
|
||||
'pricePerMillion': pricePerMillion,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'url': url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class LatestPrice {
|
||||
final double lowestPrice;
|
||||
final String sellerName;
|
||||
final String platform;
|
||||
final List<PriceData> allPrices;
|
||||
final DateTime timestamp;
|
||||
|
||||
LatestPrice({
|
||||
required this.lowestPrice,
|
||||
required this.sellerName,
|
||||
required this.platform,
|
||||
required this.allPrices,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory LatestPrice.fromJson(Map<String, dynamic> json) {
|
||||
final data = json['data'] as Map<String, dynamic>;
|
||||
return LatestPrice(
|
||||
lowestPrice: (data['lowestPrice'] as num).toDouble(),
|
||||
sellerName: data['sellerName'] as String,
|
||||
platform: data['platform'] as String,
|
||||
allPrices: (data['allPrices'] as List)
|
||||
.map((e) => PriceData.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
timestamp: DateTime.parse(data['timestamp'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryData {
|
||||
final List<HistoryPrice> prices;
|
||||
|
||||
HistoryData({required this.prices});
|
||||
|
||||
factory HistoryData.fromJson(Map<String, dynamic> json) {
|
||||
return HistoryData(
|
||||
prices: (json['data'] as List)
|
||||
.map((e) => HistoryPrice.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryPrice {
|
||||
final DateTime timestamp;
|
||||
final double price;
|
||||
final String vendor;
|
||||
final String seller;
|
||||
|
||||
HistoryPrice({
|
||||
required this.timestamp,
|
||||
required this.price,
|
||||
required this.vendor,
|
||||
required this.seller,
|
||||
});
|
||||
|
||||
factory HistoryPrice.fromJson(Map<String, dynamic> json) {
|
||||
return HistoryPrice(
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
price: double.parse(json['price'] as String),
|
||||
vendor: json['vendor'] as String,
|
||||
seller: json['seller'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert to PriceData for compatibility
|
||||
PriceData toPriceData() {
|
||||
return PriceData(
|
||||
id: '${timestamp.millisecondsSinceEpoch}-$seller',
|
||||
platform: vendor,
|
||||
sellerName: seller,
|
||||
pricePerMillion: price,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PriceAlert {
|
||||
final String id;
|
||||
final double auecAmount;
|
||||
final double maxPrice;
|
||||
final bool enabled;
|
||||
|
||||
PriceAlert({
|
||||
required this.id,
|
||||
required this.auecAmount,
|
||||
required this.maxPrice,
|
||||
required this.enabled,
|
||||
});
|
||||
|
||||
factory PriceAlert.fromJson(Map<String, dynamic> json) {
|
||||
return PriceAlert(
|
||||
id: json['id'] as String,
|
||||
auecAmount: (json['auecAmount'] as num).toDouble(),
|
||||
maxPrice: (json['maxPrice'] as num).toDouble(),
|
||||
enabled: json['enabled'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'auecAmount': auecAmount,
|
||||
'maxPrice': maxPrice,
|
||||
'enabled': enabled,
|
||||
};
|
||||
}
|
||||
|
||||
PriceAlert copyWith({
|
||||
String? id,
|
||||
double? auecAmount,
|
||||
double? maxPrice,
|
||||
bool? enabled,
|
||||
}) {
|
||||
return PriceAlert(
|
||||
id: id ?? this.id,
|
||||
auecAmount: auecAmount ?? this.auecAmount,
|
||||
maxPrice: maxPrice ?? this.maxPrice,
|
||||
enabled: enabled ?? this.enabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
176
flutter_app/lib/providers/price_provider.dart
Normal file
176
flutter_app/lib/providers/price_provider.dart
Normal file
@@ -0,0 +1,176 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/price_data.dart';
|
||||
import '../services/websocket_service.dart';
|
||||
import '../services/api_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
|
||||
class PriceProvider with ChangeNotifier {
|
||||
final WebSocketService _wsService = WebSocketService();
|
||||
final ApiService _apiService = ApiService();
|
||||
final StorageService _storageService = StorageService();
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
|
||||
LatestPrice? _latestPrice;
|
||||
HistoryData? _historyData;
|
||||
String _connectionStatus = 'Disconnected';
|
||||
List<PriceAlert> _alerts = [];
|
||||
double? _customAuecAmount;
|
||||
String _selectedRange = '7d';
|
||||
bool _isLoading = false;
|
||||
bool _isHistoryLoading = false;
|
||||
|
||||
LatestPrice? get latestPrice => _latestPrice;
|
||||
HistoryData? get historyData => _historyData;
|
||||
String get connectionStatus => _connectionStatus;
|
||||
List<PriceAlert> get alerts => _alerts;
|
||||
double? get customAuecAmount => _customAuecAmount;
|
||||
String get selectedRange => _selectedRange;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isHistoryLoading => _isHistoryLoading;
|
||||
|
||||
PriceProvider() {
|
||||
_initialize();
|
||||
}
|
||||
|
||||
Timer? _pollTimer;
|
||||
|
||||
void _initialize() {
|
||||
// Load saved data
|
||||
_loadAlerts();
|
||||
_loadCustomAuecAmount();
|
||||
|
||||
// Connect WebSocket (currently disabled)
|
||||
_wsService.connect();
|
||||
|
||||
// Listen to WebSocket streams
|
||||
_wsService.latestPriceStream.listen((price) {
|
||||
_latestPrice = price;
|
||||
_checkAlerts(price);
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
_wsService.connectionStatusStream.listen((status) {
|
||||
_connectionStatus = status;
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
// Fetch initial data
|
||||
fetchInitialData();
|
||||
|
||||
// Start polling for updates every 5 minutes as backup to WebSocket
|
||||
_startPolling();
|
||||
}
|
||||
|
||||
void _startPolling() {
|
||||
_pollTimer = Timer.periodic(const Duration(minutes: 5), (timer) {
|
||||
fetchInitialData();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchInitialData() async {
|
||||
final latest = await _apiService.fetchLatestPrice();
|
||||
if (latest != null) {
|
||||
_latestPrice = latest;
|
||||
notifyListeners();
|
||||
}
|
||||
await fetchHistory(_selectedRange);
|
||||
}
|
||||
|
||||
Future<void> fetchHistory(String range) async {
|
||||
_isHistoryLoading = true;
|
||||
_selectedRange = range;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final history = await _apiService.fetchHistory(range);
|
||||
if (history != null) {
|
||||
_historyData = history;
|
||||
}
|
||||
_wsService.requestHistory(range);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error fetching history: $e');
|
||||
}
|
||||
} finally {
|
||||
_isHistoryLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadAlerts() async {
|
||||
_alerts = await _storageService.getAlerts();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadCustomAuecAmount() async {
|
||||
_customAuecAmount = await _storageService.getCustomAuecAmount();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> addAlert(double auecAmount, double maxPrice) async {
|
||||
final alert = PriceAlert(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
auecAmount: auecAmount,
|
||||
maxPrice: maxPrice,
|
||||
enabled: true,
|
||||
);
|
||||
await _storageService.addAlert(alert);
|
||||
await _loadAlerts();
|
||||
}
|
||||
|
||||
Future<void> toggleAlert(String id) async {
|
||||
final alert = _alerts.firstWhere((a) => a.id == id);
|
||||
final updated = alert.copyWith(enabled: !alert.enabled);
|
||||
await _storageService.updateAlert(updated);
|
||||
await _loadAlerts();
|
||||
}
|
||||
|
||||
Future<void> deleteAlert(String id) async {
|
||||
await _storageService.deleteAlert(id);
|
||||
await _loadAlerts();
|
||||
}
|
||||
|
||||
Future<void> setCustomAuecAmount(double amount) async {
|
||||
await _storageService.setCustomAuecAmount(amount);
|
||||
_customAuecAmount = amount;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _checkAlerts(LatestPrice price) {
|
||||
for (final alert in _alerts) {
|
||||
if (!alert.enabled) continue;
|
||||
|
||||
final matchingSeller = price.allPrices.firstWhere(
|
||||
(p) {
|
||||
final totalPrice = (alert.auecAmount / 1000000) * p.pricePerMillion;
|
||||
return totalPrice <= alert.maxPrice;
|
||||
},
|
||||
orElse: () => price.allPrices.first,
|
||||
);
|
||||
|
||||
final totalPrice = (alert.auecAmount / 1000000) * matchingSeller.pricePerMillion;
|
||||
if (totalPrice <= alert.maxPrice) {
|
||||
// Trigger notification
|
||||
_notificationService.showPriceAlert(
|
||||
title: 'Price Alert Triggered!',
|
||||
body: '${(alert.auecAmount / 1000000000).toStringAsFixed(1)}B AUEC available for \$${totalPrice.toStringAsFixed(2)} from ${matchingSeller.sellerName}',
|
||||
auecAmount: alert.auecAmount,
|
||||
price: totalPrice,
|
||||
seller: matchingSeller.sellerName,
|
||||
);
|
||||
|
||||
// Disable alert
|
||||
toggleAlert(alert.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollTimer?.cancel();
|
||||
_wsService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
314
flutter_app/lib/screens/home_screen.dart
Normal file
314
flutter_app/lib/screens/home_screen.dart
Normal file
@@ -0,0 +1,314 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/price_provider.dart';
|
||||
import '../widgets/price_chart.dart';
|
||||
import '../widgets/alerts_panel.dart';
|
||||
import '../widgets/vendor_table.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0A0E27),
|
||||
body: Column(
|
||||
children: [
|
||||
// Custom title bar (desktop only)
|
||||
if (!kIsWeb && (Theme.of(context).platform == TargetPlatform.windows ||
|
||||
Theme.of(context).platform == TargetPlatform.macOS ||
|
||||
Theme.of(context).platform == TargetPlatform.linux))
|
||||
Container(
|
||||
height: 40,
|
||||
color: const Color(0xFF1A1F3A),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
const Text(
|
||||
'rmtPocketWatcher',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const Text(
|
||||
'Lambda Banking Conglomerate',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF888888),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.minimize, color: Colors.white, size: 16),
|
||||
onPressed: () {
|
||||
// Minimize window - implement with window_manager
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white, size: 16),
|
||||
onPressed: () {
|
||||
// Close window - implement with window_manager
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Main content
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Top stats row - Bloomberg style
|
||||
Consumer<PriceProvider>(
|
||||
builder: (context, provider, child) {
|
||||
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: MediaQuery.of(context).size.width > 600
|
||||
? Row(
|
||||
children: [
|
||||
// Connection status
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'CONNECTION',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF888888),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
provider.connectionStatus == 'Connected' ? 'CONNECTED' : provider.connectionStatus.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: provider.connectionStatus == 'Connected'
|
||||
? const Color(0xFF50E3C2)
|
||||
: const Color(0xFFFF6B9D),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (provider.latestPrice != null) ...[
|
||||
// Lowest price
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'LOWEST PRICE',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF888888),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
provider.latestPrice!.lowestPrice >= 1
|
||||
? '\$${provider.latestPrice!.lowestPrice.toStringAsFixed(2)}'
|
||||
: provider.latestPrice!.lowestPrice >= 0.01
|
||||
? '\$${provider.latestPrice!.lowestPrice.toStringAsFixed(4)}'
|
||||
: provider.latestPrice!.lowestPrice >= 0.0001
|
||||
? '\$${provider.latestPrice!.lowestPrice.toStringAsFixed(6)}'
|
||||
: '\$${provider.latestPrice!.lowestPrice.toStringAsFixed(8)}', // Use more decimal places instead of scientific notation
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF50E3C2),
|
||||
fontSize: 18, // Reduced from 20
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'per 1M AUEC',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF888888),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Seller info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'SELLER',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF888888),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
provider.latestPrice!.sellerName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
Text(
|
||||
provider.latestPrice!.platform,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF888888),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Connection status (mobile)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'CONNECTION',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF888888),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
provider.connectionStatus == 'Connected' ? 'CONNECTED' : provider.connectionStatus.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: provider.connectionStatus == 'Connected'
|
||||
? const Color(0xFF50E3C2)
|
||||
: const Color(0xFFFF6B9D),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (provider.latestPrice != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
// Lowest price (mobile)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'LOWEST PRICE',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF888888),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
provider.latestPrice!.lowestPrice >= 1
|
||||
? '\$${provider.latestPrice!.lowestPrice.toStringAsFixed(2)}'
|
||||
: provider.latestPrice!.lowestPrice >= 0.01
|
||||
? '\$${provider.latestPrice!.lowestPrice.toStringAsFixed(4)}'
|
||||
: provider.latestPrice!.lowestPrice >= 0.0001
|
||||
? '\$${provider.latestPrice!.lowestPrice.toStringAsFixed(6)}'
|
||||
: '\$${provider.latestPrice!.lowestPrice.toStringAsFixed(8)}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF50E3C2),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'per 1M AUEC',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF888888),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Seller info (mobile)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'SELLER',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF888888),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
provider.latestPrice!.sellerName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
Text(
|
||||
provider.latestPrice!.platform,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF888888),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Price Alerts section
|
||||
const AlertsPanel(),
|
||||
const SizedBox(height: 16),
|
||||
// Price History Chart
|
||||
const PriceChart(),
|
||||
const SizedBox(height: 16),
|
||||
// Current Listings table
|
||||
const VendorTable(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
flutter_app/lib/services/api_service.dart
Normal file
76
flutter_app/lib/services/api_service.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../models/price_data.dart';
|
||||
|
||||
class ApiService {
|
||||
final String baseUrl;
|
||||
|
||||
ApiService() : baseUrl = dotenv.env['API_URL'] ?? 'http://localhost:3000' {
|
||||
// Debug: Print the actual URL being used
|
||||
if (kDebugMode) {
|
||||
print('ApiService initialized with baseUrl: $baseUrl');
|
||||
print('Available env vars: ${dotenv.env.keys.toList()}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<LatestPrice?> fetchLatestPrice() async {
|
||||
try {
|
||||
final url = '$baseUrl/prices/latest';
|
||||
if (kDebugMode) {
|
||||
print('Fetching latest price from: $url');
|
||||
}
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Response status: ${response.statusCode}');
|
||||
print('Response body: ${response.body}');
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return LatestPrice.fromJson(data);
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('HTTP Error ${response.statusCode}: ${response.body}');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error fetching latest price: $e');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<HistoryData?> fetchHistory(String range) async {
|
||||
try {
|
||||
final url = '$baseUrl/index/history?range=$range';
|
||||
if (kDebugMode) {
|
||||
print('Fetching history from: $url');
|
||||
}
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (kDebugMode) {
|
||||
print('History response status: ${response.statusCode}');
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return HistoryData.fromJson(data);
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('HTTP Error ${response.statusCode}: ${response.body}');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error fetching history: $e');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
150
flutter_app/lib/services/notification_service.dart
Normal file
150
flutter_app/lib/services/notification_service.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||
bool _initialized = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
try {
|
||||
// Android initialization
|
||||
const AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
// iOS initialization
|
||||
const DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
// Linux/Windows initialization
|
||||
const LinuxInitializationSettings linuxSettings = LinuxInitializationSettings(
|
||||
defaultActionName: 'Open rmtPocketWatcher',
|
||||
);
|
||||
|
||||
const InitializationSettings settings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
linux: linuxSettings,
|
||||
);
|
||||
|
||||
await _notifications.initialize(
|
||||
settings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTapped,
|
||||
);
|
||||
|
||||
_initialized = true;
|
||||
|
||||
if (kDebugMode) {
|
||||
print('NotificationService initialized successfully');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to initialize notifications: $e');
|
||||
}
|
||||
// Don't throw - allow app to continue without notifications
|
||||
}
|
||||
}
|
||||
|
||||
void _onNotificationTapped(NotificationResponse response) {
|
||||
// Handle notification tap if needed
|
||||
if (kDebugMode) {
|
||||
print('Notification tapped: ${response.payload}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showPriceAlert({
|
||||
required String title,
|
||||
required String body,
|
||||
required double auecAmount,
|
||||
required double price,
|
||||
required String seller,
|
||||
}) async {
|
||||
if (!_initialized) await initialize();
|
||||
if (!_initialized) return; // Skip if initialization failed
|
||||
|
||||
try {
|
||||
// Android notification details
|
||||
const AndroidNotificationDetails androidDetails = AndroidNotificationDetails(
|
||||
'price_alerts',
|
||||
'Price Alerts',
|
||||
channelDescription: 'Notifications for AUEC price alerts from rmtPocketWatcher',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
sound: RawResourceAndroidNotificationSound('notifcation'),
|
||||
icon: '@mipmap/ic_launcher',
|
||||
largeIcon: DrawableResourceAndroidBitmap('@mipmap/ic_launcher'),
|
||||
enableVibration: true,
|
||||
enableLights: true,
|
||||
showWhen: true,
|
||||
);
|
||||
|
||||
// iOS notification details
|
||||
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(
|
||||
sound: 'notifcation.mp3', // iOS looks in main bundle, not assets
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
badgeNumber: 1,
|
||||
subtitle: 'Lambda Banking Conglomerate',
|
||||
threadIdentifier: 'price_alerts',
|
||||
);
|
||||
|
||||
// Linux/Windows notification details
|
||||
final LinuxNotificationDetails linuxDetails = LinuxNotificationDetails(
|
||||
icon: AssetsLinuxIcon('assets/logo.png'),
|
||||
sound: AssetsLinuxSound('assets/notifcation.mp3'),
|
||||
category: LinuxNotificationCategory.imReceived,
|
||||
urgency: LinuxNotificationUrgency.critical,
|
||||
);
|
||||
|
||||
final NotificationDetails details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
linux: linuxDetails,
|
||||
);
|
||||
|
||||
final int notificationId = DateTime.now().millisecondsSinceEpoch.remainder(100000);
|
||||
|
||||
await _notifications.show(
|
||||
notificationId,
|
||||
title,
|
||||
body,
|
||||
details,
|
||||
payload: 'price_alert:$seller:$auecAmount:$price',
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Price alert notification sent: $title - $body');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to show notification: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> requestPermissions() async {
|
||||
if (!_initialized) await initialize();
|
||||
|
||||
// Request permissions for iOS
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
|
||||
// Request permissions for Android 13+
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
}
|
||||
}
|
||||
54
flutter_app/lib/services/storage_service.dart
Normal file
54
flutter_app/lib/services/storage_service.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/price_data.dart';
|
||||
|
||||
class StorageService {
|
||||
static const String _alertsKey = 'price_alerts';
|
||||
static const String _customAuecKey = 'custom_auec_amount';
|
||||
|
||||
Future<List<PriceAlert>> getAlerts() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final alertsJson = prefs.getString(_alertsKey);
|
||||
if (alertsJson == null) return [];
|
||||
|
||||
final List<dynamic> decoded = jsonDecode(alertsJson);
|
||||
return decoded.map((e) => PriceAlert.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<void> saveAlerts(List<PriceAlert> alerts) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final alertsJson = jsonEncode(alerts.map((e) => e.toJson()).toList());
|
||||
await prefs.setString(_alertsKey, alertsJson);
|
||||
}
|
||||
|
||||
Future<void> addAlert(PriceAlert alert) async {
|
||||
final alerts = await getAlerts();
|
||||
alerts.add(alert);
|
||||
await saveAlerts(alerts);
|
||||
}
|
||||
|
||||
Future<void> updateAlert(PriceAlert alert) async {
|
||||
final alerts = await getAlerts();
|
||||
final index = alerts.indexWhere((a) => a.id == alert.id);
|
||||
if (index != -1) {
|
||||
alerts[index] = alert;
|
||||
await saveAlerts(alerts);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAlert(String id) async {
|
||||
final alerts = await getAlerts();
|
||||
alerts.removeWhere((a) => a.id == id);
|
||||
await saveAlerts(alerts);
|
||||
}
|
||||
|
||||
Future<double?> getCustomAuecAmount() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getDouble(_customAuecKey);
|
||||
}
|
||||
|
||||
Future<void> setCustomAuecAmount(double amount) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_customAuecKey, amount);
|
||||
}
|
||||
}
|
||||
127
flutter_app/lib/services/websocket_service.dart
Normal file
127
flutter_app/lib/services/websocket_service.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../models/price_data.dart';
|
||||
|
||||
class WebSocketService {
|
||||
WebSocketChannel? _channel;
|
||||
final _latestPriceController = StreamController<LatestPrice>.broadcast();
|
||||
final _connectionStatusController = StreamController<String>.broadcast();
|
||||
Timer? _reconnectTimer;
|
||||
bool _isConnecting = false;
|
||||
|
||||
Stream<LatestPrice> get latestPriceStream => _latestPriceController.stream;
|
||||
Stream<String> get connectionStatusStream => _connectionStatusController.stream;
|
||||
|
||||
void connect() {
|
||||
if (_isConnecting) return;
|
||||
_isConnecting = true;
|
||||
|
||||
try {
|
||||
final wsUrl = dotenv.env['WS_URL'] ?? 'ws://localhost:3000/ws/index';
|
||||
if (kDebugMode) {
|
||||
print('WebSocket connecting to: $wsUrl');
|
||||
print('Available env vars: ${dotenv.env.keys.toList()}');
|
||||
}
|
||||
|
||||
_channel = WebSocketChannel.connect(Uri.parse(wsUrl));
|
||||
|
||||
// Send subscription message after connection
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'subscribe',
|
||||
'data': {'channel': 'price_updates'}
|
||||
}));
|
||||
|
||||
_connectionStatusController.add('Connected');
|
||||
_isConnecting = false;
|
||||
|
||||
_channel!.stream.listen(
|
||||
(message) {
|
||||
try {
|
||||
final data = jsonDecode(message as String) as Map<String, dynamic>;
|
||||
if (kDebugMode) {
|
||||
print('WebSocket message received: ${data['type']}');
|
||||
}
|
||||
|
||||
switch (data['type']) {
|
||||
case 'price_update':
|
||||
final latestPrice = LatestPrice.fromJson({'data': data['data']});
|
||||
_latestPriceController.add(latestPrice);
|
||||
break;
|
||||
case 'history_data':
|
||||
// Handle history data if needed
|
||||
break;
|
||||
case 'connection_status':
|
||||
if (kDebugMode) {
|
||||
print('Connection status: ${data['data']}');
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
if (kDebugMode) {
|
||||
print('WebSocket error message: ${data['data']}');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (kDebugMode) {
|
||||
print('Unknown message type: ${data['type']}');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error parsing WebSocket message: $e');
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (kDebugMode) {
|
||||
print('WebSocket error: $error');
|
||||
}
|
||||
_connectionStatusController.add('Error');
|
||||
_scheduleReconnect();
|
||||
},
|
||||
onDone: () {
|
||||
if (kDebugMode) {
|
||||
print('WebSocket connection closed');
|
||||
}
|
||||
_connectionStatusController.add('Disconnected');
|
||||
_scheduleReconnect();
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to connect: $e');
|
||||
}
|
||||
_connectionStatusController.add('Error');
|
||||
_isConnecting = false;
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(const Duration(seconds: 5), () {
|
||||
if (kDebugMode) {
|
||||
print('Attempting to reconnect...');
|
||||
}
|
||||
connect();
|
||||
});
|
||||
}
|
||||
|
||||
void requestHistory(String range) {
|
||||
if (_channel != null) {
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'request_history',
|
||||
'range': range,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_reconnectTimer?.cancel();
|
||||
_channel?.sink.close();
|
||||
_latestPriceController.close();
|
||||
_connectionStatusController.close();
|
||||
}
|
||||
}
|
||||
344
flutter_app/lib/widgets/alerts_panel.dart
Normal file
344
flutter_app/lib/widgets/alerts_panel.dart
Normal 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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
203
flutter_app/lib/widgets/loading_screen.dart
Normal file
203
flutter_app/lib/widgets/loading_screen.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
60
flutter_app/lib/widgets/price_stats_card.dart
Normal file
60
flutter_app/lib/widgets/price_stats_card.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
329
flutter_app/lib/widgets/vendor_table.dart
Normal file
329
flutter_app/lib/widgets/vendor_table.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user