feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core
This commit is contained in:
@@ -9,7 +9,7 @@ class ApiClient {
|
||||
Function()? onUnauthorized;
|
||||
|
||||
static String get baseUrl {
|
||||
if (kIsWeb) {
|
||||
if (kIsWeb) {//todo on release
|
||||
final origin = Uri.base.origin;
|
||||
if (origin.isNotEmpty && !origin.contains('null') && !origin.startsWith('file:')) {
|
||||
return origin;
|
||||
@@ -38,7 +38,7 @@ class ApiClient {
|
||||
return handler.next(options);
|
||||
},
|
||||
onError: (DioException error, handler) async {
|
||||
if (error.response?.statusCode == 401) {
|
||||
if (error.response?.statusCode == 401 || error.response?.statusCode == 403) {
|
||||
await _storageService.clearAll();
|
||||
onUnauthorized?.call();
|
||||
}
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import 'dart:async';
|
||||
import 'dart:async';
|
||||
import 'package:finlytic_app/core/network/api_client.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:signalr_core/signalr_core.dart';
|
||||
import '../services/secure_storage_service.dart';
|
||||
|
||||
/// Central Real-Time WebSocket Service utilizing SignalR (`signalr_core`).
|
||||
/// Connects persistently to `/hubs/health` and `/hubs/favorites-prices` WebSockets.
|
||||
/// Connects persistently to `/hubs/health`, `/hubs/favorites-prices`, `/hubs/trade-stream`, `/hubs/logs`, and `/hubs/news`.
|
||||
class SignalRService extends ChangeNotifier {
|
||||
final SecureStorageService storageService;
|
||||
|
||||
HubConnection? _healthConnection;
|
||||
HubConnection? _favoritesConnection;
|
||||
HubConnection? _tradeStreamConnection;
|
||||
HubConnection? _logsConnection;
|
||||
HubConnection? _newsConnection;
|
||||
|
||||
bool _isConnected = false;
|
||||
final _statusController = StreamController<bool>.broadcast();
|
||||
final _healthController = StreamController<List<Map<String, dynamic>>>.broadcast();
|
||||
final _favoritePricesController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _tradeProposalController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _tradeUpdateController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _botPositionController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _portfolioSummaryController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _logMessageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _newsArticleController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
|
||||
bool get isConnected => _isConnected;
|
||||
Stream<bool> get connectionStream => _statusController.stream;
|
||||
Stream<List<Map<String, dynamic>>> get healthStream => _healthController.stream;
|
||||
Stream<Map<String, dynamic>> get favoritePricesStream => _favoritePricesController.stream;
|
||||
Stream<Map<String, dynamic>> get tradeProposalStream => _tradeProposalController.stream;
|
||||
Stream<Map<String, dynamic>> get tradeUpdateStream => _tradeUpdateController.stream;
|
||||
Stream<Map<String, dynamic>> get botPositionStream => _botPositionController.stream;
|
||||
Stream<Map<String, dynamic>> get portfolioSummaryStream => _portfolioSummaryController.stream;
|
||||
Stream<Map<String, dynamic>> get logMessageStream => _logMessageController.stream;
|
||||
Stream<Map<String, dynamic>> get newsArticleStream => _newsArticleController.stream;
|
||||
|
||||
static String get baseUrl => ApiClient.baseUrl;
|
||||
|
||||
@@ -30,14 +45,18 @@ class SignalRService extends ChangeNotifier {
|
||||
if (_isConnected) return;
|
||||
|
||||
try {
|
||||
final token = await storageService.getToken();
|
||||
// Reads the token fresh from secure storage on every connection attempt
|
||||
// (initial connect AND every automatic reconnect), so a token refreshed
|
||||
// mid-session is always picked up instead of being pinned to the value
|
||||
// read at initSignalR() time.
|
||||
Future<String?> tokenFactory() => storageService.getToken();
|
||||
|
||||
// 1. Connect SystemHealthHub over WebSockets
|
||||
_healthConnection = HubConnectionBuilder()
|
||||
.withUrl(
|
||||
'$baseUrl/hubs/health',
|
||||
HttpConnectionOptions(
|
||||
accessTokenFactory: () async => token,
|
||||
accessTokenFactory: tokenFactory,
|
||||
transport: HttpTransportType.webSockets,
|
||||
logging: (level, message) {
|
||||
if (kDebugMode) debugPrint('[SignalR Health WS] $message');
|
||||
@@ -59,19 +78,12 @@ class SignalRService extends ChangeNotifier {
|
||||
}
|
||||
});
|
||||
|
||||
_healthConnection!.onclose((error) {
|
||||
if (kDebugMode) debugPrint('[SignalR Health WS] Closed: $error');
|
||||
});
|
||||
|
||||
await _healthConnection!.start();
|
||||
if (kDebugMode) debugPrint('[SignalR Health WS] Connected via WebSocket to /hubs/health.');
|
||||
|
||||
// 2. Connect FavoritesPriceHub over WebSockets
|
||||
_favoritesConnection = HubConnectionBuilder()
|
||||
.withUrl(
|
||||
'$baseUrl/hubs/favorites-prices',
|
||||
HttpConnectionOptions(
|
||||
accessTokenFactory: () async => token,
|
||||
accessTokenFactory: tokenFactory,
|
||||
transport: HttpTransportType.webSockets,
|
||||
logging: (level, message) {
|
||||
if (kDebugMode) debugPrint('[SignalR Favorites WS] $message');
|
||||
@@ -92,8 +104,126 @@ class SignalRService extends ChangeNotifier {
|
||||
}
|
||||
});
|
||||
|
||||
await _favoritesConnection!.start();
|
||||
if (kDebugMode) debugPrint('[SignalR Favorites WS] Connected via WebSocket to /hubs/favorites-prices.');
|
||||
// 3. Connect TradeStreamHub over WebSockets (Engine & Bot Real-Time Streams)
|
||||
_tradeStreamConnection = HubConnectionBuilder()
|
||||
.withUrl(
|
||||
'$baseUrl/hubs/trade-stream',
|
||||
HttpConnectionOptions(
|
||||
accessTokenFactory: tokenFactory,
|
||||
transport: HttpTransportType.webSockets,
|
||||
logging: (level, message) {
|
||||
if (kDebugMode) debugPrint('[SignalR TradeStream WS] $message');
|
||||
},
|
||||
),
|
||||
)
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
_tradeStreamConnection!.on('ReceiveTradeProposal', (arguments) {
|
||||
if (arguments != null && arguments.isNotEmpty) {
|
||||
try {
|
||||
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||
_tradeProposalController.add(map);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('[SignalR Proposal Parsing Error] $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_tradeStreamConnection!.on('ReceiveTradeUpdate', (arguments) {
|
||||
if (arguments != null && arguments.isNotEmpty) {
|
||||
try {
|
||||
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||
_tradeUpdateController.add(map);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('[SignalR TradeUpdate Parsing Error] $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_tradeStreamConnection!.on('ReceiveBotPositionUpdate', (arguments) {
|
||||
if (arguments != null && arguments.isNotEmpty) {
|
||||
try {
|
||||
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||
_botPositionController.add(map);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('[SignalR BotPosition Parsing Error] $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_tradeStreamConnection!.on('ReceivePortfolioSummary', (arguments) {
|
||||
if (arguments != null && arguments.isNotEmpty) {
|
||||
try {
|
||||
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||
_portfolioSummaryController.add(map);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('[SignalR PortfolioSummary Parsing Error] $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Connect LogStreamHub over WebSockets
|
||||
_logsConnection = HubConnectionBuilder()
|
||||
.withUrl(
|
||||
'$baseUrl/hubs/logs',
|
||||
HttpConnectionOptions(
|
||||
accessTokenFactory: tokenFactory,
|
||||
transport: HttpTransportType.webSockets,
|
||||
logging: (level, message) {
|
||||
if (kDebugMode) debugPrint('[SignalR Logs WS] $message');
|
||||
},
|
||||
),
|
||||
)
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
_logsConnection!.on('ReceiveLogMessage', (arguments) {
|
||||
if (arguments != null && arguments.isNotEmpty) {
|
||||
try {
|
||||
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||
_logMessageController.add(map);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('[SignalR Log Parsing Error] $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Connect NewsHub over WebSockets (Live News Feed)
|
||||
_newsConnection = HubConnectionBuilder()
|
||||
.withUrl(
|
||||
'$baseUrl/hubs/news',
|
||||
HttpConnectionOptions(
|
||||
accessTokenFactory: tokenFactory,
|
||||
transport: HttpTransportType.webSockets,
|
||||
logging: (level, message) {
|
||||
if (kDebugMode) debugPrint('[SignalR News WS] $message');
|
||||
},
|
||||
),
|
||||
)
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
_newsConnection!.on('ReceiveNewArticle', (arguments) {
|
||||
if (arguments != null && arguments.isNotEmpty) {
|
||||
try {
|
||||
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||
_newsArticleController.add(map);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('[SignalR News Parsing Error] $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Future.wait([
|
||||
_healthConnection!.start() ?? Future.value(),
|
||||
_favoritesConnection!.start() ?? Future.value(),
|
||||
_tradeStreamConnection!.start() ?? Future.value(),
|
||||
_logsConnection!.start() ?? Future.value(),
|
||||
_newsConnection!.start() ?? Future.value(),
|
||||
]);
|
||||
|
||||
if (kDebugMode) debugPrint('[SignalR WS] All 5 Real-Time WebSockets successfully connected.');
|
||||
|
||||
_isConnected = true;
|
||||
_statusController.add(true);
|
||||
@@ -106,20 +236,13 @@ class SignalRService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void broadcastHealthUpdate(List<Map<String, dynamic>> healthData) {
|
||||
_healthController.add(healthData);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void broadcastFavoritePrices(Map<String, dynamic> priceMap) {
|
||||
_favoritePricesController.add(priceMap);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void disconnect() async {
|
||||
try {
|
||||
await _healthConnection?.stop();
|
||||
await _favoritesConnection?.stop();
|
||||
await _tradeStreamConnection?.stop();
|
||||
await _logsConnection?.stop();
|
||||
await _newsConnection?.stop();
|
||||
} catch (_) {}
|
||||
_isConnected = false;
|
||||
_statusController.add(false);
|
||||
@@ -132,6 +255,12 @@ class SignalRService extends ChangeNotifier {
|
||||
_statusController.close();
|
||||
_healthController.close();
|
||||
_favoritePricesController.close();
|
||||
_tradeProposalController.close();
|
||||
_tradeUpdateController.close();
|
||||
_botPositionController.close();
|
||||
_portfolioSummaryController.close();
|
||||
_logMessageController.close();
|
||||
_newsArticleController.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user