feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core

This commit is contained in:
2026-08-24 21:37:43 +02:00
parent 676496b77d
commit 0894c40f07
113 changed files with 12413 additions and 3613 deletions
+2 -2
View File
@@ -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();
}
+153 -24
View File
@@ -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();
}
}