feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,86 +0,0 @@
|
||||
# FinlyticApp — Architecture Guidelines & Engineering Standards
|
||||
|
||||
Dieses Dokument definiert die verbindlichen Architektur- und Entwicklungsstandards für die **FinlyticApp** (Dart / Flutter Cross-Platform Client für Android, iOS und Web).
|
||||
|
||||
---
|
||||
|
||||
## 1. Grundprinzipien & Dateistruktur
|
||||
|
||||
Das Projekt folgt einer strengen **Feature-Driven Clean Architecture**. Dadurch wird eine vollständige Entkopplung von Benutzeroberfläche, Geschäftslogik und Datenquellen gewährleistet.
|
||||
|
||||
### 📏 Dateigröße & Struktur-Limits
|
||||
* **Maximal 150 bis 200 Zeilen pro Datei:** Wenn eine Datei diese Grenze überschreitet, muss sie in kleinere, fokussierte Einheiten refactored werden (*Single Responsibility Principle*).
|
||||
* **Eine Hauptklasse pro Datei:** Helper-Klassen gehören in eigene Dateien, es sei denn, sie sind `private` und ausschließlich lokal relevant.
|
||||
* **Kompakte `build()`-Methoden:** Die `build()`-Methode dient nur der Anordnung von Sub-Widgets und darf selten länger als 30–40 Zeilen sein.
|
||||
|
||||
### 📁 Verzeichnisstruktur (`lib/`)
|
||||
|
||||
```text
|
||||
lib/
|
||||
├── core/
|
||||
│ ├── network/ # ApiClient, MQTT-Service, WebSockets
|
||||
│ ├── theme/ # Finlytic Dark Glassmorphism, Typography, Colors
|
||||
│ ├── utils/ # Formatierer (Währungen, Prozentangaben, Datum)
|
||||
│ └── widgets/ # App-weit genutzte UI-Komponenten (Buttons, Modals)
|
||||
├── features/
|
||||
│ ├── dashboard/ # Dashboard-Overview, Analytics-Cards
|
||||
│ ├── news/ # Feed, Pagination, Sentiment-Analysen
|
||||
│ ├── favorites/ # Favoriten-Grid, Watchlist
|
||||
│ ├── calendar/ # Corporate Calendar, Earnings, Dividenden
|
||||
│ ├── trades/ # Trade-Signale, Automatische Trades
|
||||
│ ├── admin/ # User-Verwaltung, System-Settings (Admin-Only)
|
||||
│ └── asset_detail/ # Fundamentaldaten, TA-Chart, Finance-Metrics
|
||||
└── main.dart # Entry Point & Service Locator Initialization
|
||||
|
||||
2. Clean Architecture Layering
|
||||
Jedes Feature im features/-Ordner wird intern strikt in drei Layer unterteilt:LayerVerantwortlichkeitErlaubte Abhängigkeiten1. PresentationUI-Komponenten, Screen-Layouts, Consumer von States.Greift nur auf Logic (BLoC/Notifier) zu. Keine direkten API/DB-Calls!2. Domain / LogicBusiness-Logik, State Management, UseCases, Entities.Absolut frei von flutter/material.dart! Nutzt Repositories als Abstraktion.3. DataAPI-Clients, DTOs, Local Caching, MQTT-Stream Handlers.Implementiert Repository-Interfaces aus dem Domain Layer.
|
||||
|
||||
3. Widget-Architektur & Sub-Widget Auslagerung
|
||||
❌ VERBOTEN: Helper-Methoden für Widgets (_buildX())Unter keinen Umständen dürfen Methoden innerhalb von Widget-Klassen definiert werden, die ein Widget zurückgeben:Dart// ❌ FALSCH: Baut keinen eigenen BuildContext/Lifecycle auf und erfordert Rebuilding des gesamten Mutter-Widgets!
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: const Text('Dashboard'),
|
||||
);
|
||||
}
|
||||
✅ PFLICHT: Auslagerung in eigene StatelessWidget KlassenJedes logische Teilsegment der Benutzeroberfläche muss als eigene Klasse ausgegliedert werden:Dart// ✅ KORREKT: Saubere Performance, eigener BuildContext, optimierter Element-Tree
|
||||
class DashboardHeader extends StatelessWidget {
|
||||
const DashboardHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: const Text('Dashboard'),
|
||||
);
|
||||
}
|
||||
}
|
||||
⚡ Performance-Regeln für Widgets:const Konstruktoren: Jedes Sub-Widget muss wenn möglich einen const Konstruktor haben, um unnötige Re-Renders im Widget-Tree zu verhindern.Keine Business-Logik im UI-Widget: Widgets reagieren ausschließlich auf übergebene Daten oder States und leiten Nutzerinteraktionen über Callbacks / BLoC-Events weiter.
|
||||
|
||||
4. Data Classes & Code-Generierung
|
||||
Immutability: Alle Models, DTOs und States müssen unbeeinflussbar (immutable) sein.Freezed & JSON Serializable: Das manuelle Schreiben von fromJson, toJson oder copyWith ist untersagt. Es wird freezed zusammen mit build_runner eingesetzt.Dartimport 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'asset_model.freezed.dart';
|
||||
part 'asset_model.g.dart';
|
||||
|
||||
@freezed
|
||||
class Asset with _$Asset {
|
||||
const factory Asset({
|
||||
required String isin,
|
||||
required String name,
|
||||
required double currentPrice,
|
||||
required String currency,
|
||||
}) = _Asset;
|
||||
|
||||
factory Asset.fromJson(Map<String, dynamic> json) => _$AssetFromJson(json);
|
||||
}
|
||||
5. Responsive & Adaptive Navigation LayoutFinlyticApp wird plattformübergreifend betrieben und muss sich dem Screen-Format anpassen:Mobile Breakpoint (< 800px): Anforderung von Material NavigationBar (Android) / Cupertino Tab Bar (iOS) am unteren Bildschirmrand.Web / Desktop Breakpoint (>= 800px): Automatische Skalierung auf ein linkes Tab-Menü (NavigationRail / Sidebar).Keine starren Dimensionen: Nutzung von LayoutBuilder, Flexible und Expanded, um Überläufe (Pixel Overflow) auf schmalen Displays zu verhindern.6. Linter Standard (analysis_options.yaml)Alle Entwickler müssen die folgenden Linter-Regeln in der analysis_options.yaml einhalten:YAMLlinter:
|
||||
rules:
|
||||
- prefer_const_constructors
|
||||
- prefer_const_declarations
|
||||
- prefer_final_fields
|
||||
- prefer_final_locals
|
||||
- avoid_unnecessary_containers
|
||||
- sizedbox_for_whitespace
|
||||
- use_build_context_synchronously
|
||||
- always_declare_return_types
|
||||
@@ -1,71 +0,0 @@
|
||||
# FinlyticApp (Flutter Application)
|
||||
|
||||
FinlyticApp is the cross-platform mobile and desktop application for the Finlytic Enterprise Financial Intelligence Platform, built with Flutter, BLoC State Management, and Clean Architecture.
|
||||
|
||||
---
|
||||
|
||||
## Clean Architecture Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── core/
|
||||
│ ├── network/ # ApiClient (Dio), SignalRService
|
||||
│ ├── services/ # SecureStorageService
|
||||
│ ├── theme/ # AppTheme (Glassmorphism, Dark Emerald Theme)
|
||||
│ └── widgets/ # GlassContainer, StatusBadge, AssetLogoWidget
|
||||
└── features/
|
||||
├── trades/ # models/, repositories/, bloc/, views/, widgets/
|
||||
├── asset_detail/ # models/, repositories/, bloc/, views/, widgets/
|
||||
├── favorites/ # models/, repositories/, bloc/, views/, widgets/
|
||||
├── auth/ # models/, repositories/, bloc/, views/, widgets/
|
||||
├── admin/ # models/, repositories/, bloc/, views/, widgets/
|
||||
├── calendar/ # models/, repositories/, bloc/, views/, widgets/
|
||||
└── search/ # models/, repositories/, bloc/, views/, widgets/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Features & Modules
|
||||
|
||||
### 1. Trades Module
|
||||
- **`TradeModel`**, **`TradeRepository`**, **`TradeBloc`**: Manages real-time trade signals, entry/exit prices, stop-loss/take-profit, and position closure.
|
||||
- **`TradesFeedScreen`**: Interactive trade cards with live profit/loss indicators.
|
||||
|
||||
### 2. Assets & Technical Analysis Module
|
||||
- **`AssetModel`**, **`FundamentalDataModel`**, **`TechnicalAnalysisModel`**, **`AssetRepository`**, **`AssetDetailBloc`**.
|
||||
- **`AssetDetailScreen`** & **`TechnicalAnalysisTabView`**: Interactive chart indicators (EMA 20, SMA 50, SMA 200, Supertrend, RSI, MACD).
|
||||
|
||||
### 3. Favorites Module
|
||||
- **`FavoriteAssetModel`**, **`FavoritesRepository`**, **`FavoritesBloc`**.
|
||||
- **`FavoritesScreen`** & **`WatchlistCard`**: Real-time price tracking and watchlist management.
|
||||
|
||||
### 4. Auth Module
|
||||
- **`UserModel`** (with `Equatable`), **`AuthRepository`**, **`AuthBloc`**.
|
||||
- **`LoginScreen`**, **`RegisterScreen`**, **`ForgotPasswordDialog`**: Secure JWT authentication and persistent local session storage.
|
||||
|
||||
### 5. Admin Module
|
||||
- **`AdminUserModel`**, **`AdminRepository`**, **`AdminBloc`**.
|
||||
- **`AdminUsersScreen`**: User role management and AI pipeline cutoff settings.
|
||||
|
||||
### 6. Calendar Module
|
||||
- **`CorporateEventModel`**, **`CalendarRepository`**, **`CalendarBloc`**.
|
||||
- **`CorporateCalendarScreen`**: Monthly calendar grid, date filter, and category chips (Earnings, ExDividend, Payout).
|
||||
|
||||
### 7. Search Module
|
||||
- **`SearchResultModel`**, **`SearchRepository`**, **`SearchBloc`** (with Debounce).
|
||||
- **`AssetSearchDialog`**: Reactive omnibox asset search with ISIN lookup and instant logo resolution.
|
||||
|
||||
---
|
||||
|
||||
## Feature Status
|
||||
|
||||
### Implemented Features
|
||||
- [x] Full Clean Architecture migration across all 7 feature modules (`models/`, `repositories/`, `bloc/`).
|
||||
- [x] Strongly typed data models extending `Equatable` for zero redundant widget rebuilds.
|
||||
- [x] Complete removal of all mock/demo fallbacks in repositories and screens.
|
||||
- [x] SignalR WebSocket integration (`NewsHub`, `TradeHub`).
|
||||
- [x] Premium glassmorphism dark mode design system (`AppTheme`).
|
||||
|
||||
### Planned Features
|
||||
- [ ] Push Notifications integration via Firebase Cloud Messaging for instant trade alerts.
|
||||
- [ ] Biometric Authentication (FaceID / TouchID / Fingerprint) unlock.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../repositories/admin_repository.dart';
|
||||
import 'admin_evaluation_history_event.dart';
|
||||
import 'admin_evaluation_history_state.dart';
|
||||
|
||||
export 'admin_evaluation_history_event.dart';
|
||||
export 'admin_evaluation_history_state.dart';
|
||||
|
||||
class AdminEvaluationHistoryBloc extends Bloc<AdminEvaluationHistoryEvent, AdminEvaluationHistoryState> {
|
||||
final AdminRepository repository;
|
||||
|
||||
AdminEvaluationHistoryBloc({required this.repository}) : super(AdminEvaluationHistoryInitial()) {
|
||||
on<FetchEvaluationHistory>(_onFetch);
|
||||
}
|
||||
|
||||
Future<void> _onFetch(FetchEvaluationHistory event, Emitter<AdminEvaluationHistoryState> emit) async {
|
||||
emit(AdminEvaluationHistoryLoading());
|
||||
try {
|
||||
final response = await repository.fetchEvaluationHistory(
|
||||
fromUtc: event.fromUtc,
|
||||
toUtc: event.toUtc,
|
||||
outcome: event.outcome,
|
||||
triggerSource: event.triggerSource,
|
||||
search: event.search,
|
||||
page: event.page,
|
||||
pageSize: event.pageSize,
|
||||
);
|
||||
emit(AdminEvaluationHistoryLoaded(response: response, page: event.page, pageSize: event.pageSize));
|
||||
} catch (e) {
|
||||
emit(AdminEvaluationHistoryError(e.toString().replaceFirst('Exception: ', '')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../models/evaluation_history_enums.dart';
|
||||
|
||||
abstract class AdminEvaluationHistoryEvent extends Equatable {
|
||||
const AdminEvaluationHistoryEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Fetches (or re-fetches) one page of evaluation history for the given filter
|
||||
/// set. There is deliberately no separate "change page" event — every fetch is
|
||||
/// a full filter snapshot, so the bloc never has to guess which filters were
|
||||
/// active on a previously-loaded page when the caller asks for the next one.
|
||||
class FetchEvaluationHistory extends AdminEvaluationHistoryEvent {
|
||||
final DateTime? fromUtc;
|
||||
final DateTime? toUtc;
|
||||
final OutcomeReason? outcome;
|
||||
final TriggerSource? triggerSource;
|
||||
final String? search;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
const FetchEvaluationHistory({
|
||||
this.fromUtc,
|
||||
this.toUtc,
|
||||
this.outcome,
|
||||
this.triggerSource,
|
||||
this.search,
|
||||
this.page = 1,
|
||||
this.pageSize = 50,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [fromUtc, toUtc, outcome, triggerSource, search, page, pageSize];
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../models/evaluation_history_response_model.dart';
|
||||
|
||||
abstract class AdminEvaluationHistoryState extends Equatable {
|
||||
const AdminEvaluationHistoryState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class AdminEvaluationHistoryInitial extends AdminEvaluationHistoryState {}
|
||||
|
||||
class AdminEvaluationHistoryLoading extends AdminEvaluationHistoryState {}
|
||||
|
||||
class AdminEvaluationHistoryLoaded extends AdminEvaluationHistoryState {
|
||||
final EvaluationHistoryResponseModel response;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
const AdminEvaluationHistoryLoaded({
|
||||
required this.response,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
});
|
||||
|
||||
bool get hasPreviousPage => page > 1;
|
||||
|
||||
bool get hasNextPage => page * pageSize < response.totalCount;
|
||||
|
||||
int get rangeStart => response.totalCount == 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
|
||||
int get rangeEnd {
|
||||
final end = page * pageSize;
|
||||
return end > response.totalCount ? response.totalCount : end;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [response, page, pageSize];
|
||||
}
|
||||
|
||||
class AdminEvaluationHistoryError extends AdminEvaluationHistoryState {
|
||||
final String message;
|
||||
|
||||
const AdminEvaluationHistoryError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'evaluation_history_enums.dart';
|
||||
|
||||
/// Typed mirror of `FinlyticCore.Dtos.Trading.EvaluationHistoryEntryDto` — one row
|
||||
/// of `GET /api/v1/admin/evaluations`. Every score field is the real,
|
||||
/// already-computed value the server persisted (including the honest 0/default
|
||||
/// values recorded for [OutcomeReason.noTechnicalSetups]) — nothing here is
|
||||
/// fabricated client-side (Rules.md §4).
|
||||
class EvaluationHistoryEntryModel extends Equatable {
|
||||
final String id;
|
||||
final String isin;
|
||||
final String symbol;
|
||||
final double technicalScore;
|
||||
final double sentimentScore;
|
||||
final double fundamentalScore;
|
||||
final double compositeOpportunityScore;
|
||||
final double reliabilityBonus;
|
||||
final bool passedEarningsLockout;
|
||||
final int? daysToNextEarnings;
|
||||
final bool passedDividendGate;
|
||||
final int? daysToNextExDividend;
|
||||
final UniverseSource? universeSource;
|
||||
final DateTime? universeEnteredAtUtc;
|
||||
final bool passedSimulationVeto;
|
||||
final bool passedAiValidation;
|
||||
final String aiThesisSummary;
|
||||
final OutcomeReason outcomeReason;
|
||||
final TriggerSource triggerSource;
|
||||
final String? triggeredByUserId;
|
||||
final String? proposalId;
|
||||
final DateTime evaluatedAtUtc;
|
||||
|
||||
const EvaluationHistoryEntryModel({
|
||||
required this.id,
|
||||
required this.isin,
|
||||
required this.symbol,
|
||||
required this.technicalScore,
|
||||
required this.sentimentScore,
|
||||
required this.fundamentalScore,
|
||||
required this.compositeOpportunityScore,
|
||||
required this.reliabilityBonus,
|
||||
required this.passedEarningsLockout,
|
||||
this.daysToNextEarnings,
|
||||
required this.passedDividendGate,
|
||||
this.daysToNextExDividend,
|
||||
this.universeSource,
|
||||
this.universeEnteredAtUtc,
|
||||
required this.passedSimulationVeto,
|
||||
required this.passedAiValidation,
|
||||
required this.aiThesisSummary,
|
||||
required this.outcomeReason,
|
||||
required this.triggerSource,
|
||||
this.triggeredByUserId,
|
||||
this.proposalId,
|
||||
required this.evaluatedAtUtc,
|
||||
});
|
||||
|
||||
/// True exactly when this evaluation resulted in a trade proposal.
|
||||
bool get hasProposal => proposalId != null && proposalId!.isNotEmpty;
|
||||
|
||||
factory EvaluationHistoryEntryModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic val) {
|
||||
if (val == null) return 0.0;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
DateTime parseDate(dynamic val) {
|
||||
if (val == null) return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
return DateTime.tryParse(val.toString())?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
}
|
||||
|
||||
return EvaluationHistoryEntryModel(
|
||||
id: json['id']?.toString() ?? '',
|
||||
isin: json['isin']?.toString() ?? '',
|
||||
symbol: json['symbol']?.toString() ?? '',
|
||||
technicalScore: parseDbl(json['technicalScore']),
|
||||
sentimentScore: parseDbl(json['sentimentScore']),
|
||||
fundamentalScore: parseDbl(json['fundamentalScore']),
|
||||
compositeOpportunityScore: parseDbl(json['compositeOpportunityScore']),
|
||||
reliabilityBonus: parseDbl(json['reliabilityBonus']),
|
||||
passedEarningsLockout: json['passedEarningsLockout'] == true,
|
||||
daysToNextEarnings: json['daysToNextEarnings'] is num ? (json['daysToNextEarnings'] as num).toInt() : null,
|
||||
passedDividendGate: json['passedDividendGate'] == true,
|
||||
daysToNextExDividend: json['daysToNextExDividend'] is num ? (json['daysToNextExDividend'] as num).toInt() : null,
|
||||
universeSource: UniverseSource.fromJson(json['universeSource']?.toString()),
|
||||
universeEnteredAtUtc: json['universeEnteredAtUtc'] == null ? null : parseDate(json['universeEnteredAtUtc']),
|
||||
passedSimulationVeto: json['passedSimulationVeto'] == true,
|
||||
passedAiValidation: json['passedAiValidation'] == true,
|
||||
aiThesisSummary: json['aiThesisSummary']?.toString() ?? '',
|
||||
outcomeReason: OutcomeReason.fromJson(json['outcomeReason']?.toString()),
|
||||
triggerSource: TriggerSource.fromJson(json['triggerSource']?.toString()),
|
||||
triggeredByUserId: json['triggeredByUserId']?.toString(),
|
||||
proposalId: json['proposalId']?.toString(),
|
||||
evaluatedAtUtc: parseDate(json['evaluatedAtUtc']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
isin,
|
||||
symbol,
|
||||
technicalScore,
|
||||
sentimentScore,
|
||||
fundamentalScore,
|
||||
compositeOpportunityScore,
|
||||
reliabilityBonus,
|
||||
passedEarningsLockout,
|
||||
daysToNextEarnings,
|
||||
passedDividendGate,
|
||||
daysToNextExDividend,
|
||||
universeSource,
|
||||
universeEnteredAtUtc,
|
||||
passedSimulationVeto,
|
||||
passedAiValidation,
|
||||
aiThesisSummary,
|
||||
outcomeReason,
|
||||
triggerSource,
|
||||
triggeredByUserId,
|
||||
proposalId,
|
||||
evaluatedAtUtc,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
|
||||
/// Mirrors `FinlyticCore.Dtos.Trading.OutcomeReason` (`TradeEnums.cs`), which the
|
||||
/// `/api/v1/admin/evaluations` endpoint serializes as a `JsonStringEnumConverter`
|
||||
/// string using the exact C# member name (e.g. `"Approved"`, `"BelowScoreThreshold"`).
|
||||
///
|
||||
/// [unknown] is the fallback both for the server's own `Unknown = 0` default (an
|
||||
/// honest "we don't know" rather than a fabricated reason, Rules.md §4) and for any
|
||||
/// future server-side member this client doesn't recognize yet.
|
||||
enum OutcomeReason {
|
||||
unknown,
|
||||
approved,
|
||||
belowScoreThreshold,
|
||||
earningsLockout,
|
||||
simulationVeto,
|
||||
aiRejected,
|
||||
noTechnicalSetups,
|
||||
duplicateActiveProposal,
|
||||
dividendGate;
|
||||
|
||||
static OutcomeReason fromJson(String? raw) {
|
||||
switch (raw) {
|
||||
case 'Approved':
|
||||
return OutcomeReason.approved;
|
||||
case 'BelowScoreThreshold':
|
||||
return OutcomeReason.belowScoreThreshold;
|
||||
case 'EarningsLockout':
|
||||
return OutcomeReason.earningsLockout;
|
||||
case 'SimulationVeto':
|
||||
return OutcomeReason.simulationVeto;
|
||||
case 'AiRejected':
|
||||
return OutcomeReason.aiRejected;
|
||||
case 'NoTechnicalSetups':
|
||||
return OutcomeReason.noTechnicalSetups;
|
||||
case 'DuplicateActiveProposal':
|
||||
return OutcomeReason.duplicateActiveProposal;
|
||||
case 'DividendGate':
|
||||
return OutcomeReason.dividendGate;
|
||||
case 'Unknown':
|
||||
default:
|
||||
return OutcomeReason.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact server-side enum member name. `[FromQuery] OutcomeReason?` on
|
||||
/// `AdminEvaluationHistoryController` model-binds a bare enum member name from
|
||||
/// the query string (ASP.NET Core's default `Enum.TryParse`-based binder), not a
|
||||
/// JSON string — so this is what must be sent back as the `outcome` filter value.
|
||||
String toApiValue() {
|
||||
switch (this) {
|
||||
case OutcomeReason.approved:
|
||||
return 'Approved';
|
||||
case OutcomeReason.belowScoreThreshold:
|
||||
return 'BelowScoreThreshold';
|
||||
case OutcomeReason.earningsLockout:
|
||||
return 'EarningsLockout';
|
||||
case OutcomeReason.simulationVeto:
|
||||
return 'SimulationVeto';
|
||||
case OutcomeReason.aiRejected:
|
||||
return 'AiRejected';
|
||||
case OutcomeReason.noTechnicalSetups:
|
||||
return 'NoTechnicalSetups';
|
||||
case OutcomeReason.duplicateActiveProposal:
|
||||
return 'DuplicateActiveProposal';
|
||||
case OutcomeReason.dividendGate:
|
||||
return 'DividendGate';
|
||||
case OutcomeReason.unknown:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case OutcomeReason.approved:
|
||||
return 'Freigegeben';
|
||||
case OutcomeReason.belowScoreThreshold:
|
||||
return 'Score zu niedrig';
|
||||
case OutcomeReason.earningsLockout:
|
||||
return 'Earnings-Sperre';
|
||||
case OutcomeReason.simulationVeto:
|
||||
return 'Simulation-Veto';
|
||||
case OutcomeReason.aiRejected:
|
||||
return 'KI abgelehnt';
|
||||
case OutcomeReason.noTechnicalSetups:
|
||||
return 'Kein Setup';
|
||||
case OutcomeReason.duplicateActiveProposal:
|
||||
return 'Bereits aktiver Vorschlag';
|
||||
case OutcomeReason.dividendGate:
|
||||
return 'Dividend-Sperre';
|
||||
case OutcomeReason.unknown:
|
||||
return 'Unbekannt';
|
||||
}
|
||||
}
|
||||
|
||||
/// Color-coding for the history-list badge, reusing only colors already
|
||||
/// established elsewhere in the app (`AppTheme.primaryEmerald`/`accentRed` plus
|
||||
/// the `Colors.amber`/`Colors.purpleAccent` already used by
|
||||
/// `EvaluationScoreBreakdownSheet`) rather than introducing a new palette.
|
||||
Color get color {
|
||||
switch (this) {
|
||||
case OutcomeReason.approved:
|
||||
return AppTheme.primaryEmerald;
|
||||
case OutcomeReason.aiRejected:
|
||||
case OutcomeReason.simulationVeto:
|
||||
return AppTheme.accentRed;
|
||||
case OutcomeReason.belowScoreThreshold:
|
||||
case OutcomeReason.earningsLockout:
|
||||
case OutcomeReason.duplicateActiveProposal:
|
||||
case OutcomeReason.dividendGate:
|
||||
return Colors.amber;
|
||||
case OutcomeReason.noTechnicalSetups:
|
||||
case OutcomeReason.unknown:
|
||||
return AppTheme.textMuted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors `FinlyticCore.Dtos.Trading.TriggerSource`.
|
||||
enum TriggerSource {
|
||||
unknown,
|
||||
automatic,
|
||||
manual;
|
||||
|
||||
static TriggerSource fromJson(String? raw) {
|
||||
switch (raw) {
|
||||
case 'Automatic':
|
||||
return TriggerSource.automatic;
|
||||
case 'Manual':
|
||||
return TriggerSource.manual;
|
||||
case 'Unknown':
|
||||
default:
|
||||
return TriggerSource.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
String toApiValue() {
|
||||
switch (this) {
|
||||
case TriggerSource.automatic:
|
||||
return 'Automatic';
|
||||
case TriggerSource.manual:
|
||||
return 'Manual';
|
||||
case TriggerSource.unknown:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case TriggerSource.automatic:
|
||||
return 'Automatisch';
|
||||
case TriggerSource.manual:
|
||||
return 'Manuell';
|
||||
case TriggerSource.unknown:
|
||||
return 'Unbekannt';
|
||||
}
|
||||
}
|
||||
|
||||
Color get color {
|
||||
switch (this) {
|
||||
case TriggerSource.automatic:
|
||||
return AppTheme.accentCyan;
|
||||
case TriggerSource.manual:
|
||||
return Colors.purpleAccent;
|
||||
case TriggerSource.unknown:
|
||||
return AppTheme.textMuted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors `FinlyticCore.Dtos.TechnicalAnalysis.UniverseSource` - which recurring
|
||||
/// FinlyticTechnicals selection mechanism added the ISIN to the continuously
|
||||
/// scanned universe before this evaluation ran. `null` on the Dart side (not
|
||||
/// modeled as its own enum value here) means the evaluation happened outside
|
||||
/// that universe entirely (e.g. a manual "Analyze now" call).
|
||||
enum UniverseSource {
|
||||
sentimentSpike,
|
||||
userFavorite,
|
||||
discovery;
|
||||
|
||||
static UniverseSource? fromJson(String? raw) {
|
||||
switch (raw) {
|
||||
case 'SentimentSpike':
|
||||
return UniverseSource.sentimentSpike;
|
||||
case 'UserFavorite':
|
||||
return UniverseSource.userFavorite;
|
||||
case 'Discovery':
|
||||
return UniverseSource.discovery;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case UniverseSource.sentimentSpike:
|
||||
return 'Sentiment-Spike';
|
||||
case UniverseSource.userFavorite:
|
||||
return 'Nutzer-Favorit';
|
||||
case UniverseSource.discovery:
|
||||
return 'Discovery-Liste';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'evaluation_history_entry_model.dart';
|
||||
import 'evaluation_history_summary_model.dart';
|
||||
|
||||
/// Typed mirror of `FinlyticCore.Dtos.Trading.GetEvaluationHistoryResponse` — the
|
||||
/// full response body of `GET /api/v1/admin/evaluations`.
|
||||
class EvaluationHistoryResponseModel extends Equatable {
|
||||
final int totalCount;
|
||||
final List<EvaluationHistoryEntryModel> entries;
|
||||
final EvaluationHistorySummaryModel summary;
|
||||
|
||||
const EvaluationHistoryResponseModel({
|
||||
required this.totalCount,
|
||||
required this.entries,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
factory EvaluationHistoryResponseModel.empty() => EvaluationHistoryResponseModel(
|
||||
totalCount: 0,
|
||||
entries: const [],
|
||||
summary: EvaluationHistorySummaryModel.empty(),
|
||||
);
|
||||
|
||||
factory EvaluationHistoryResponseModel.fromJson(Map<String, dynamic> json) {
|
||||
final rawEntries = json['entries'];
|
||||
final entries = rawEntries is List
|
||||
? rawEntries.whereType<Map<String, dynamic>>().map(EvaluationHistoryEntryModel.fromJson).toList()
|
||||
: <EvaluationHistoryEntryModel>[];
|
||||
|
||||
final rawSummary = json['summary'];
|
||||
final summary = rawSummary is Map<String, dynamic>
|
||||
? EvaluationHistorySummaryModel.fromJson(rawSummary)
|
||||
: EvaluationHistorySummaryModel.empty();
|
||||
|
||||
return EvaluationHistoryResponseModel(
|
||||
totalCount: json['totalCount'] is num ? (json['totalCount'] as num).toInt() : 0,
|
||||
entries: entries,
|
||||
summary: summary,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [totalCount, entries, summary];
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'evaluation_history_enums.dart';
|
||||
|
||||
/// Typed mirror of `FinlyticCore.Dtos.Trading.OutcomeReasonCountDto`.
|
||||
class OutcomeReasonCountModel extends Equatable {
|
||||
final OutcomeReason outcomeReason;
|
||||
final int count;
|
||||
|
||||
const OutcomeReasonCountModel({required this.outcomeReason, required this.count});
|
||||
|
||||
factory OutcomeReasonCountModel.fromJson(Map<String, dynamic> json) {
|
||||
return OutcomeReasonCountModel(
|
||||
outcomeReason: OutcomeReason.fromJson(json['outcomeReason']?.toString()),
|
||||
count: json['count'] is num ? (json['count'] as num).toInt() : 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [outcomeReason, count];
|
||||
}
|
||||
|
||||
/// Typed mirror of `FinlyticCore.Dtos.Trading.EvaluationHistorySummaryDto` — the
|
||||
/// pre-aggregated headline numbers for the admin evaluation-history tab. Every
|
||||
/// field except [lastProposalCreatedAtUtc] is scoped to the same filters as the
|
||||
/// paginated entry list it accompanies; [lastProposalCreatedAtUtc] deliberately
|
||||
/// ignores the from/to filters (see the server-side DTO doc comment) so the admin
|
||||
/// always sees "how long since the last real proposal" regardless of which
|
||||
/// historical window is currently selected.
|
||||
class EvaluationHistorySummaryModel extends Equatable {
|
||||
final int totalEvaluations;
|
||||
final List<OutcomeReasonCountModel> countsByOutcome;
|
||||
final double averageCompositeScore;
|
||||
final int proposalsCreated;
|
||||
final DateTime? lastProposalCreatedAtUtc;
|
||||
|
||||
const EvaluationHistorySummaryModel({
|
||||
required this.totalEvaluations,
|
||||
required this.countsByOutcome,
|
||||
required this.averageCompositeScore,
|
||||
required this.proposalsCreated,
|
||||
this.lastProposalCreatedAtUtc,
|
||||
});
|
||||
|
||||
int countFor(OutcomeReason reason) {
|
||||
for (final c in countsByOutcome) {
|
||||
if (c.outcomeReason == reason) return c.count;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
factory EvaluationHistorySummaryModel.empty() => const EvaluationHistorySummaryModel(
|
||||
totalEvaluations: 0,
|
||||
countsByOutcome: [],
|
||||
averageCompositeScore: 0,
|
||||
proposalsCreated: 0,
|
||||
lastProposalCreatedAtUtc: null,
|
||||
);
|
||||
|
||||
factory EvaluationHistorySummaryModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic val) {
|
||||
if (val == null) return 0.0;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
final rawCounts = json['countsByOutcome'];
|
||||
final counts = rawCounts is List
|
||||
? rawCounts.whereType<Map<String, dynamic>>().map(OutcomeReasonCountModel.fromJson).toList()
|
||||
: <OutcomeReasonCountModel>[];
|
||||
|
||||
final rawLast = json['lastProposalCreatedAtUtc'];
|
||||
|
||||
return EvaluationHistorySummaryModel(
|
||||
totalEvaluations: json['totalEvaluations'] is num ? (json['totalEvaluations'] as num).toInt() : 0,
|
||||
countsByOutcome: counts,
|
||||
averageCompositeScore: parseDbl(json['averageCompositeScore']),
|
||||
proposalsCreated: json['proposalsCreated'] is num ? (json['proposalsCreated'] as num).toInt() : 0,
|
||||
lastProposalCreatedAtUtc: rawLast != null ? DateTime.tryParse(rawLast.toString())?.toUtc() : null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [totalEvaluations, countsByOutcome, averageCompositeScore, proposalsCreated, lastProposalCreatedAtUtc];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Typed mirror of the fields the admin UI needs from
|
||||
/// `FinlyticCore.Dtos.TechnicalAnalysis.StrategyResultDto`, as returned by
|
||||
/// `GET /api/v1/admin/evaluations/watchlist/{isin}/history` — the last N
|
||||
/// technical-analysis setups computed for one ISIN, most recent first, so the
|
||||
/// score trend (improving/worsening, and whether it ever cleared the engine's
|
||||
/// top-pick bar) is visible even for setups too weak to ever reach the engine.
|
||||
class RecentSetupModel extends Equatable {
|
||||
final String strategyName;
|
||||
final double qualityScore;
|
||||
final bool isTopPick;
|
||||
final String rating;
|
||||
final DateTime createdAt;
|
||||
|
||||
const RecentSetupModel({
|
||||
required this.strategyName,
|
||||
required this.qualityScore,
|
||||
required this.isTopPick,
|
||||
required this.rating,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory RecentSetupModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic val) {
|
||||
if (val == null) return 0.0;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
return RecentSetupModel(
|
||||
strategyName: json['strategyName']?.toString() ?? '',
|
||||
qualityScore: parseDbl(json['qualityScore']),
|
||||
isTopPick: json['isTopPick'] == true,
|
||||
rating: json['rating']?.toString() ?? '',
|
||||
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '')?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [strategyName, qualityScore, isTopPick, rating, createdAt];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'evaluation_history_enums.dart';
|
||||
|
||||
/// Typed mirror of `FinlyticCore.Dtos.TechnicalAnalysis.WatchlistEntryDto` — one
|
||||
/// row of `GET /api/v1/admin/evaluations/watchlist`: an asset FinlyticTechnicals'
|
||||
/// background scanner is actually evaluating every cycle, independent of
|
||||
/// whether it has produced any evaluation the engine ever saw.
|
||||
class WatchlistEntryModel extends Equatable {
|
||||
final String isin;
|
||||
final String? symbol;
|
||||
final UniverseSource? source;
|
||||
final int priority;
|
||||
final DateTime addedAtUtc;
|
||||
final DateTime? expiresAtUtc;
|
||||
|
||||
const WatchlistEntryModel({
|
||||
required this.isin,
|
||||
this.symbol,
|
||||
this.source,
|
||||
required this.priority,
|
||||
required this.addedAtUtc,
|
||||
this.expiresAtUtc,
|
||||
});
|
||||
|
||||
factory WatchlistEntryModel.fromJson(Map<String, dynamic> json) {
|
||||
DateTime parseDate(dynamic val) {
|
||||
return DateTime.tryParse(val?.toString() ?? '')?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
}
|
||||
|
||||
return WatchlistEntryModel(
|
||||
isin: json['isin']?.toString() ?? '',
|
||||
symbol: json['symbol']?.toString(),
|
||||
source: UniverseSource.fromJson(json['source']?.toString()),
|
||||
priority: json['priority'] is num ? (json['priority'] as num).toInt() : 0,
|
||||
addedAtUtc: parseDate(json['addedAtUtc']),
|
||||
expiresAtUtc: json['expiresAtUtc'] == null ? null : parseDate(json['expiresAtUtc']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [isin, symbol, source, priority, addedAtUtc, expiresAtUtc];
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:finlytic_app/core/network/api_client.dart';
|
||||
import 'package:finlytic_app/features/admin/models/admin_user_model.dart';
|
||||
import 'package:finlytic_app/features/admin/models/admin_create_user_request_dto.dart';
|
||||
import 'package:finlytic_app/features/admin/models/admin_update_user_request_dto.dart';
|
||||
import 'package:finlytic_app/features/admin/models/evaluation_history_enums.dart';
|
||||
import 'package:finlytic_app/features/admin/models/evaluation_history_response_model.dart';
|
||||
import 'package:finlytic_app/features/admin/models/recent_setup_model.dart';
|
||||
import 'package:finlytic_app/features/admin/models/service_setting_dto.dart';
|
||||
import 'package:finlytic_app/features/admin/models/watchlist_entry_model.dart';
|
||||
|
||||
class AdminRepository {
|
||||
final ApiClient apiClient;
|
||||
@@ -60,4 +65,86 @@ class AdminRepository {
|
||||
throw Exception('Einstellungen konnten nicht gespeichert werden');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches a filtered, paginated page of the evaluation history plus its
|
||||
/// accompanying summary from `GET /api/v1/admin/evaluations`
|
||||
/// (`AdminEvaluationHistoryController`). All filter parameters are optional —
|
||||
/// omitting one means "do not filter on this field", mirroring the server
|
||||
/// contract exactly (`GetEvaluationHistoryRequest`).
|
||||
///
|
||||
/// `[Authorize(Roles = "Admin")]` on the server means a non-admin caller (or an
|
||||
/// expired/invalid token) gets a `401`/`403`, which `ApiClient`'s interceptor
|
||||
/// already turns into an auto-logout (Rules.md §8) before this method's
|
||||
/// `catch` even runs — this method only has to turn the remaining
|
||||
/// error responses (engine unreachable `503`, RPC timeout `502`, unexpected
|
||||
/// `500` — all `ProblemDetails` bodies per the controller) into a readable
|
||||
/// message instead of letting a raw `DioException` reach the UI.
|
||||
Future<EvaluationHistoryResponseModel> fetchEvaluationHistory({
|
||||
DateTime? fromUtc,
|
||||
DateTime? toUtc,
|
||||
OutcomeReason? outcome,
|
||||
TriggerSource? triggerSource,
|
||||
String? search,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
}) async {
|
||||
final query = <String, dynamic>{
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
};
|
||||
if (fromUtc != null) query['fromUtc'] = fromUtc.toUtc().toIso8601String();
|
||||
if (toUtc != null) query['toUtc'] = toUtc.toUtc().toIso8601String();
|
||||
if (outcome != null) query['outcome'] = outcome.toApiValue();
|
||||
if (triggerSource != null) query['triggerSource'] = triggerSource.toApiValue();
|
||||
if (search != null && search.trim().isNotEmpty) query['search'] = search.trim();
|
||||
|
||||
try {
|
||||
final res = await apiClient.get('/api/v1/admin/evaluations', queryParameters: query);
|
||||
if (res.data is Map<String, dynamic>) {
|
||||
return EvaluationHistoryResponseModel.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
return EvaluationHistoryResponseModel.empty();
|
||||
} on DioException catch (e) {
|
||||
final data = e.response?.data;
|
||||
final title = (data is Map && data['title'] is String) ? data['title'] as String : null;
|
||||
throw Exception(title ?? 'Evaluierungs-Historie konnte nicht geladen werden.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches FinlyticTechnicals' current scan universe ("watchlist") from
|
||||
/// `GET /api/v1/admin/evaluations/watchlist` — the assets actually being
|
||||
/// evaluated every cycle in the background, independent of the (filtered)
|
||||
/// evaluation history above.
|
||||
Future<List<WatchlistEntryModel>> fetchWatchlist() async {
|
||||
try {
|
||||
final res = await apiClient.get('/api/v1/admin/evaluations/watchlist');
|
||||
if (res.data is List) {
|
||||
return (res.data as List).whereType<Map<String, dynamic>>().map(WatchlistEntryModel.fromJson).toList();
|
||||
}
|
||||
return [];
|
||||
} on DioException catch (e) {
|
||||
final data = e.response?.data;
|
||||
final title = (data is Map && data['title'] is String) ? data['title'] as String : null;
|
||||
throw Exception(title ?? 'Watchlist konnte nicht geladen werden.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the last [limit] technical-analysis setups computed for [isin]
|
||||
/// (most recent first) from `GET /api/v1/admin/evaluations/watchlist/{isin}/history`.
|
||||
Future<List<RecentSetupModel>> fetchWatchlistEntryHistory(String isin, {int limit = 8}) async {
|
||||
try {
|
||||
final res = await apiClient.get(
|
||||
'/api/v1/admin/evaluations/watchlist/${Uri.encodeComponent(isin)}/history',
|
||||
queryParameters: {'limit': limit},
|
||||
);
|
||||
if (res.data is List) {
|
||||
return (res.data as List).whereType<Map<String, dynamic>>().map(RecentSetupModel.fromJson).toList();
|
||||
}
|
||||
return [];
|
||||
} on DioException catch (e) {
|
||||
final data = e.response?.data;
|
||||
final title = (data is Map && data['title'] is String) ? data['title'] as String : null;
|
||||
throw Exception(title ?? 'Score-Verlauf konnte nicht geladen werden.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../shared/widgets/evaluation_score_breakdown_sheet.dart';
|
||||
import '../bloc/admin_evaluation_history_bloc.dart';
|
||||
import '../models/evaluation_history_entry_model.dart';
|
||||
import '../models/evaluation_history_enums.dart';
|
||||
import '../models/evaluation_history_summary_model.dart';
|
||||
import '../repositories/admin_repository.dart';
|
||||
import '../widgets/evaluation_history_filter_bar.dart';
|
||||
import '../widgets/evaluation_history_kpi_header.dart';
|
||||
import '../widgets/evaluation_history_list_item.dart';
|
||||
|
||||
/// Admin-only tab showing the full history of every asset evaluation the
|
||||
/// engine ever ran — approved or not, automatic or manual — so an admin can
|
||||
/// see directly *why* no new trade proposal appeared instead of having to
|
||||
/// query the database by hand. Backed by `GET /api/v1/admin/evaluations`
|
||||
/// (`AdminEvaluationHistoryController`, `[Authorize(Roles = "Admin")]`).
|
||||
///
|
||||
/// This screen is only ever mounted from `ResponsiveScaffold` behind an
|
||||
/// `if (widget.user.isAdmin)` guard, same as the Bot Panel/Backtest/Admin
|
||||
/// Panel tabs — that guard is UX only, not a security boundary. The real
|
||||
/// boundary is the server-side `[Authorize(Roles = "Admin")]`: if a non-admin
|
||||
/// (or an expired-token admin) somehow still reaches this screen, the 401/403
|
||||
/// response is caught by `ApiClient`'s central interceptor, which clears the
|
||||
/// stored token and triggers auto-logout (Rules.md §8) — the bloc below just
|
||||
/// has to not crash on the `AdminEvaluationHistoryError` that results in the
|
||||
/// meantime, which it doesn't (it renders a normal retryable error state).
|
||||
class AdminEvaluationHistoryScreen extends StatelessWidget {
|
||||
final ApiClient apiClient;
|
||||
|
||||
const AdminEvaluationHistoryScreen({super.key, required this.apiClient});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => AdminEvaluationHistoryBloc(
|
||||
repository: AdminRepository(apiClient: apiClient),
|
||||
)..add(const FetchEvaluationHistory()),
|
||||
child: _AdminEvaluationHistoryScreenContent(apiClient: apiClient),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminEvaluationHistoryScreenContent extends StatefulWidget {
|
||||
final ApiClient apiClient;
|
||||
|
||||
const _AdminEvaluationHistoryScreenContent({required this.apiClient});
|
||||
|
||||
@override
|
||||
State<_AdminEvaluationHistoryScreenContent> createState() => _AdminEvaluationHistoryScreenContentState();
|
||||
}
|
||||
|
||||
class _AdminEvaluationHistoryScreenContentState extends State<_AdminEvaluationHistoryScreenContent> {
|
||||
static const int _pageSize = 50;
|
||||
|
||||
DateTime? _fromUtc;
|
||||
DateTime? _toUtc;
|
||||
OutcomeReason? _outcome;
|
||||
TriggerSource? _triggerSource;
|
||||
String _search = '';
|
||||
int _page = 1;
|
||||
|
||||
void _fetch() {
|
||||
context.read<AdminEvaluationHistoryBloc>().add(FetchEvaluationHistory(
|
||||
fromUtc: _fromUtc,
|
||||
toUtc: _toUtc,
|
||||
outcome: _outcome,
|
||||
triggerSource: _triggerSource,
|
||||
search: _search,
|
||||
page: _page,
|
||||
pageSize: _pageSize,
|
||||
));
|
||||
}
|
||||
|
||||
void _onFilterChanged({
|
||||
required DateTime? fromUtc,
|
||||
required DateTime? toUtc,
|
||||
required OutcomeReason? outcome,
|
||||
required TriggerSource? triggerSource,
|
||||
required String search,
|
||||
}) {
|
||||
setState(() {
|
||||
_fromUtc = fromUtc;
|
||||
_toUtc = toUtc;
|
||||
_outcome = outcome;
|
||||
_triggerSource = triggerSource;
|
||||
_search = search;
|
||||
_page = 1;
|
||||
});
|
||||
_fetch();
|
||||
}
|
||||
|
||||
void _goToPage(int page) {
|
||||
setState(() => _page = page);
|
||||
_fetch();
|
||||
}
|
||||
|
||||
void _showDetail(EvaluationHistoryEntryModel entry) {
|
||||
final approvedLike = entry.outcomeReason == OutcomeReason.approved || entry.passedAiValidation;
|
||||
|
||||
EvaluationScoreBreakdownSheet.show(
|
||||
context,
|
||||
title: '${entry.symbol.isNotEmpty ? entry.symbol : entry.isin} · ${entry.outcomeReason.label}',
|
||||
subtitle: 'Evaluiert am ${_formatFullTimestamp(entry.evaluatedAtUtc)} · Ausgelöst: ${entry.triggerSource.label}.',
|
||||
headerIcon: approvedLike ? Icons.psychology_outlined : Icons.block_outlined,
|
||||
headerColor: approvedLike ? AppTheme.primaryEmerald : entry.outcomeReason.color,
|
||||
compositeScore: entry.compositeOpportunityScore,
|
||||
technicalScore: entry.technicalScore,
|
||||
sentimentScore: entry.sentimentScore,
|
||||
fundamentalScore: entry.fundamentalScore,
|
||||
reliabilityBonus: entry.reliabilityBonus,
|
||||
passedEarningsLockout: entry.passedEarningsLockout,
|
||||
daysToNextEarnings: entry.daysToNextEarnings,
|
||||
passedDividendGate: entry.passedDividendGate,
|
||||
daysToNextExDividend: entry.daysToNextExDividend,
|
||||
universeSourceLabel: entry.universeSource?.label,
|
||||
universeEnteredAtUtc: entry.universeEnteredAtUtc,
|
||||
passedSimulationVeto: entry.passedSimulationVeto,
|
||||
reasoningLabel: entry.passedAiValidation ? 'KI-These' : 'Ablehnungsgrund',
|
||||
reasoningText: entry.aiThesisSummary,
|
||||
footer: entry.hasProposal
|
||||
? Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.rocket_launch_outlined, size: 16, color: AppTheme.primaryEmerald),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Aus dieser Analyse entstand ein Trade-Vorschlag (Proposal-ID: ${entry.proposalId}).',
|
||||
style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 12, height: 1.4, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatFullTimestamp(DateTime utc) {
|
||||
final local = utc.toLocal();
|
||||
final d = local.day.toString().padLeft(2, '0');
|
||||
final m = local.month.toString().padLeft(2, '0');
|
||||
final h = local.hour.toString().padLeft(2, '0');
|
||||
final min = local.minute.toString().padLeft(2, '0');
|
||||
return '$d.$m.${local.year} $h:$min';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Evaluierungs-Historie',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Jede Analyse, jeder Filter, jedes Ergebnis – nachvollziehbar ohne DB-Zugriff.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _fetch,
|
||||
icon: const Icon(Icons.refresh_rounded, color: Colors.white70),
|
||||
tooltip: 'Neu laden',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: BlocBuilder<AdminEvaluationHistoryBloc, AdminEvaluationHistoryState>(
|
||||
builder: (context, state) {
|
||||
final summary = state is AdminEvaluationHistoryLoaded ? state.response.summary : EvaluationHistorySummaryModel.empty();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
EvaluationHistoryKpiHeader(summary: summary, apiClient: widget.apiClient),
|
||||
const SizedBox(height: 16),
|
||||
EvaluationHistoryFilterBar(
|
||||
fromUtc: _fromUtc,
|
||||
toUtc: _toUtc,
|
||||
outcome: _outcome,
|
||||
triggerSource: _triggerSource,
|
||||
search: _search,
|
||||
onChanged: _onFilterChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildBody(state),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(AdminEvaluationHistoryState state) {
|
||||
if (state is AdminEvaluationHistoryLoading || state is AdminEvaluationHistoryInitial) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
child: Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald)),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is AdminEvaluationHistoryError) {
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline_rounded, color: AppTheme.accentRed, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(state.message, style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _fetch,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald, foregroundColor: Colors.black),
|
||||
child: const Text('Erneut versuchen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final loaded = state as AdminEvaluationHistoryLoaded;
|
||||
final entries = loaded.response.entries;
|
||||
|
||||
if (entries.isEmpty) {
|
||||
// Explicit empty state (Rules.md §4) — never a silent blank list, so an
|
||||
// admin who set a narrow filter knows the filter matched nothing rather
|
||||
// than wondering whether the tab itself is broken.
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, size: 44, color: AppTheme.textMuted),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Keine Analysen im gewählten Zeitraum/Filter gefunden.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Server already returns each page sorted by EvaluatedAtUtc descending
|
||||
// (EvaluationHistoryService.GetHistoryAsync: .OrderByDescending(s =>
|
||||
// s.EvaluatedAtUtc)) — rendered in received order, no client re-sort needed.
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
return EvaluationHistoryListItem(entry: entry, onTap: () => _showDetail(entry));
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
loaded.response.totalCount == 0
|
||||
? '0 Einträge'
|
||||
: '${loaded.rangeStart}–${loaded.rangeEnd} von ${loaded.response.totalCount}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: loaded.hasPreviousPage ? () => _goToPage(_page - 1) : null,
|
||||
child: const Text('Zurück'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: loaded.hasNextPage ? () => _goToPage(_page + 1) : null,
|
||||
child: const Text('Weiter'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,11 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
||||
}
|
||||
|
||||
String _formatLabel(String key) {
|
||||
return key
|
||||
// Strip the "Logging.Channel." prefix for display - the section header already says "Logging-Kanäle",
|
||||
// repeating it on every single chip label added visual noise without any extra information.
|
||||
final withoutChannelPrefix = key.startsWith('Logging.Channel.') ? key.substring('Logging.Channel.'.length) : key;
|
||||
|
||||
return withoutChannelPrefix
|
||||
.replaceAll(RegExp(r'(?<!^)(?=[A-Z])'), ' ')
|
||||
.replaceAll('Minutes', '(Minuten)')
|
||||
.replaceAll('Seconds', '(Sekunden)')
|
||||
@@ -143,6 +147,187 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
||||
.replaceAll('Multiplier', 'Multiplikator');
|
||||
}
|
||||
|
||||
/// Groups settings by kind so the settings card reads as organized sections instead of one long,
|
||||
/// unstructured list mixing logging toggles, feature switches, numeric thresholds, and free text together.
|
||||
/// Order is fixed (not alphabetical) so the most-scanned category (logging channels, usually the most
|
||||
/// numerous) sits first.
|
||||
static const List<String> _categoryOrder = ['Logging-Kanäle', 'Umschalter', 'Zahlenwerte', 'Text'];
|
||||
|
||||
String _categoryFor(ServiceSettingDto s) {
|
||||
if (s.key.startsWith('Logging.Channel.')) return 'Logging-Kanäle';
|
||||
|
||||
final type = s.type.toLowerCase();
|
||||
final looksBoolean = type == 'bool' || s.value.toLowerCase() == 'true' || s.value.toLowerCase() == 'false';
|
||||
if (looksBoolean) return 'Umschalter';
|
||||
|
||||
final looksNumeric = type == 'int' || type == 'double' || type == 'number' || type == 'decimal';
|
||||
if (looksNumeric) return 'Zahlenwerte';
|
||||
|
||||
return 'Text';
|
||||
}
|
||||
|
||||
Map<String, List<ServiceSettingDto>> get _groupedSettings {
|
||||
final groups = <String, List<ServiceSettingDto>>{};
|
||||
for (final s in _settings) {
|
||||
groups.putIfAbsent(_categoryFor(s), () => []).add(s);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title, IconData icon) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 15, color: AppTheme.textMuted),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.textMuted, letterSpacing: 0.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Compact toggle "chip" for a single boolean setting - used for logging channels, which can easily number
|
||||
/// a dozen+ per service, so a full-width `SwitchListTile` per entry (the previous, only, layout for every
|
||||
/// setting regardless of category or count) made the card feel "gequetscht"/cramped and pushed the actually
|
||||
/// important numeric settings far down the page.
|
||||
Widget _buildToggleChip(ServiceSettingDto s, TextEditingController controller) {
|
||||
final boolVal = controller.text.toLowerCase() == 'true';
|
||||
|
||||
return Tooltip(
|
||||
message: s.description.isNotEmpty ? s.description : _formatLabel(s.key),
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
textStyle: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => setState(() => controller.text = (!boolVal).toString()),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.15) : AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.5) : AppTheme.glassBorder),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
boolVal ? Icons.check_circle : Icons.circle_outlined,
|
||||
size: 14,
|
||||
color: boolVal ? AppTheme.primaryEmerald : AppTheme.textMuted,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_formatLabel(s.key),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: boolVal ? Colors.white : AppTheme.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSwitchSetting(ServiceSettingDto s, TextEditingController controller) {
|
||||
final boolVal = controller.text.toLowerCase() == 'true';
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.4) : AppTheme.glassBorder),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(_formatLabel(s.key), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
subtitle: s.description.isNotEmpty ? Text(s.description, style: TextStyle(fontSize: 11, color: AppTheme.textMuted)) : null,
|
||||
value: boolVal,
|
||||
activeThumbColor: AppTheme.primaryEmerald,
|
||||
activeTrackColor: AppTheme.primaryEmerald.withValues(alpha: 0.3),
|
||||
onChanged: (val) => setState(() => controller.text = val.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextFieldSetting(ServiceSettingDto s, TextEditingController controller, {required bool isNumeric}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: isNumeric ? const TextInputType.numberWithOptions(decimal: true) : TextInputType.text,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
labelText: _formatLabel(s.key),
|
||||
helperText: s.description.isNotEmpty ? s.description : null,
|
||||
helperMaxLines: 2,
|
||||
prefixIcon: Icon(
|
||||
isNumeric ? Icons.numbers_outlined : Icons.tune_outlined,
|
||||
size: 18,
|
||||
color: AppTheme.primaryEmerald,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildGroupedSettingsSections() {
|
||||
final grouped = _groupedSettings;
|
||||
final widgets = <Widget>[];
|
||||
|
||||
for (final category in _categoryOrder) {
|
||||
final items = grouped[category];
|
||||
if (items == null || items.isEmpty) continue;
|
||||
|
||||
widgets.add(_buildSectionHeader(
|
||||
'$category (${items.length})',
|
||||
switch (category) {
|
||||
'Logging-Kanäle' => Icons.terminal_rounded,
|
||||
'Umschalter' => Icons.toggle_on_outlined,
|
||||
'Zahlenwerte' => Icons.numbers_outlined,
|
||||
_ => Icons.tune_outlined,
|
||||
},
|
||||
));
|
||||
|
||||
if (category == 'Logging-Kanäle') {
|
||||
final chips = <Widget>[];
|
||||
for (final s in items) {
|
||||
final controller = _controllers[s.key];
|
||||
if (controller != null) chips.add(_buildToggleChip(s, controller));
|
||||
}
|
||||
widgets.add(Wrap(spacing: 8, runSpacing: 8, children: chips));
|
||||
} else if (category == 'Umschalter') {
|
||||
for (final s in items) {
|
||||
final controller = _controllers[s.key];
|
||||
if (controller != null) widgets.add(_buildSwitchSetting(s, controller));
|
||||
}
|
||||
} else {
|
||||
for (final s in items) {
|
||||
final controller = _controllers[s.key];
|
||||
if (controller != null) {
|
||||
widgets.add(_buildTextFieldSetting(s, controller, isNumeric: category == 'Zahlenwerte'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
widgets.add(const SizedBox(height: 14));
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -179,66 +364,7 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
||||
if (_settings.isEmpty)
|
||||
const Text('Keine spezifischen Einstellungen gefunden.')
|
||||
else
|
||||
..._settings.map((s) {
|
||||
final key = s.key;
|
||||
final desc = s.description;
|
||||
final type = s.type.toLowerCase();
|
||||
final controller = _controllers[key];
|
||||
if (controller == null) return const SizedBox.shrink();
|
||||
|
||||
final isBoolean = type == 'bool' ||
|
||||
controller.text.toLowerCase() == 'true' ||
|
||||
controller.text.toLowerCase() == 'false';
|
||||
|
||||
if (isBoolean) {
|
||||
final boolVal = controller.text.toLowerCase() == 'true';
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: boolVal
|
||||
? AppTheme.primaryEmerald.withValues(alpha: 0.4)
|
||||
: AppTheme.glassBorder,
|
||||
),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(_formatLabel(key), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
subtitle: desc.isNotEmpty ? Text(desc, style: TextStyle(fontSize: 11, color: AppTheme.textMuted)) : null,
|
||||
value: boolVal,
|
||||
activeThumbColor: AppTheme.primaryEmerald,
|
||||
activeTrackColor: AppTheme.primaryEmerald.withValues(alpha: 0.3),
|
||||
onChanged: (val) => setState(() => controller.text = val.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final isNumeric = type == 'int' || type == 'double' || type == 'number' || type == 'decimal';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: isNumeric
|
||||
? const TextInputType.numberWithOptions(decimal: true)
|
||||
: TextInputType.text,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
labelText: _formatLabel(key),
|
||||
helperText: desc.isNotEmpty ? desc : null,
|
||||
helperMaxLines: 2,
|
||||
prefixIcon: Icon(
|
||||
isNumeric ? Icons.numbers_outlined : Icons.tune_outlined,
|
||||
size: 18,
|
||||
color: AppTheme.primaryEmerald,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
..._buildGroupedSettingsSections(),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
if (_settings.isNotEmpty)
|
||||
@@ -269,18 +395,6 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
||||
serviceName: widget.serviceName,
|
||||
apiClient: widget.apiClient,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Statistiken', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Live-Statistiken werden noch implementiert...', style: TextStyle(fontStyle: FontStyle.italic, color: Colors.white54)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../models/evaluation_history_enums.dart';
|
||||
|
||||
/// Full filter snapshot emitted by [EvaluationHistoryFilterBar.onChanged] on
|
||||
/// every single control change — deliberately not a partial/sparse update, so
|
||||
/// there is no ambiguity between "caller didn't touch this field" and "caller
|
||||
/// explicitly cleared this field" on the receiving end.
|
||||
typedef EvaluationHistoryFilterChanged = void Function({
|
||||
required DateTime? fromUtc,
|
||||
required DateTime? toUtc,
|
||||
required OutcomeReason? outcome,
|
||||
required TriggerSource? triggerSource,
|
||||
required String search,
|
||||
});
|
||||
|
||||
/// Filter bar for the admin evaluation-history tab: a from/to date range (plain
|
||||
/// `showDatePicker` — a full calendar-range widget is overkill for "roughly which
|
||||
/// days"), an [OutcomeReason] dropdown, a [TriggerSource] dropdown, and an
|
||||
/// ISIN/symbol search field. All four map 1:1 onto the server's optional query
|
||||
/// filters (`fromUtc`/`toUtc`/`outcome`/`triggerSource`/`search`).
|
||||
class EvaluationHistoryFilterBar extends StatefulWidget {
|
||||
final DateTime? fromUtc;
|
||||
final DateTime? toUtc;
|
||||
final OutcomeReason? outcome;
|
||||
final TriggerSource? triggerSource;
|
||||
final String search;
|
||||
final EvaluationHistoryFilterChanged onChanged;
|
||||
|
||||
const EvaluationHistoryFilterBar({
|
||||
super.key,
|
||||
required this.fromUtc,
|
||||
required this.toUtc,
|
||||
required this.outcome,
|
||||
required this.triggerSource,
|
||||
required this.search,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EvaluationHistoryFilterBar> createState() => _EvaluationHistoryFilterBarState();
|
||||
}
|
||||
|
||||
class _EvaluationHistoryFilterBarState extends State<EvaluationHistoryFilterBar> {
|
||||
late final TextEditingController _searchController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController = TextEditingController(text: widget.search);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickDate({required bool isFrom}) async {
|
||||
final initial = (isFrom ? widget.fromUtc : widget.toUtc)?.toLocal() ?? DateTime.now();
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initial,
|
||||
firstDate: DateTime(2020, 1, 1),
|
||||
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||
);
|
||||
if (picked == null) return;
|
||||
|
||||
if (isFrom) {
|
||||
_emit(fromUtc: DateTime.utc(picked.year, picked.month, picked.day));
|
||||
} else {
|
||||
// Inclusive upper bound on the whole selected day.
|
||||
_emit(toUtc: DateTime.utc(picked.year, picked.month, picked.day, 23, 59, 59));
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits the full filter snapshot, overriding only the field(s) that
|
||||
/// actually changed and carrying every other field through unchanged from
|
||||
/// `widget.*` — see [EvaluationHistoryFilterChanged].
|
||||
void _emit({
|
||||
Object? fromUtc = _unset,
|
||||
Object? toUtc = _unset,
|
||||
Object? outcome = _unset,
|
||||
Object? triggerSource = _unset,
|
||||
String? search,
|
||||
}) {
|
||||
widget.onChanged(
|
||||
fromUtc: fromUtc == _unset ? widget.fromUtc : fromUtc as DateTime?,
|
||||
toUtc: toUtc == _unset ? widget.toUtc : toUtc as DateTime?,
|
||||
outcome: outcome == _unset ? widget.outcome : outcome as OutcomeReason?,
|
||||
triggerSource: triggerSource == _unset ? widget.triggerSource : triggerSource as TriggerSource?,
|
||||
search: search ?? widget.search,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime? dt) {
|
||||
if (dt == null) return 'Egal';
|
||||
final local = dt.toLocal();
|
||||
return '${local.day.toString().padLeft(2, '0')}.${local.month.toString().padLeft(2, '0')}.${local.year}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onSubmitted: (val) => _emit(search: val),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'ISIN oder Symbol suchen...',
|
||||
prefixIcon: Icon(Icons.search_rounded, color: AppTheme.textMuted),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_emit(search: '');
|
||||
},
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.arrow_forward, size: 18),
|
||||
onPressed: () => _emit(search: _searchController.text),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_dateChip(label: 'Von: ${_formatDate(widget.fromUtc)}', onTap: () => _pickDate(isFrom: true)),
|
||||
_dateChip(label: 'Bis: ${_formatDate(widget.toUtc)}', onTap: () => _pickDate(isFrom: false)),
|
||||
if (widget.fromUtc != null || widget.toUtc != null)
|
||||
TextButton(
|
||||
onPressed: () => _emit(fromUtc: null, toUtc: null),
|
||||
child: const Text('Zeitraum zurücksetzen', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
SizedBox(
|
||||
width: 190,
|
||||
child: DropdownButtonFormField<OutcomeReason?>(
|
||||
initialValue: widget.outcome,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Ergebnis', isDense: true),
|
||||
items: [
|
||||
const DropdownMenuItem<OutcomeReason?>(value: null, child: Text('Alle Ergebnisse')),
|
||||
...OutcomeReason.values.map(
|
||||
(r) => DropdownMenuItem<OutcomeReason?>(value: r, child: Text(r.label)),
|
||||
),
|
||||
],
|
||||
onChanged: (val) => _emit(outcome: val),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 170,
|
||||
child: DropdownButtonFormField<TriggerSource?>(
|
||||
initialValue: widget.triggerSource,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Ausgelöst durch', isDense: true),
|
||||
items: [
|
||||
const DropdownMenuItem<TriggerSource?>(value: null, child: Text('Alle Quellen')),
|
||||
...TriggerSource.values.map(
|
||||
(t) => DropdownMenuItem<TriggerSource?>(value: t, child: Text(t.label)),
|
||||
),
|
||||
],
|
||||
onChanged: (val) => _emit(triggerSource: val),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dateChip({required String label, required VoidCallback onTap}) {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: onTap,
|
||||
icon: Icon(Icons.calendar_today_outlined, size: 14, color: AppTheme.textSecondary),
|
||||
label: Text(label, style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: AppTheme.glassBorder),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentinel distinguishing "caller of [_EvaluationHistoryFilterBarState._emit]
|
||||
/// didn't touch this field" (default) from "caller explicitly passed `null`"
|
||||
/// (clear this field) — needed because `Object?`'s own null is one of the two
|
||||
/// values this default has to be distinguishable from.
|
||||
const Object _unset = Object();
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/time_utils.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../models/evaluation_history_enums.dart';
|
||||
import '../models/evaluation_history_summary_model.dart';
|
||||
import 'watchlist_card.dart';
|
||||
|
||||
/// Headline KPI row for the admin evaluation-history tab: how many analyses ran
|
||||
/// in the current filter window, the outcome breakdown (this is what makes a
|
||||
/// "why are there no new proposals" question answerable at a glance — e.g. most
|
||||
/// assets sitting in [OutcomeReason.belowScoreThreshold]), the average composite
|
||||
/// score, and how long ago the last trade proposal was actually created.
|
||||
///
|
||||
/// Every number here comes straight from `EvaluationHistorySummaryModel`
|
||||
/// (server-aggregated over the same filtered set as the entry list) — nothing is
|
||||
/// computed client-side from the current page alone (Rules.md §4).
|
||||
class EvaluationHistoryKpiHeader extends StatelessWidget {
|
||||
final EvaluationHistorySummaryModel summary;
|
||||
final ApiClient apiClient;
|
||||
|
||||
const EvaluationHistoryKpiHeader({super.key, required this.summary, required this.apiClient});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = MediaQuery.of(context).size.width < 700;
|
||||
|
||||
final lastProposalText = summary.lastProposalCreatedAtUtc == null
|
||||
? 'Noch nie'
|
||||
: TimeUtils.formatRelativeTime(summary.lastProposalCreatedAtUtc!.toIso8601String());
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
isMobile
|
||||
? Column(
|
||||
children: [
|
||||
_kpiCard('Analysen (Filter)', '${summary.totalEvaluations}', Icons.query_stats_rounded, AppTheme.accentCyan),
|
||||
const SizedBox(height: 10),
|
||||
_kpiCard('Ø Composite-Score', summary.averageCompositeScore.toStringAsFixed(1), Icons.speed_rounded, AppTheme.primaryEmerald),
|
||||
const SizedBox(height: 10),
|
||||
_kpiCard(
|
||||
'Letzter Vorschlag',
|
||||
lastProposalText,
|
||||
Icons.rocket_launch_outlined,
|
||||
summary.lastProposalCreatedAtUtc == null ? AppTheme.textMuted : Colors.amber,
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
Expanded(child: _kpiCard('Analysen (Filter)', '${summary.totalEvaluations}', Icons.query_stats_rounded, AppTheme.accentCyan)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _kpiCard(
|
||||
'Ø Composite-Score', summary.averageCompositeScore.toStringAsFixed(1), Icons.speed_rounded, AppTheme.primaryEmerald),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _kpiCard(
|
||||
'Letzter Vorschlag',
|
||||
lastProposalText,
|
||||
Icons.rocket_launch_outlined,
|
||||
summary.lastProposalCreatedAtUtc == null ? AppTheme.textMuted : Colors.amber,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
isMobile
|
||||
? Column(
|
||||
children: [
|
||||
_buildBreakdownCard(),
|
||||
const SizedBox(height: 10),
|
||||
WatchlistCard(apiClient: apiClient),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(flex: 2, child: _buildBreakdownCard()),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: WatchlistCard(apiClient: apiClient)),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBreakdownCard() {
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('AUFSCHLÜSSELUNG NACH GRUND', style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||
const SizedBox(height: 10),
|
||||
summary.totalEvaluations == 0
|
||||
? Text('Keine Analysen im gewählten Filter.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12))
|
||||
: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: OutcomeReason.values
|
||||
.map((reason) => _outcomeChip(reason, summary.countFor(reason)))
|
||||
.where((w) => w != null)
|
||||
.cast<Widget>()
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _outcomeChip(OutcomeReason reason, int count) {
|
||||
if (count == 0) return null;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: reason.color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: reason.color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
'${reason.label}: $count',
|
||||
style: TextStyle(color: reason.color, fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kpiCard(String title, String value, IconData icon, Color color) {
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: color),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(title, style: TextStyle(fontSize: 11, color: AppTheme.textMuted, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(fontSize: 15, color: AppTheme.textPrimary, fontWeight: FontWeight.bold),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../core/widgets/status_badge.dart';
|
||||
import '../models/evaluation_history_entry_model.dart';
|
||||
|
||||
/// One row of the paginated evaluation-history list: symbol/ISIN, timestamp,
|
||||
/// composite score, and color-coded [OutcomeReason]/[TriggerSource] badges.
|
||||
/// Tapping opens the full score/reasoning breakdown via the caller-supplied
|
||||
/// [onTap] (wired to the shared `EvaluationScoreBreakdownSheet` by the screen).
|
||||
class EvaluationHistoryListItem extends StatelessWidget {
|
||||
final EvaluationHistoryEntryModel entry;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const EvaluationHistoryListItem({super.key, required this.entry, required this.onTap});
|
||||
|
||||
String _formatTimestamp(DateTime utc) {
|
||||
final local = utc.toLocal();
|
||||
final d = local.day.toString().padLeft(2, '0');
|
||||
final m = local.month.toString().padLeft(2, '0');
|
||||
final h = local.hour.toString().padLeft(2, '0');
|
||||
final min = local.minute.toString().padLeft(2, '0');
|
||||
return '$d.$m.${local.year} $h:$min';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scoreColor = entry.compositeOpportunityScore >= 70
|
||||
? AppTheme.primaryEmerald
|
||||
: (entry.compositeOpportunityScore >= 40 ? Colors.amber : AppTheme.accentRed);
|
||||
|
||||
return GlassContainer(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(12),
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: Text(
|
||||
entry.compositeOpportunityScore.toStringAsFixed(0),
|
||||
style: TextStyle(color: scoreColor, fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
entry.symbol.isNotEmpty ? entry.symbol : entry.isin,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
if (entry.symbol.isNotEmpty && entry.isin.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(entry.isin, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
],
|
||||
if (entry.hasProposal) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(Icons.link_rounded, size: 13, color: AppTheme.primaryEmerald),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(_formatTimestamp(entry.evaluatedAtUtc), style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
StatusBadge(label: entry.outcomeReason.label, color: entry.outcomeReason.color),
|
||||
const SizedBox(height: 6),
|
||||
StatusBadge(label: entry.triggerSource.label, color: entry.triggerSource.color),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.chevron_right_rounded, color: AppTheme.textMuted, size: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -157,9 +157,33 @@ class _LiveLogConsoleState extends State<LiveLogConsole> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapses the backend's full `Microsoft.Extensions.Logging.LogLevel` names ("Information", "Warning",
|
||||
/// "Trace", "Critical", ...) down to the 4 short codes the filter chips use ("INFO", "WARN", "DEBUG",
|
||||
/// "ERROR"). The filter previously compared `log.level.toUpperCase()` ("INFORMATION") directly against the
|
||||
/// chip value ("INFO") - which never matched anything but "ALL", so selecting any specific level silently
|
||||
/// hid every log line instead of actually filtering.
|
||||
String _normalizeLevel(String level) {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'INFORMATION':
|
||||
case 'INFO':
|
||||
return 'INFO';
|
||||
case 'WARNING':
|
||||
case 'WARN':
|
||||
return 'WARN';
|
||||
case 'ERROR':
|
||||
case 'CRITICAL':
|
||||
return 'ERROR';
|
||||
case 'DEBUG':
|
||||
case 'TRACE':
|
||||
return 'DEBUG';
|
||||
default:
|
||||
return level.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
List<LogMessageDto> get _filteredLogs {
|
||||
return _logs.where((log) {
|
||||
if (_selectedLevel != 'ALL' && log.level.toUpperCase() != _selectedLevel) {
|
||||
if (_selectedLevel != 'ALL' && _normalizeLevel(log.level) != _selectedLevel) {
|
||||
return false;
|
||||
}
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
|
||||
@@ -67,16 +67,9 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
||||
accentColor: Color(0xFFEC4899),
|
||||
),
|
||||
ServiceConfigMeta(
|
||||
key: 'FinlyticAnalyzer',
|
||||
displayName: 'Analyzer Signal Engine',
|
||||
description: 'Scraper Cron-Schedule & Minimaler Signal-Score',
|
||||
icon: Icons.analytics_outlined,
|
||||
accentColor: Color(0xFFF59E0B),
|
||||
),
|
||||
ServiceConfigMeta(
|
||||
key: 'FinlyticTrades',
|
||||
displayName: 'Trade Manager',
|
||||
description: 'ATR Stop-Loss Multiplikator, Risiko-Prozente & Positionen',
|
||||
key: 'FinlyticEngine',
|
||||
displayName: 'Trading Engine',
|
||||
description: 'Strategy Screener, Trade Lifecycle & Risikomanagement',
|
||||
icon: Icons.candlestick_chart_outlined,
|
||||
accentColor: Color(0xFF10B981),
|
||||
),
|
||||
@@ -87,6 +80,13 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
||||
icon: Icons.corporate_fare_outlined,
|
||||
accentColor: Color(0xFF06B6D4),
|
||||
),
|
||||
ServiceConfigMeta(
|
||||
key: 'FinlyticBot',
|
||||
displayName: 'FinlyticBot (Paper)',
|
||||
description: 'Alpaca Paper Trading, Risikomanagement & Sizing Engine',
|
||||
icon: Icons.smart_toy_outlined,
|
||||
accentColor: Color(0xFF10B981),
|
||||
),
|
||||
];
|
||||
|
||||
final Map<String, Map<String, TextEditingController>> _controllers = {
|
||||
@@ -113,24 +113,35 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
||||
'MinConfidenceScore': TextEditingController(text: '0.70'),
|
||||
'MaxBatchSize': TextEditingController(text: '50'),
|
||||
},
|
||||
'FinlyticAnalyzer': {
|
||||
'ScanCronSchedule': TextEditingController(text: '0 */1 * * *'),
|
||||
'MinSignalScore': TextEditingController(text: '75'),
|
||||
'EnableLog_MqttHealthPing': TextEditingController(text: 'false'),
|
||||
'EnableLog_MqttGeneral': TextEditingController(text: 'true'),
|
||||
'EnableLog_AnalyzerAuto': TextEditingController(text: 'true'),
|
||||
'EnableLog_AnalyzerManual': TextEditingController(text: 'true'),
|
||||
'EnableLog_DatabaseOps': TextEditingController(text: 'true'),
|
||||
},
|
||||
'FinlyticTrades': {
|
||||
'AtrStopLossMultiplier': TextEditingController(text: '1.5'),
|
||||
'RiskPerTradePercentage': TextEditingController(text: '1.0'),
|
||||
'MaxOpenPositions': TextEditingController(text: '5'),
|
||||
'FinlyticEngine': {
|
||||
'Engine.MinCompositeScore': TextEditingController(text: '75.0'),
|
||||
'Engine.WeightTechnical': TextEditingController(text: '0.45'),
|
||||
'Engine.WeightSentiment': TextEditingController(text: '0.35'),
|
||||
'Engine.WeightFundamental': TextEditingController(text: '0.20'),
|
||||
'Engine.EarningsLockoutDays': TextEditingController(text: '2'),
|
||||
'Engine.MinDerivativeLeverage': TextEditingController(text: '5.0'),
|
||||
'Engine.TargetDefaultLeverage': TextEditingController(text: '7.0'),
|
||||
'Engine.KnockOutSafetyBufferPercent': TextEditingController(text: '2.0'),
|
||||
'Engine.EnableAiValidation': TextEditingController(text: 'true'),
|
||||
'Engine.EnablePaperTradingBot': TextEditingController(text: 'false'),
|
||||
'Engine.PollingIntervalSeconds': TextEditingController(text: '120'),
|
||||
'Engine.MonitoringIntervalSeconds': TextEditingController(text: '60'),
|
||||
},
|
||||
'FinlyticFundamentals': {
|
||||
'CacheTtlHours': TextEditingController(text: '24'),
|
||||
'EnableYahooFallback': TextEditingController(text: 'true'),
|
||||
},
|
||||
'FinlyticBot': {
|
||||
'Alpaca.KeyId': TextEditingController(text: ''),
|
||||
'Alpaca.SecretKey': TextEditingController(text: ''),
|
||||
'Alpaca.IsPaper': TextEditingController(text: 'true'),
|
||||
'Bot.EnableAutoExecution': TextEditingController(text: 'true'),
|
||||
'Bot.RiskPerTradePercent': TextEditingController(text: '1.0'),
|
||||
'Bot.MaxPositionAllocationPercent': TextEditingController(text: '20.0'),
|
||||
'Bot.MaxConcurrentPositions': TextEditingController(text: '5'),
|
||||
'Bot.DailyLossLimitPercent': TextEditingController(text: '3.0'),
|
||||
'Bot.MonitoringIntervalSeconds': TextEditingController(text: '15'),
|
||||
},
|
||||
};
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
@@ -46,6 +46,32 @@ class _SystemDiagnosticsWidgetState extends State<SystemDiagnosticsWidget> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
IconData _getServiceIcon(String name) {
|
||||
switch (name) {
|
||||
case 'FinlyticBackend':
|
||||
return Icons.hub_outlined;
|
||||
case 'FinlyticAssets':
|
||||
return Icons.inventory_2_outlined;
|
||||
case 'FinlyticNews':
|
||||
return Icons.newspaper_outlined;
|
||||
case 'FinlyticTechnicals':
|
||||
case 'FinlyticTechnicalAnalysis':
|
||||
return Icons.show_chart_outlined;
|
||||
case 'FinlyticSentiment':
|
||||
return Icons.psychology_outlined;
|
||||
case 'FinlyticEngine':
|
||||
case 'FinlyticAnalyzer':
|
||||
case 'FinlyticTrades':
|
||||
return Icons.candlestick_chart_outlined;
|
||||
case 'FinlyticFundamentals':
|
||||
return Icons.corporate_fare_outlined;
|
||||
case 'FinlyticBot':
|
||||
return Icons.smart_toy_outlined;
|
||||
default:
|
||||
return Icons.dns_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final int totalCount = _serviceStatuses.length;
|
||||
@@ -191,7 +217,7 @@ class _SystemDiagnosticsWidgetState extends State<SystemDiagnosticsWidget> {
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
name == 'FinlyticBackend' ? Icons.hub_outlined : Icons.dns_outlined,
|
||||
_getServiceIcon(name),
|
||||
size: 18,
|
||||
color: isOnline ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../models/recent_setup_model.dart';
|
||||
import '../models/watchlist_entry_model.dart';
|
||||
import '../repositories/admin_repository.dart';
|
||||
|
||||
/// Card sitting next to "AUFSCHLÜSSELUNG NACH GRUND" showing how many assets
|
||||
/// FinlyticTechnicals' background scanner is currently watching. Tapping opens
|
||||
/// a dialog listing every entry — this directly answers "is anything even
|
||||
/// being checked in the background right now", independent of whether any of
|
||||
/// those checks have (yet) produced a proposal-worthy evaluation the engine
|
||||
/// history tab above would show.
|
||||
class WatchlistCard extends StatefulWidget {
|
||||
final ApiClient apiClient;
|
||||
|
||||
const WatchlistCard({super.key, required this.apiClient});
|
||||
|
||||
@override
|
||||
State<WatchlistCard> createState() => _WatchlistCardState();
|
||||
}
|
||||
|
||||
class _WatchlistCardState extends State<WatchlistCard> {
|
||||
late final AdminRepository _repository = AdminRepository(apiClient: widget.apiClient);
|
||||
List<WatchlistEntryModel>? _entries;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final entries = await _repository.fetchWatchlist();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_entries = entries;
|
||||
_error = null;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _showDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => _WatchlistDialog(repository: _repository, initialEntries: _entries ?? const []),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final count = _entries?.length;
|
||||
final value = _error != null ? '—' : (count?.toString() ?? '…');
|
||||
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(14),
|
||||
onTap: _showDialog,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('WATCHLIST', style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||
const Spacer(),
|
||||
Icon(Icons.list_alt_rounded, size: 16, color: AppTheme.accentCyan),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(value, style: TextStyle(fontSize: 22, color: AppTheme.textPrimary, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text('überwachte Assets', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(_error!, style: TextStyle(color: AppTheme.accentRed, fontSize: 11)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WatchlistDialog extends StatefulWidget {
|
||||
final AdminRepository repository;
|
||||
final List<WatchlistEntryModel> initialEntries;
|
||||
|
||||
const _WatchlistDialog({required this.repository, required this.initialEntries});
|
||||
|
||||
@override
|
||||
State<_WatchlistDialog> createState() => _WatchlistDialogState();
|
||||
}
|
||||
|
||||
class _WatchlistDialogState extends State<_WatchlistDialog> {
|
||||
late List<WatchlistEntryModel> _entries = widget.initialEntries;
|
||||
bool _refreshing = false;
|
||||
|
||||
Future<void> _refresh() async {
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
final fresh = await widget.repository.fetchWatchlist();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_entries = fresh;
|
||||
_refreshing = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _refreshing = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime utc) {
|
||||
final local = utc.toLocal();
|
||||
final d = local.day.toString().padLeft(2, '0');
|
||||
final m = local.month.toString().padLeft(2, '0');
|
||||
final h = local.hour.toString().padLeft(2, '0');
|
||||
final min = local.minute.toString().padLeft(2, '0');
|
||||
return '$d.$m.${local.year} $h:$min';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeTheme = AppTheme.activePreset;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: activeTheme.cardSurface,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640),
|
||||
child: GlassContainer(
|
||||
borderRadius: 20,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.list_alt_rounded, color: AppTheme.accentCyan, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text('Watchlist (${_entries.length})', style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _refreshing ? null : _refresh,
|
||||
icon: _refreshing
|
||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.refresh_rounded, color: Colors.white70),
|
||||
tooltip: 'Neu laden',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'Assets, die FinlyticTechnicals derzeit im Hintergrund fortlaufend überprüft. Eintrag antippen für die letzten Bewertungen.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_entries.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Text('Die Watchlist ist derzeit leer.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: _entries.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1, color: Colors.white12),
|
||||
itemBuilder: (context, index) => _WatchlistEntryTile(
|
||||
entry: _entries[index],
|
||||
repository: widget.repository,
|
||||
formatTimestamp: _formatTimestamp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WatchlistEntryTile extends StatefulWidget {
|
||||
final WatchlistEntryModel entry;
|
||||
final AdminRepository repository;
|
||||
final String Function(DateTime) formatTimestamp;
|
||||
|
||||
const _WatchlistEntryTile({required this.entry, required this.repository, required this.formatTimestamp});
|
||||
|
||||
@override
|
||||
State<_WatchlistEntryTile> createState() => _WatchlistEntryTileState();
|
||||
}
|
||||
|
||||
class _WatchlistEntryTileState extends State<_WatchlistEntryTile> {
|
||||
List<RecentSetupModel>? _history;
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
if (_history != null || _loading) return;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final history = await widget.repository.fetchWatchlistEntryHistory(widget.entry.isin);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_history = history;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entry = widget.entry;
|
||||
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
onExpansionChanged: (expanded) {
|
||||
if (expanded) _loadHistory();
|
||||
},
|
||||
tilePadding: EdgeInsets.zero,
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.symbol?.isNotEmpty == true ? entry.symbol! : entry.isin,
|
||||
style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
Text(entry.isin, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (entry.source != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentCyan.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: AppTheme.accentCyan.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(entry.source!.label, style: TextStyle(color: AppTheme.accentCyan, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
'Seit ${widget.formatTimestamp(entry.addedAtUtc)}'
|
||||
'${entry.expiresAtUtc != null ? ' · Läuft ab ${widget.formatTimestamp(entry.expiresAtUtc!)}' : ''}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildHistoryBody(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryBody() {
|
||||
if (_loading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))),
|
||||
);
|
||||
}
|
||||
if (_error != null) {
|
||||
return Text(_error!, style: TextStyle(color: AppTheme.accentRed, fontSize: 12));
|
||||
}
|
||||
final history = _history ?? const [];
|
||||
if (history.isEmpty) {
|
||||
return Text(
|
||||
'Noch keine technische Bewertung für dieses Asset erfasst.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('LETZTE BEWERTUNGEN', style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||
const SizedBox(height: 6),
|
||||
...history.map((setup) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(widget.formatTimestamp(setup.createdAt), style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(setup.strategyName, style: TextStyle(color: AppTheme.textPrimary, fontSize: 11), overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (setup.isTopPick ? AppTheme.primaryEmerald : Colors.amber).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
setup.qualityScore.toStringAsFixed(1),
|
||||
style: TextStyle(
|
||||
color: setup.isTopPick ? AppTheme.primaryEmerald : Colors.amber,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../trades/models/trade_model.dart';
|
||||
import 'asset_trades_event.dart';
|
||||
import 'asset_trades_state.dart';
|
||||
import '../../repositories/asset_repository.dart';
|
||||
@@ -20,28 +19,23 @@ class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
|
||||
on<TriggerManualAnalysis>((event, emit) async {
|
||||
emit(AssetTradesLoading());
|
||||
try {
|
||||
final analysisRes = await repository.triggerManualAnalysis(event.isin, payload: event.payload);
|
||||
// Server contract: always 200 -> AssetEvaluationResultDto, whether the
|
||||
// pipeline produced a proposal or rejected the opportunity. The trade
|
||||
// list itself is unaffected until the user actually accepts a
|
||||
// proposal, so it is simply reloaded as-is; the analysis result is
|
||||
// surfaced separately for the UI to react to exactly once.
|
||||
final result = await repository.triggerManualAnalysis(event.isin, payload: event.payload);
|
||||
final existingTrades = await repository.getAssetTrades(event.isin, null);
|
||||
|
||||
final list = List<TradeModel>.from(existingTrades);
|
||||
final newProposal = analysisRes?.proposal;
|
||||
if (newProposal != null) {
|
||||
final isDuplicate = list.any((t) => t.id == newProposal.id || (t.analysisId.isNotEmpty && t.analysisId == newProposal.analysisId));
|
||||
if (!isDuplicate) {
|
||||
list.insert(0, newProposal);
|
||||
}
|
||||
}
|
||||
emit(AssetTradesLoaded(list));
|
||||
emit(AssetTradesLoaded(existingTrades, manualAnalysisResult: result));
|
||||
} catch (e) {
|
||||
emit(AssetTradesError("Failed to trigger manual analysis: $e"));
|
||||
}
|
||||
});
|
||||
on<RejectTradeEvent>((event, emit) async {
|
||||
try {
|
||||
await repository.rejectTrade(event.tradeId);
|
||||
add(LoadAssetTrades(event.isin));
|
||||
} catch (e) {
|
||||
emit(AssetTradesError("Failed to reject trade: $e"));
|
||||
on<DismissTradeEvent>((event, emit) {
|
||||
// Purely local: no server call, see DismissTradeEvent doc comment.
|
||||
final current = state;
|
||||
if (current is AssetTradesLoaded) {
|
||||
emit(AssetTradesLoaded(current.data.where((t) => t.id != event.tradeId).toList()));
|
||||
}
|
||||
});
|
||||
on<AcceptTradeEvent>((event, emit) async {
|
||||
@@ -52,6 +46,14 @@ class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
|
||||
emit(AssetTradesError("Failed to accept trade: $e"));
|
||||
}
|
||||
});
|
||||
on<AddTradeFillEvent>((event, emit) async {
|
||||
try {
|
||||
await repository.addTradeFill(event.tradeId, executedPrice: event.executedPrice, quantity: event.quantity);
|
||||
add(LoadAssetTrades(event.isin));
|
||||
} catch (e) {
|
||||
emit(AssetTradesError("Failed to update trade execution: $e"));
|
||||
}
|
||||
});
|
||||
on<CloseTradeEvent>((event, emit) async {
|
||||
try {
|
||||
await repository.closeTrade(event.tradeId, event.exitPrice);
|
||||
|
||||
@@ -13,16 +13,34 @@ class TriggerManualAnalysis extends AssetTradesEvent {
|
||||
final ManualAnalysisRequestDto? payload;
|
||||
TriggerManualAnalysis(this.isin, {this.payload});
|
||||
}
|
||||
class RejectTradeEvent extends AssetTradesEvent {
|
||||
/// Dismisses a trade proposal from the locally displayed list only.
|
||||
///
|
||||
/// There is no server-side "reject" anymore: a proposal is a system-wide
|
||||
/// opportunity that any user may accept independently, so rejecting it has
|
||||
/// no server-side meaning. This purely removes the card from the current
|
||||
/// in-memory list; the proposal keeps existing server-side until its 24h
|
||||
/// TTL expires, so it can reappear after the next reload (Rules.md §4 —
|
||||
/// no fabricated "permanently rejected" state is invented).
|
||||
class DismissTradeEvent extends AssetTradesEvent {
|
||||
final String tradeId;
|
||||
final String isin;
|
||||
RejectTradeEvent(this.tradeId, this.isin);
|
||||
DismissTradeEvent(this.tradeId);
|
||||
}
|
||||
class AcceptTradeEvent extends AssetTradesEvent {
|
||||
final TradeAcceptanceDto tradeAcceptanceDto;
|
||||
final String isin;
|
||||
AcceptTradeEvent(this.tradeAcceptanceDto, this.isin);
|
||||
}
|
||||
|
||||
/// Records a corrective/additional fill against an already-active trade
|
||||
/// (review-execution path). Distinct from [AcceptTradeEvent], which targets
|
||||
/// a proposal, not an existing trade — see `AssetRepository.addTradeFill`.
|
||||
class AddTradeFillEvent extends AssetTradesEvent {
|
||||
final String tradeId;
|
||||
final String isin;
|
||||
final double executedPrice;
|
||||
final double quantity;
|
||||
AddTradeFillEvent(this.tradeId, this.isin, this.executedPrice, this.quantity);
|
||||
}
|
||||
class CloseTradeEvent extends AssetTradesEvent {
|
||||
final String tradeId;
|
||||
final String isin;
|
||||
|
||||
@@ -5,7 +5,23 @@ class AssetTradesInitial extends AssetTradesState {}
|
||||
class AssetTradesLoading extends AssetTradesState {}
|
||||
class AssetTradesLoaded extends AssetTradesState {
|
||||
final List<TradeModel> data;
|
||||
AssetTradesLoaded(this.data);
|
||||
|
||||
/// Transient result of a just-triggered manual analysis. Only set on the
|
||||
/// state instance emitted directly by `TriggerManualAnalysis` — a plain
|
||||
/// reload/dismiss/accept emits a fresh `AssetTradesLoaded` without it, so a
|
||||
/// `BlocConsumer` listener naturally reacts to it exactly once instead of
|
||||
/// on every rebuild.
|
||||
///
|
||||
/// Always fully populated when set: the server contract no longer has a
|
||||
/// silent "204, no proposal" outcome, so unlike the old
|
||||
/// `manualAnalysisProposal`/`manualAnalysisEmpty` pair, a single non-null
|
||||
/// value here already tells the caller everything — check
|
||||
/// `manualAnalysisResult!.hasProposal` to distinguish an accepted
|
||||
/// opportunity from a rejected one with real scores/AI reasoning attached
|
||||
/// (Rules.md §4).
|
||||
final AssetEvaluationResultModel? manualAnalysisResult;
|
||||
|
||||
AssetTradesLoaded(this.data, {this.manualAnalysisResult});
|
||||
}
|
||||
class AssetTradesError extends AssetTradesState {
|
||||
final String message;
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../trades/models/trade_model.dart';
|
||||
|
||||
class ExecutionPlanModel extends Equatable {
|
||||
final double stopLoss;
|
||||
final List<double> takeProfitTargets;
|
||||
final double riskRewardRatio;
|
||||
final double maxLeverage;
|
||||
|
||||
const ExecutionPlanModel({
|
||||
this.stopLoss = 0.0,
|
||||
this.takeProfitTargets = const [],
|
||||
this.riskRewardRatio = 0.0,
|
||||
this.maxLeverage = 1.0,
|
||||
});
|
||||
|
||||
factory ExecutionPlanModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic v) => (v as num?)?.toDouble() ?? 0.0;
|
||||
return ExecutionPlanModel(
|
||||
stopLoss: parseDbl(json['stopLoss']),
|
||||
takeProfitTargets: (json['takeProfitTargets'] as List<dynamic>? ?? []).map((e) => parseDbl(e)).toList(),
|
||||
riskRewardRatio: parseDbl(json['riskRewardRatio']),
|
||||
maxLeverage: parseDbl(json['maxLeverage']) == 0 ? 1.0 : parseDbl(json['maxLeverage']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [stopLoss, takeProfitTargets, riskRewardRatio, maxLeverage];
|
||||
}
|
||||
|
||||
class DetailedAnalysisModel extends Equatable {
|
||||
final String technicalRationale;
|
||||
final String fundamentalRationale;
|
||||
final String riskWarning;
|
||||
|
||||
const DetailedAnalysisModel({
|
||||
this.technicalRationale = '',
|
||||
this.fundamentalRationale = '',
|
||||
this.riskWarning = '',
|
||||
});
|
||||
|
||||
factory DetailedAnalysisModel.fromJson(Map<String, dynamic> json) {
|
||||
return DetailedAnalysisModel(
|
||||
technicalRationale: json['technicalRationale']?.toString() ?? '',
|
||||
fundamentalRationale: json['fundamentalRationale']?.toString() ?? '',
|
||||
riskWarning: json['riskWarning']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [technicalRationale, fundamentalRationale, riskWarning];
|
||||
}
|
||||
|
||||
class N8nAnalysisResponseDto extends Equatable {
|
||||
final String aiDecision; // "Proceed", "Rejected", "Hold"
|
||||
final String aiReasoning;
|
||||
final int evalScore;
|
||||
final String suggestedDirection; // "Long", "Short"
|
||||
final String suggestedRisk;
|
||||
final String suggestedTimeframe;
|
||||
final ExecutionPlanModel? executionPlan;
|
||||
final DetailedAnalysisModel? detailedAnalysis;
|
||||
|
||||
const N8nAnalysisResponseDto({
|
||||
this.aiDecision = 'Rejected',
|
||||
this.aiReasoning = '',
|
||||
this.evalScore = 0,
|
||||
this.suggestedDirection = 'Long',
|
||||
this.suggestedRisk = 'Moderate',
|
||||
this.suggestedTimeframe = '1D',
|
||||
this.executionPlan,
|
||||
this.detailedAnalysis,
|
||||
});
|
||||
|
||||
factory N8nAnalysisResponseDto.fromJson(Map<String, dynamic> json) {
|
||||
ExecutionPlanModel? execPlan;
|
||||
if (json['executionPlan'] != null && json['executionPlan'] is Map<String, dynamic>) {
|
||||
execPlan = ExecutionPlanModel.fromJson(json['executionPlan']);
|
||||
}
|
||||
|
||||
DetailedAnalysisModel? detailAnalysis;
|
||||
if (json['detailedAnalysis'] != null && json['detailedAnalysis'] is Map<String, dynamic>) {
|
||||
detailAnalysis = DetailedAnalysisModel.fromJson(json['detailedAnalysis']);
|
||||
}
|
||||
|
||||
return N8nAnalysisResponseDto(
|
||||
aiDecision: json['aiDecision']?.toString() ?? 'Rejected',
|
||||
aiReasoning: json['aiReasoning']?.toString() ?? '',
|
||||
evalScore: (json['evalScore'] as num?)?.toInt() ?? 0,
|
||||
suggestedDirection: json['suggestedDirection']?.toString() ?? 'Long',
|
||||
suggestedRisk: json['suggestedRisk']?.toString() ?? 'Moderate',
|
||||
suggestedTimeframe: json['suggestedTimeframe']?.toString() ?? '1D',
|
||||
executionPlan: execPlan,
|
||||
detailedAnalysis: detailAnalysis,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
aiDecision,
|
||||
aiReasoning,
|
||||
evalScore,
|
||||
suggestedDirection,
|
||||
suggestedRisk,
|
||||
suggestedTimeframe,
|
||||
executionPlan,
|
||||
detailedAnalysis,
|
||||
];
|
||||
}
|
||||
|
||||
class ManualAnalysisResponseDto extends Equatable {
|
||||
final String analysisId;
|
||||
final bool isTradeProposed;
|
||||
final String status;
|
||||
final String recommendation; // "RECOMMENDED", "NOT_RECOMMENDED"
|
||||
final N8nAnalysisResponseDto? n8nResponse;
|
||||
final TradeModel? proposal;
|
||||
final String message;
|
||||
|
||||
const ManualAnalysisResponseDto({
|
||||
required this.analysisId,
|
||||
this.isTradeProposed = false,
|
||||
this.status = 'Success',
|
||||
this.recommendation = 'NOT_RECOMMENDED',
|
||||
this.n8nResponse,
|
||||
this.proposal,
|
||||
this.message = '',
|
||||
});
|
||||
|
||||
factory ManualAnalysisResponseDto.fromJson(Map<String, dynamic> json) {
|
||||
N8nAnalysisResponseDto? n8n;
|
||||
if (json['n8nResponse'] != null && json['n8nResponse'] is Map<String, dynamic>) {
|
||||
n8n = N8nAnalysisResponseDto.fromJson(json['n8nResponse']);
|
||||
}
|
||||
|
||||
TradeModel? prop;
|
||||
if (json['proposal'] != null && json['proposal'] is Map<String, dynamic>) {
|
||||
prop = TradeModel.fromJson(json['proposal']);
|
||||
} else if (n8n != null) {
|
||||
final exec = n8n.executionPlan;
|
||||
final det = n8n.detailedAnalysis;
|
||||
final isProceed = n8n.aiDecision.toLowerCase() == 'proceed';
|
||||
final analysisIdStr = (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '';
|
||||
final tradeIdStr = 'PROP-${analysisIdStr.length > 10 ? analysisIdStr.substring(0, 10).toUpperCase() : 'MANUAL'}';
|
||||
|
||||
prop = TradeModel(
|
||||
id: tradeIdStr,
|
||||
analysisId: analysisIdStr,
|
||||
symbol: (json['symbol'] ?? json['Symbol'])?.toString() ?? '',
|
||||
isin: (json['isin'] ?? json['Isin'])?.toString() ?? '',
|
||||
status: isProceed ? 'Proposed' : 'Rejected',
|
||||
signalType: n8n.suggestedDirection.toUpperCase() == 'SHORT' ? 'SELL' : 'BUY',
|
||||
entryPrice: 0.0,
|
||||
stopLoss: exec?.stopLoss ?? 0.0,
|
||||
takeProfit: (exec?.takeProfitTargets.isNotEmpty ?? false) ? exec!.takeProfitTargets.first : 0.0,
|
||||
reasoning: n8n.aiReasoning,
|
||||
technicalRationale: det?.technicalRationale ?? '',
|
||||
fundamentalRationale: det?.fundamentalRationale ?? '',
|
||||
riskWarning: det?.riskWarning ?? '',
|
||||
takeProfitTargets: exec?.takeProfitTargets ?? const [],
|
||||
maxLeverage: exec?.maxLeverage ?? 1.0,
|
||||
riskTolerance: n8n.suggestedRisk,
|
||||
timeframe: n8n.suggestedTimeframe,
|
||||
);
|
||||
}
|
||||
|
||||
return ManualAnalysisResponseDto(
|
||||
analysisId: (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '',
|
||||
isTradeProposed: json['isTradeProposed'] == true || json['IsTradeProposed'] == true,
|
||||
status: (json['status'] ?? json['Status'])?.toString() ?? 'Success',
|
||||
recommendation: (json['recommendation'] ?? json['Recommendation'])?.toString() ?? 'NOT_RECOMMENDED',
|
||||
n8nResponse: n8n,
|
||||
proposal: prop,
|
||||
message: (json['message'] ?? json['Message'])?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [analysisId, isTradeProposed, status, recommendation, n8nResponse, proposal, message];
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import 'package:finlytic_app/core/network/api_client.dart';
|
||||
import 'package:finlytic_app/features/asset_detail/models/fundamental_data_model.dart';
|
||||
import 'package:finlytic_app/features/asset_detail/models/technical_analysis_model.dart';
|
||||
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
|
||||
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_response_dto.dart';
|
||||
import 'package:finlytic_app/features/trades/models/trade_model.dart';
|
||||
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
|
||||
import 'package:finlytic_app/features/trades/models/close_trade_request_dto.dart';
|
||||
@@ -102,23 +101,47 @@ class AssetRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<double?> getLivePrice(String isin) async {
|
||||
try {
|
||||
final res = await apiClient.get('/api/v1/assets/$isin/live');
|
||||
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
||||
final val = res.data['currentPrice'] ?? res.data['CurrentPrice'];
|
||||
if (val is num) return val.toDouble();
|
||||
if (val != null) return double.tryParse(val.toString());
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
|
||||
return _tradeRepository.fetchTrades(isin: isin, status: status);
|
||||
}
|
||||
|
||||
Future<ManualAnalysisResponseDto?> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
|
||||
/// Triggers an on-demand manual analysis for [isin] via `POST /api/v1/analyze/manual`.
|
||||
///
|
||||
/// Server contract: always `200 OK` with a full `AssetEvaluationResultDto`
|
||||
/// body — even when the analysis ran but did not clear the bar for a trade
|
||||
/// proposal (`AssetEvaluationResultModel.proposal == null`), the response
|
||||
/// still carries the real, already-computed scores and AI reasoning, so
|
||||
/// there is no more silent `204 No Content` outcome to handle here
|
||||
/// (Rules.md §4). A non-2xx status (missing ISIN, engine unreachable, no
|
||||
/// RPC response, unexpected error) surfaces as a `DioException` that
|
||||
/// propagates to the caller instead of being swallowed into `null`.
|
||||
Future<AssetEvaluationResultModel> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
|
||||
final body = payload != null ? payload.toJson() : {'isin': isin};
|
||||
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
|
||||
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
||||
return ManualAnalysisResponseDto.fromJson(res.data);
|
||||
if (res.data != null && res.data is Map<String, dynamic>) {
|
||||
return AssetEvaluationResultModel.fromJson(res.data);
|
||||
}
|
||||
return null;
|
||||
throw StateError('Manual analysis endpoint returned an unexpected empty/non-object body.');
|
||||
}
|
||||
|
||||
Future<void> rejectTrade(String tradeId) async => _tradeRepository.rejectTrade(tradeId);
|
||||
|
||||
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async => _tradeRepository.acceptTrade(tradeAcceptanceDto);
|
||||
|
||||
Future<void> closeTrade(String tradeId, double exitPrice) async =>
|
||||
_tradeRepository.closeTrade(tradeId, dto: CloseTradeRequestDto(userExitPrice: exitPrice));
|
||||
|
||||
Future<TradeModel> addTradeFill(String tradeId, {required double executedPrice, required double quantity}) async =>
|
||||
_tradeRepository.addTradeFill(tradeId, executedPrice: executedPrice, quantity: quantity);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/widgets/glass_container.dart';
|
||||
import '../../../../core/widgets/shimmer_loading.dart';
|
||||
import '../../../../core/widgets/status_badge.dart';
|
||||
import '../../../../shared/widgets/evaluation_score_breakdown_sheet.dart';
|
||||
import '../../../bot/repositories/bot_repository.dart';
|
||||
import '../../../proposals/views/proposal_decision_screen.dart';
|
||||
import '../../../trades/models/trade_model.dart';
|
||||
import '../../../trades/widgets/trade_execution_cockpit.dart';
|
||||
import '../../../trades/widgets/trade_closing_cockpit.dart';
|
||||
@@ -22,11 +26,12 @@ class TradesTab extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TradesTabState extends State<TradesTab> {
|
||||
bool _justTriggeredAnalysis = false;
|
||||
late final BotRepository _botRepository;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_botRepository = BotRepository(apiClient: context.read<ApiClient>());
|
||||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
|
||||
}
|
||||
|
||||
@@ -39,8 +44,23 @@ class _TradesTabState extends State<TradesTab> {
|
||||
defaultSymbol: widget.symbol,
|
||||
isActive: isActive,
|
||||
onAccept: (dto) {
|
||||
tradesBloc.add(AcceptTradeEvent(dto, widget.symbol));
|
||||
final tId = trade.id;
|
||||
// `TradeExecutionCockpit._buildDto()` already picks the right identifier
|
||||
// (trade.id for isActive, trade.proposalId otherwise) and always fills
|
||||
// actualEntryPrice/quantity from the two fields the dialog actually
|
||||
// collects — but the two identifiers target different server-side
|
||||
// operations: accepting a *proposal* vs. recording a fill against an
|
||||
// already-*existing* trade (`UserTradesController.AcceptTrade` looks
|
||||
// `dto.tradeId` up as a proposal id, which fails for an active trade's
|
||||
// own id). Route accordingly instead of always calling AcceptTradeEvent.
|
||||
if (isActive) {
|
||||
final price = dto.actualEntryPrice ?? dto.entryPrice;
|
||||
final qty = dto.quantity ?? dto.positionSize;
|
||||
if (price == null || qty == null) return;
|
||||
tradesBloc.add(AddTradeFillEvent(tId, widget.symbol, price, qty));
|
||||
} else {
|
||||
tradesBloc.add(AcceptTradeEvent(dto, widget.symbol));
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isActive ? 'Einstellungen für Trade $tId gespeichert!' : 'Trade $tId angenommen & Position eröffnet!'),
|
||||
@@ -50,10 +70,15 @@ class _TradesTabState extends State<TradesTab> {
|
||||
);
|
||||
},
|
||||
onReject: (tId) {
|
||||
tradesBloc.add(RejectTradeEvent(tId, widget.symbol));
|
||||
// Purely local dismissal — there is no server-side rejection (a
|
||||
// proposal is a system-wide opportunity anyone may still accept).
|
||||
// Wording must not claim a permanence the backend doesn't provide.
|
||||
tradesBloc.add(DismissTradeEvent(tId));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Trade $tId abgelehnt.'),
|
||||
content: const Text(
|
||||
'Vorschlag ausgeblendet – er kann beim nächsten Neuladen erneut erscheinen, bis er serverseitig abläuft.',
|
||||
),
|
||||
backgroundColor: AppTheme.textSecondary,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
@@ -62,20 +87,101 @@ class _TradesTabState extends State<TradesTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _executeProposalViaBot(BuildContext context, TradeProposalModel proposal) async {
|
||||
Navigator.of(context).pop();
|
||||
try {
|
||||
await _botRepository.executeProposal(proposal.proposalId);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Vorschlag für ${proposal.symbol} an den Bot übergeben.'),
|
||||
backgroundColor: AppTheme.primaryEmerald,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Fehler bei der Bot-Übergabe: $e'),
|
||||
backgroundColor: AppTheme.accentRed,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showProposalDecision(BuildContext context, TradeProposalModel proposal) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProposalDecisionScreen(
|
||||
proposal: proposal,
|
||||
onExecuteBot: () => _executeProposalViaBot(context, proposal),
|
||||
onManualTrade: () {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Manuelle Eröffnung: Bitte über die Order-Maske deines Brokers ausführen.'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows the real, already-computed score breakdown and AI reasoning for a
|
||||
/// manual analysis that ran but did not produce a trade proposal
|
||||
/// ([AssetEvaluationResultModel.proposal] is `null`). Replaces the old bare
|
||||
/// "kein Vorschlag" snackbar: the user gets to see *why* the opportunity
|
||||
/// was rejected, not just *that* it was (Rules.md §4). Every value shown
|
||||
/// here comes straight from the server response — nothing is invented, and
|
||||
/// [AssetEvaluationResultModel.daysToNextEarnings] is only rendered when
|
||||
/// the server actually sent a value.
|
||||
void _showEvaluationRejectedSheet(BuildContext context, AssetEvaluationResultModel result) {
|
||||
EvaluationScoreBreakdownSheet.show(
|
||||
context,
|
||||
title: 'Analyse abgeschlossen – kein Vorschlag',
|
||||
subtitle:
|
||||
'Für ${widget.symbol} wurde keine aktive Trade-Empfehlung erzeugt. Die berechneten Werte und die KI-Begründung stehen unten.',
|
||||
headerIcon: result.aiApproved ? Icons.psychology_outlined : Icons.block_outlined,
|
||||
headerColor: result.aiApproved ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||
compositeScore: result.compositeScore,
|
||||
technicalScore: result.technicalScore,
|
||||
sentimentScore: result.sentimentScore,
|
||||
fundamentalScore: result.fundamentalScore,
|
||||
passedEarningsLockout: result.passedEarningsLockout,
|
||||
daysToNextEarnings: result.daysToNextEarnings,
|
||||
passedDividendGate: result.passedDividendGate,
|
||||
daysToNextExDividend: result.daysToNextExDividend,
|
||||
reasoningLabel: result.aiApproved ? 'KI-These' : 'Ablehnungsgrund',
|
||||
reasoningText: result.aiThesisSummary,
|
||||
identifiedRisks: result.aiIdentifiedRisks,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<AssetTradesBloc, AssetTradesState>(
|
||||
listener: (context, state) {
|
||||
if (_justTriggeredAnalysis && state is AssetTradesLoaded) {
|
||||
final List<TradeModel> tradesList = state.data;
|
||||
if (tradesList.isNotEmpty) {
|
||||
_justTriggeredAnalysis = false;
|
||||
final latestTrade = tradesList.first;
|
||||
if (state is! AssetTradesLoaded) return;
|
||||
|
||||
final result = state.manualAnalysisResult;
|
||||
if (result == null) return;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showEditTradeExecutionDialog(context, latestTrade);
|
||||
if (!mounted) return;
|
||||
if (result.hasProposal) {
|
||||
_showProposalDecision(context, result.proposal!);
|
||||
} else {
|
||||
// Rejected (or no technical setup at all) - show the real, already
|
||||
// computed scores and AI reasoning instead of a bare "no proposal"
|
||||
// snackbar, so the user understands *why*, not just *that*
|
||||
// (Rules.md §4).
|
||||
_showEvaluationRejectedSheet(context, result);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final List<TradeModel> tradesList = (state is AssetTradesLoaded) ? state.data : [];
|
||||
@@ -117,11 +223,10 @@ class _TradesTabState extends State<TradesTab> {
|
||||
symbol: widget.symbol,
|
||||
initialRiskScore: 50.0,
|
||||
onTrigger: (payload) {
|
||||
setState(() => _justTriggeredAnalysis = true);
|
||||
context.read<AssetTradesBloc>().add(TriggerManualAnalysis(widget.symbol, payload: payload));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('KI-Analyse für ${widget.symbol} abgeschlossen. Trade-Cockpit öffnet sich...'),
|
||||
content: Text('KI-Analyse für ${widget.symbol} wird ausgeführt...'),
|
||||
backgroundColor: AppTheme.accentCyan,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
@@ -153,18 +258,12 @@ class _TradesTabState extends State<TradesTab> {
|
||||
else if (state is AssetTradesLoaded) ...[
|
||||
_buildTradeList(
|
||||
'Aktive Trade-Signale & Positionen',
|
||||
tradesList.where((t) {
|
||||
final s = t.status.toUpperCase();
|
||||
return s == 'ACTIVE' || s == 'PENDING' || s == 'PROPOSED';
|
||||
}).toList(),
|
||||
tradesList.where((t) => t.isActive || t.isProposed).toList(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildTradeList(
|
||||
'Historische Trades & KI-Bewertungen',
|
||||
tradesList.where((t) {
|
||||
final s = t.status.toUpperCase();
|
||||
return s == 'CLOSED' || s == 'REJECTED' || (s != 'ACTIVE' && s != 'PENDING' && s != 'PROPOSED');
|
||||
}).toList(),
|
||||
tradesList.where((t) => t.isClosed || t.isRejected).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -208,8 +307,7 @@ class _TradesTabState extends State<TradesTab> {
|
||||
itemCount: trades.length,
|
||||
itemBuilder: (context, index) {
|
||||
final trade = trades[index];
|
||||
final s = trade.status.toUpperCase();
|
||||
final isActive = s == 'ACTIVE';
|
||||
final isActive = trade.isActive;
|
||||
|
||||
return AssetTradeItemCard(
|
||||
trade: trade,
|
||||
@@ -223,7 +321,7 @@ class _TradesTabState extends State<TradesTab> {
|
||||
trade: trade,
|
||||
defaultSymbol: widget.symbol,
|
||||
onClose: (dto) {
|
||||
final isinVal = trade.isin.isNotEmpty ? trade.isin : widget.symbol;
|
||||
final isinVal = trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : widget.symbol;
|
||||
context.read<AssetTradesBloc>().add(CloseTradeEvent(trade.id, isinVal, dto.userExitPrice));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
||||
@@ -4,6 +4,15 @@ import '../../../../core/widgets/glass_container.dart';
|
||||
import '../../../../core/widgets/status_badge.dart';
|
||||
import '../../../trades/models/trade_model.dart';
|
||||
|
||||
/// Trade summary card for the asset-detail "Trades" tab.
|
||||
///
|
||||
/// Migrated onto `ActiveTradeDto` (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`).
|
||||
/// A number of fields this card used to show no longer exist server-side at
|
||||
/// all (reasoning/technicalRationale/fundamentalRationale/riskWarning,
|
||||
/// hasPendingExitAlert/pendingExitReason, entryZoneMin/Max, maxLeverage,
|
||||
/// timeframe/riskTolerance/companyName, closeReason) — those sections were
|
||||
/// removed rather than kept alive showing an empty/zero placeholder
|
||||
/// (Rules.md §4).
|
||||
class AssetTradeItemCard extends StatelessWidget {
|
||||
final TradeModel trade;
|
||||
final String defaultSymbol;
|
||||
@@ -20,43 +29,42 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
String _fmt(dynamic val) {
|
||||
if (val == null) return 'N/A';
|
||||
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
|
||||
return n != null ? n.toStringAsFixed(2) : val.toString();
|
||||
}
|
||||
String _fmt(double val) => val.toStringAsFixed(2);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isin = trade.isin.isNotEmpty ? trade.isin : defaultSymbol;
|
||||
final side = (trade.signalType.isNotEmpty ? trade.signalType : 'BUY').toUpperCase();
|
||||
final status = trade.status.toUpperCase();
|
||||
final isBuy = side == 'BUY' || side == 'LONG';
|
||||
final isActive = status == 'ACTIVE';
|
||||
final isin = trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : defaultSymbol;
|
||||
final isBuy = trade.direction.isLong;
|
||||
final isActive = trade.isActive;
|
||||
final sideColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
|
||||
final entryZoneMin = trade.entryZoneMin;
|
||||
final entryZoneMax = trade.entryZoneMax;
|
||||
final entryPrice = trade.entryPrice;
|
||||
final stopLoss = trade.stopLoss;
|
||||
final takeProfit = trade.takeProfit;
|
||||
final takeProfitTargets = trade.takeProfitTargets;
|
||||
final crv = (takeProfit > 0 && stopLoss > 0 && entryPrice > 0)
|
||||
? ((takeProfit - entryPrice).abs() / (entryPrice - stopLoss).abs()).toStringAsFixed(2)
|
||||
: null;
|
||||
final maxLeverage = trade.maxLeverage;
|
||||
// Entry price: the real fill-weighted average the engine already
|
||||
// computed, not a planned/target zone (that concept no longer exists
|
||||
// server-side).
|
||||
final entryPrice = trade.averageBuyIn;
|
||||
|
||||
final actualEntry = trade.actualEntryPrice;
|
||||
final posSize = trade.positionSize;
|
||||
final levUsed = trade.leverageUsed;
|
||||
final qty = trade.positionSize > 0 && trade.actualEntryPrice > 0 ? trade.positionSize / trade.actualEntryPrice : 0;
|
||||
final entryFee = trade.entryFee;
|
||||
final exitFee = trade.exitFee;
|
||||
// Live protective stop: `currentStopLoss` (not `initialStopLoss`) is
|
||||
// used here because this card shows the trade's live state — the
|
||||
// current stop already reflects any break-even/trailing adjustment the
|
||||
// engine has made. `initialStopLoss` (the original plan value) is only
|
||||
// relevant historically and is shown in the trade detail view instead.
|
||||
final stopLoss = trade.currentStopLoss;
|
||||
|
||||
final reasoning = trade.reasoning;
|
||||
final techRationale = trade.technicalRationale;
|
||||
final fundRationale = trade.fundamentalRationale;
|
||||
final riskWarning = trade.riskWarning;
|
||||
final tpStages = trade.exitPlan.takeProfitStages;
|
||||
// Server-computed reward:risk multiple for the first take-profit stage —
|
||||
// used instead of a client-side recomputation from raw prices.
|
||||
final primaryRMultiple = tpStages.isNotEmpty ? tpStages.first.rMultiple : null;
|
||||
|
||||
final investedCapital = entryPrice > 0 && trade.totalQuantity > 0 ? entryPrice * trade.totalQuantity : null;
|
||||
|
||||
// Never recomputed from raw prices client-side — always the server's
|
||||
// own figure (realized once resolved, otherwise its live unrealized
|
||||
// value; see `TradeModel.pnlEur`).
|
||||
final pnlEur = trade.pnlEur;
|
||||
final pnlPercent = trade.unrealizedPnlPercent;
|
||||
final isPnlWin = pnlEur >= 0;
|
||||
final pnlColor = isPnlWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
final showPnl = isActive || trade.isClosed;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
@@ -71,14 +79,13 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
StatusBadge(label: side, color: sideColor),
|
||||
StatusBadge(label: trade.direction.label, color: sideColor),
|
||||
const SizedBox(width: 8),
|
||||
StatusBadge(
|
||||
label: status,
|
||||
color: isActive ? AppTheme.primaryEmerald : (status == 'PROPOSED' ? AppTheme.accentCyan : AppTheme.textMuted),
|
||||
label: trade.status.label,
|
||||
color: isActive ? AppTheme.primaryEmerald : (trade.isProposed ? AppTheme.accentCyan : AppTheme.textMuted),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (trade.instrumentType.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
@@ -86,7 +93,9 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
trade.derivativeIsin.isNotEmpty ? '${trade.instrumentType} (${trade.derivativeIsin})' : trade.instrumentType,
|
||||
trade.derivativeIsin != null && trade.derivativeIsin!.isNotEmpty
|
||||
? '${trade.instrumentType.label} (${trade.derivativeIsin})'
|
||||
: trade.instrumentType.label,
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
@@ -120,7 +129,7 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
] else if (status == 'PROPOSED' || status == 'PENDING') ...[
|
||||
] else if (trade.isProposed) ...[
|
||||
if (onAccept != null)
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAccept,
|
||||
@@ -146,55 +155,14 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
_buildDriftRadarBar(trade),
|
||||
],
|
||||
|
||||
// Pending Exit Alert Banner
|
||||
if (isActive && trade.hasPendingExitAlert) ...[
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentRed.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: AppTheme.accentRed, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('KI-Guardian Ratschlag: Position schließen!', style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
if (trade.pendingExitReason.isNotEmpty)
|
||||
Text(trade.pendingExitReason, style: const TextStyle(color: Colors.white70, fontSize: 11), maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onClose != null)
|
||||
ElevatedButton(
|
||||
onPressed: onClose,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentRed,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text('Schließen', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${trade.companyName.isNotEmpty ? trade.companyName : defaultSymbol} ($isin) | Timeframe: ${trade.timeframe} | Risk: ${trade.riskTolerance}',
|
||||
'${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol} ($isin)',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Target Price Metrics Grid
|
||||
// Price Metrics Grid
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
@@ -207,22 +175,26 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '€${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '€${_fmt(entryPrice)}', Colors.white),
|
||||
_buildTradeStat('Einstiegskurs', '€${_fmt(entryPrice)}', Colors.white),
|
||||
_buildTradeStat(
|
||||
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
|
||||
'€${_fmt(stopLoss)}',
|
||||
AppTheme.accentRed,
|
||||
),
|
||||
_buildTradeStat('Take-Profit', takeProfitTargets.isNotEmpty ? takeProfitTargets.map((t) => '€${_fmt(t)}').join(' / ') : '€${_fmt(takeProfit)}', AppTheme.primaryEmerald),
|
||||
_buildTradeStat(
|
||||
'Take-Profit',
|
||||
tpStages.isNotEmpty ? tpStages.map((s) => '€${_fmt(s.targetPrice)}').join(' / ') : 'Kein Fixziel (Trailing-Exit)',
|
||||
AppTheme.primaryEmerald,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (crv != null || maxLeverage > 0) ...[
|
||||
const Divider(color: Colors.white12, height: 16),
|
||||
if (primaryRMultiple != null) ...[
|
||||
const Divider(color: Colors.white10, height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (crv != null) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan),
|
||||
if (maxLeverage > 0) _buildTradeStat('Max. Hebel', '${_fmt(maxLeverage)}x', Colors.orangeAccent),
|
||||
_buildTradeStat('Chance-Risiko (TP1, R-Multiple)', '${_fmt(primaryRMultiple)}R', AppTheme.accentCyan),
|
||||
if (investedCapital != null) _buildTradeStat('Eingesetztes Kapital', '€${_fmt(investedCapital)}', Colors.white70),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -230,8 +202,8 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Execution Details if active
|
||||
if (actualEntry > 0 || posSize > 0 || levUsed > 0 || qty > 0) ...[
|
||||
// Position size / quantity
|
||||
if (trade.totalQuantity > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -240,84 +212,62 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald),
|
||||
const SizedBox(width: 6),
|
||||
const Text('Ihre Tatsächlichen Ausführungsdaten:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white)),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Stückzahl: ${_fmt(trade.totalQuantity)}${trade.isDerivative ? ' (Derivat)' : ''}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildTradeStat('Tatsächl. Einstieg', '€${_fmt(actualEntry > 0 ? actualEntry : entryPrice)}', Colors.white),
|
||||
_buildTradeStat('Investition', posSize > 0 ? '€${_fmt(posSize)}' : 'N/A', Colors.white),
|
||||
_buildTradeStat('Genutzter Hebel', levUsed > 0 ? '${_fmt(levUsed)}x' : '1x', AppTheme.primaryEmerald),
|
||||
_buildTradeStat('Stückzahl', qty > 0 ? '${_fmt(qty)} Stk.' : 'N/A', Colors.white70),
|
||||
],
|
||||
),
|
||||
if (entryFee > 0 || exitFee > 0) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text('Gebühren: Einstieg €${_fmt(entryFee)} | Ausstieg €${_fmt(exitFee)}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Realized PnL if closed
|
||||
if (status == 'CLOSED' || trade.pnlAbsolute != 0) ...[
|
||||
// PnL (server-computed, never recalculated client-side)
|
||||
if (showPnl) ...[
|
||||
const SizedBox(height: 12),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final pnlVal = trade.calculatedPnlAbs;
|
||||
final pnlPctVal = trade.calculatedPnlPct;
|
||||
final isWin = pnlVal >= 0;
|
||||
final color = isWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
|
||||
return Container(
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
color: pnlColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: color),
|
||||
border: Border.all(color: pnlColor),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(isWin ? Icons.trending_up : Icons.trending_down, size: 16, color: color),
|
||||
Icon(isPnlWin ? Icons.trending_up : Icons.trending_down, size: 16, color: pnlColor),
|
||||
const SizedBox(width: 6),
|
||||
const Text('Trade Ergebnis & Realisierter PnL:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
|
||||
Text(
|
||||
trade.isClosed ? 'Realisierter PnL:' : 'Aktueller PnL (unrealisiert):',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildTradeStat('Ausstiegskurs', trade.actualExitPrice > 0 ? '€${_fmt(trade.actualExitPrice)}' : 'N/A', Colors.white),
|
||||
_buildTradeStat('Realisierter PnL (€)', '${(isWin ? "+€" : "-€")}${_fmt(pnlVal.abs())}', color),
|
||||
_buildTradeStat('Rendite (%)', '${(pnlPctVal >= 0 ? "+" : "")}${_fmt(pnlPctVal)}%', isWin ? AppTheme.primaryEmerald : AppTheme.accentRed),
|
||||
_buildTradeStat('Aktueller Kurs', '€${_fmt(trade.currentPrice)}', Colors.white),
|
||||
_buildTradeStat('PnL (€)', '${isPnlWin ? "+€" : "-€"}${_fmt(pnlEur.abs())}', pnlColor),
|
||||
_buildTradeStat('PnL (%)', '${pnlPercent >= 0 ? "+" : ""}${_fmt(pnlPercent)}%', pnlColor),
|
||||
],
|
||||
),
|
||||
if (trade.closeReason.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text('Grund: ${trade.closeReason}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
// KI Timeline Expansion
|
||||
if (trade.hourlyUpdates.isNotEmpty) ...[
|
||||
// Execution history (replaces the removed AI-Guardian hourly
|
||||
// check-in timeline, which no backend DTO produces anymore —
|
||||
// this is the trade's real fill history instead).
|
||||
if (trade.fills.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
@@ -325,10 +275,10 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
dense: true,
|
||||
leading: Icon(Icons.history_toggle_off, color: AppTheme.accentCyan, size: 18),
|
||||
title: Text(
|
||||
'KI-Guardian Verlauf (${trade.hourlyUpdates.length} Prüfungen)',
|
||||
'Ausführungshistorie (${trade.fills.length} Fills)',
|
||||
style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
children: trade.hourlyUpdates.reversed.take(4).map((u) {
|
||||
children: trade.fills.reversed.map((f) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -339,62 +289,27 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${u.timestamp.hour.toString().padLeft(2, '0')}:${u.timestamp.minute.toString().padLeft(2, '0')}',
|
||||
'${f.executedAtUtc.day.toString().padLeft(2, '0')}.${f.executedAtUtc.month.toString().padLeft(2, '0')} '
|
||||
'${f.executedAtUtc.hour.toString().padLeft(2, '0')}:${f.executedAtUtc.minute.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (u.recommendation.toLowerCase().contains('close')
|
||||
? AppTheme.accentRed
|
||||
: (u.recommendation.toLowerCase().contains('adjust') ? Colors.blue : AppTheme.primaryEmerald))
|
||||
.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(u.recommendation, style: const TextStyle(color: Colors.white70, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
u.reasoning.isNotEmpty ? u.reasoning : 'Kurs: €${u.currentPrice.toStringAsFixed(2)} | VIX: ${u.vixValue.toStringAsFixed(1)}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
'${_fmt(f.quantity)} Stk. @ €${_fmt(f.price)}${f.fee > 0 ? ' (Gebühr €${_fmt(f.fee)})' : ''}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (f.note != null && f.note!.isNotEmpty)
|
||||
Text(f.note!, style: TextStyle(color: AppTheme.textMuted, fontSize: 10, fontStyle: FontStyle.italic)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
|
||||
// AI Analysis Expansion
|
||||
if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
childrenPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text('KI-Analyse & Begründung anzeigen', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
children: [
|
||||
if (reasoning.isNotEmpty) ...[
|
||||
_buildRationaleBlock('Gesamt-Strategie & KI-Entscheidung', reasoning, Colors.white70),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (techRationale.isNotEmpty) ...[
|
||||
_buildRationaleBlock('Technische Analyse', techRationale, Colors.white70),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (fundRationale.isNotEmpty) ...[
|
||||
_buildRationaleBlock('Fundamentale Analyse', fundRationale, Colors.white70),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (riskWarning.isNotEmpty) _buildRationaleBlock('Risikowarnung', riskWarning, AppTheme.accentRed),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -407,11 +322,6 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
IconData icon;
|
||||
|
||||
switch (t.driftStatus) {
|
||||
case DriftStatus.exitAlert:
|
||||
col = AppTheme.accentRed;
|
||||
label = 'Drift-Radar: Ausstieg empfohlen';
|
||||
icon = Icons.warning_rounded;
|
||||
break;
|
||||
case DriftStatus.trailingActive:
|
||||
col = AppTheme.accentCyan;
|
||||
label = 'Drift-Radar: Trailing-Stop aktiv nachgezogen';
|
||||
@@ -424,7 +334,7 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
break;
|
||||
case DriftStatus.onTrack:
|
||||
col = AppTheme.primaryEmerald;
|
||||
label = 'Drift-Radar: Prognose intakt • KI überwacht stündlich';
|
||||
label = 'Drift-Radar: Prognose intakt';
|
||||
icon = Icons.radar;
|
||||
break;
|
||||
}
|
||||
@@ -458,16 +368,4 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRationaleBlock(String title, String text, Color col) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 12)),
|
||||
const SizedBox(height: 2),
|
||||
Text(text, style: TextStyle(color: col, fontSize: 12, height: 1.4)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class CloseTradeDialog {
|
||||
required String defaultSymbol,
|
||||
required void Function(CloseTradeRequestDto) onClose,
|
||||
}) {
|
||||
final entry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice;
|
||||
final entry = trade.averageBuyIn;
|
||||
final exitController = TextEditingController(text: entry.toStringAsFixed(2));
|
||||
|
||||
showDialog(
|
||||
@@ -37,14 +37,20 @@ class CloseTradeDialog {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
|
||||
Text(
|
||||
(trade.derivativeIsin?.isNotEmpty ?? false)
|
||||
? 'Trade-ID: ${trade.id} | Derivat: ${trade.derivativeIsin} (${trade.instrumentType.label}) | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}'
|
||||
: 'Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}',
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: exitController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Tatsächlicher Ausstiegskurs (€)',
|
||||
hintText: 'Z.B. 105.50',
|
||||
decoration: InputDecoration(
|
||||
labelText: (trade.derivativeIsin?.isNotEmpty ?? false) ? 'Derivat-Verkaufskurs (€)' : 'Tatsächlicher Ausstiegskurs (€)',
|
||||
hintText: 'Gekauft zu €${entry.toStringAsFixed(2)}',
|
||||
helperText: (trade.derivativeIsin?.isNotEmpty ?? false) ? 'Gib den Verkaufskurs des Derivats/Zertifikats ein' : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -19,7 +19,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
super(AuthInitial()) {
|
||||
on<CheckAuthStatus>(_onCheckAuthStatus);
|
||||
on<LoginRequested>(_onLoginRequested);
|
||||
on<RegisterRequested>(_onRegisterRequested);
|
||||
on<LogoutRequested>(_onLogoutRequested);
|
||||
}
|
||||
|
||||
@@ -44,16 +43,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRegisterRequested(RegisterRequested event, Emitter<AuthState> emit) async {
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
final user = await repository.register(event.email, event.password, event.fullName);
|
||||
emit(Authenticated(user));
|
||||
} catch (e) {
|
||||
emit(AuthFailure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLogoutRequested(LogoutRequested event, Emitter<AuthState> emit) async {
|
||||
await repository.logout();
|
||||
emit(Unauthenticated());
|
||||
|
||||
@@ -19,15 +19,4 @@ class LoginRequested extends AuthEvent {
|
||||
List<Object?> get props => [email, password];
|
||||
}
|
||||
|
||||
class RegisterRequested extends AuthEvent {
|
||||
final String email;
|
||||
final String password;
|
||||
final String fullName;
|
||||
|
||||
const RegisterRequested(this.email, this.password, this.fullName);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [email, password, fullName];
|
||||
}
|
||||
|
||||
class LogoutRequested extends AuthEvent {}
|
||||
|
||||
@@ -36,11 +36,16 @@ class AuthRepository {
|
||||
'password': password,
|
||||
});
|
||||
if (res.statusCode == 200 && res.data != null) {
|
||||
final token = res.data['token']?.toString() ?? '';
|
||||
|
||||
if (res.data['requiresPasswordChange'] == true) {
|
||||
// The backend already issues a valid JWT even when a password change is required, so it must be
|
||||
// persisted here: the subsequent change-initial-password call is an [Authorize]-protected endpoint
|
||||
// and has no other way to authenticate itself.
|
||||
await storageService.saveToken(token);
|
||||
throw RequiresPasswordChangeException(res.data['userId']?.toString() ?? '');
|
||||
}
|
||||
|
||||
final token = res.data['token']?.toString() ?? '';
|
||||
final user = UserModel.fromJson(res.data, token: token);
|
||||
await storageService.saveToken(token);
|
||||
await storageService.saveUserEmail(user.email);
|
||||
@@ -50,23 +55,6 @@ class AuthRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<UserModel> register(String email, String password, String fullName) async {
|
||||
final res = await apiClient.post('/api/v1/auth/register', data: {
|
||||
'email': email,
|
||||
'password': password,
|
||||
'fullName': fullName,
|
||||
});
|
||||
if (res.statusCode == 200 && res.data != null) {
|
||||
final token = res.data['token']?.toString() ?? '';
|
||||
final user = UserModel.fromJson(res.data, token: token);
|
||||
await storageService.saveToken(token);
|
||||
await storageService.saveUserEmail(user.email);
|
||||
return user;
|
||||
} else {
|
||||
throw Exception('Registrierung fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> changeInitialPassword(String userId, String newPassword) async {
|
||||
final res = await apiClient.post('/api/v1/auth/change-initial-password', data: {
|
||||
'userId': userId,
|
||||
|
||||
@@ -20,6 +20,8 @@ class _ChangeInitialPasswordScreenState extends State<ChangeInitialPasswordScree
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
|
||||
void _onChangePassword() async {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
@@ -96,15 +98,29 @@ class _ChangeInitialPasswordScreenState extends State<ChangeInitialPasswordScree
|
||||
const SizedBox(height: 24),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: 'Neues Passwort', prefixIcon: Icon(Icons.lock)),
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Neues Passwort',
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined),
|
||||
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
validator: (v) => v == null || v.length < 6 ? 'Passwort muss mindestens 6 Zeichen lang sein' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: 'Passwort bestätigen', prefixIcon: Icon(Icons.lock_outline)),
|
||||
obscureText: _obscureConfirmPassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Passwort bestätigen',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscureConfirmPassword ? Icons.visibility_outlined : Icons.visibility_off_outlined),
|
||||
onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
|
||||
),
|
||||
),
|
||||
validator: (v) => v != _passwordController.text ? 'Passwörter stimmen nicht überein' : null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -16,6 +16,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -75,8 +76,15 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: 'Passwort', prefixIcon: Icon(Icons.lock_outline)),
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Passwort',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined),
|
||||
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
validator: (v) => v == null || v.isEmpty ? 'Passwort erforderlich' : null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
@@ -100,6 +108,12 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"Konten werden ausschließlich vom Administrator angelegt.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../bloc/auth_bloc.dart';
|
||||
|
||||
/// User registration screen widget.
|
||||
class RegisterScreen extends StatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
class _RegisterScreenState extends State<RegisterScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
void _submit() {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
context.read<AuthBloc>().add(RegisterRequested(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text.trim(),
|
||||
_nameController.text.trim(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Konto Registrieren')),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.person_add_outlined, size: 48, color: AppTheme.primaryEmerald),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Neues Konto erstellen',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(labelText: 'Vollständiger Name', prefixIcon: Icon(Icons.person_outline)),
|
||||
validator: (v) => v == null || v.isEmpty ? 'Name erforderlich' : null,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(labelText: 'E-Mail', prefixIcon: Icon(Icons.email_outlined)),
|
||||
validator: (v) => v == null || !v.contains('@') ? 'Gültige E-Mail erforderlich' : null,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: 'Passwort', prefixIcon: Icon(Icons.lock_outline)),
|
||||
validator: (v) => v == null || v.length < 6 ? 'Mind. 6 Zeichen' : null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: _submit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryEmerald,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
child: const Text('Registrieren', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/network/signalr_service.dart';
|
||||
import '../models/bot_models.dart';
|
||||
import '../repositories/bot_repository.dart';
|
||||
import 'bot_event.dart';
|
||||
import 'bot_state.dart';
|
||||
|
||||
class BotBloc extends Bloc<BotEvent, BotState> {
|
||||
final BotRepository repository;
|
||||
final SignalRService? signalRService;
|
||||
|
||||
StreamSubscription? _botPositionSub;
|
||||
StreamSubscription? _portfolioSummarySub;
|
||||
|
||||
BotBloc({
|
||||
required this.repository,
|
||||
this.signalRService,
|
||||
}) : super(const BotInitial()) {
|
||||
on<FetchBotDashboard>(_onFetchBotDashboard);
|
||||
on<OnBotPositionStreamReceived>(_onBotPositionStreamReceived);
|
||||
on<OnPortfolioSummaryStreamReceived>(_onPortfolioSummaryStreamReceived);
|
||||
on<TriggerBotPanicClose>(_onTriggerBotPanicClose);
|
||||
on<ExecuteManualBotProposal>(_onExecuteManualBotProposal);
|
||||
on<UpdateBotConfigSettings>(_onUpdateBotConfigSettings);
|
||||
|
||||
_initSignalRListeners();
|
||||
}
|
||||
|
||||
void _initSignalRListeners() {
|
||||
if (signalRService != null) {
|
||||
_botPositionSub = signalRService!.botPositionStream.listen((data) {
|
||||
try {
|
||||
final position = BotTradeOrderModel.fromJson(data);
|
||||
add(OnBotPositionStreamReceived(position));
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
_portfolioSummarySub = signalRService!.portfolioSummaryStream.listen((data) {
|
||||
try {
|
||||
final summary = AccountSummaryModel.fromJson(data);
|
||||
add(OnPortfolioSummaryStreamReceived(summary));
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onFetchBotDashboard(FetchBotDashboard event, Emitter<BotState> emit) async {
|
||||
emit(const BotLoading());
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
repository.fetchStatus(),
|
||||
repository.fetchSummary(),
|
||||
repository.fetchActivePositions(),
|
||||
]);
|
||||
|
||||
final status = results[0] as BotStatusModel;
|
||||
final summary = results[1] as AccountSummaryModel;
|
||||
final positions = results[2] as List<BotTradeOrderModel>;
|
||||
|
||||
emit(BotLoaded(
|
||||
status: status,
|
||||
summary: summary,
|
||||
positions: positions,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(BotError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void _onBotPositionStreamReceived(OnBotPositionStreamReceived event, Emitter<BotState> emit) {
|
||||
if (state is BotLoaded) {
|
||||
final current = state as BotLoaded;
|
||||
final updatedList = List<BotTradeOrderModel>.from(current.positions);
|
||||
|
||||
final index = updatedList.indexWhere((p) => p.orderId == event.position.orderId);
|
||||
if (index != -1) {
|
||||
updatedList[index] = event.position;
|
||||
} else {
|
||||
updatedList.insert(0, event.position);
|
||||
}
|
||||
|
||||
emit(current.copyWith(positions: updatedList));
|
||||
}
|
||||
}
|
||||
|
||||
void _onPortfolioSummaryStreamReceived(OnPortfolioSummaryStreamReceived event, Emitter<BotState> emit) {
|
||||
if (state is BotLoaded) {
|
||||
final current = state as BotLoaded;
|
||||
emit(current.copyWith(summary: event.summary));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onTriggerBotPanicClose(TriggerBotPanicClose event, Emitter<BotState> emit) async {
|
||||
if (state is BotLoaded) {
|
||||
final current = state as BotLoaded;
|
||||
emit(current.copyWith(isPanicClosing: true));
|
||||
|
||||
try {
|
||||
final result = await repository.panicCloseAll();
|
||||
final updatedPositions = await repository.fetchActivePositions();
|
||||
final updatedSummary = await repository.fetchSummary();
|
||||
|
||||
// A partial result (some Alpaca positions could not be confirmed as closed by the broker) must
|
||||
// never be presented as a full success (Rules.md §4) - surface the skipped count explicitly.
|
||||
final message = result.skippedCount > 0
|
||||
? '${result.closedCount} Position(en) geschlossen, aber ${result.skippedCount} konnte(n) NICHT bestätigt geschlossen werden (Broker nicht erreichbar/konfiguriert). Bitte manuell prüfen!'
|
||||
: '${result.closedCount} Position(en) erfolgreich geschlossen.';
|
||||
|
||||
emit(current.copyWith(
|
||||
isPanicClosing: false,
|
||||
positions: updatedPositions,
|
||||
summary: updatedSummary,
|
||||
actionMessage: message,
|
||||
actionIsWarning: result.skippedCount > 0,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(current.copyWith(
|
||||
isPanicClosing: false,
|
||||
actionMessage: 'Fehler beim Notverkauf: $e',
|
||||
actionIsWarning: true,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onExecuteManualBotProposal(ExecuteManualBotProposal event, Emitter<BotState> emit) async {
|
||||
if (state is BotLoaded) {
|
||||
final current = state as BotLoaded;
|
||||
try {
|
||||
final order = await repository.executeProposal(
|
||||
event.proposalId,
|
||||
venue: event.venue,
|
||||
quantity: event.quantity,
|
||||
);
|
||||
|
||||
final updatedList = List<BotTradeOrderModel>.from(current.positions);
|
||||
final index = updatedList.indexWhere((p) => p.orderId == order.orderId);
|
||||
if (index != -1) {
|
||||
updatedList[index] = order;
|
||||
} else {
|
||||
updatedList.insert(0, order);
|
||||
}
|
||||
|
||||
emit(current.copyWith(
|
||||
positions: updatedList,
|
||||
actionMessage: 'Trade ${order.symbol} erfolgreich ausgeführt (${order.venue}).',
|
||||
));
|
||||
} catch (e) {
|
||||
emit(current.copyWith(
|
||||
actionMessage: 'Ausführungsfehler: $e',
|
||||
actionIsWarning: true,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onUpdateBotConfigSettings(UpdateBotConfigSettings event, Emitter<BotState> emit) async {
|
||||
if (state is BotLoaded) {
|
||||
final current = state as BotLoaded;
|
||||
try {
|
||||
final updatedStatus = await repository.updateSettings(
|
||||
autoExecutionEnabled: event.autoExecutionEnabled,
|
||||
maxPositions: event.maxPositions,
|
||||
riskPerTradePercent: event.riskPerTradePercent,
|
||||
minCompositeScore: event.minCompositeScore,
|
||||
);
|
||||
|
||||
emit(current.copyWith(
|
||||
status: updatedStatus,
|
||||
actionMessage: 'Bot-Konfiguration aktualisiert.',
|
||||
));
|
||||
} catch (e) {
|
||||
emit(current.copyWith(
|
||||
actionMessage: 'Fehler beim Speichern der Einstellungen: $e',
|
||||
actionIsWarning: true,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_botPositionSub?.cancel();
|
||||
_portfolioSummarySub?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../models/bot_models.dart';
|
||||
|
||||
abstract class BotEvent extends Equatable {
|
||||
const BotEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class FetchBotDashboard extends BotEvent {
|
||||
const FetchBotDashboard();
|
||||
}
|
||||
|
||||
class OnBotPositionStreamReceived extends BotEvent {
|
||||
final BotTradeOrderModel position;
|
||||
|
||||
const OnBotPositionStreamReceived(this.position);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [position];
|
||||
}
|
||||
|
||||
class OnPortfolioSummaryStreamReceived extends BotEvent {
|
||||
final AccountSummaryModel summary;
|
||||
|
||||
const OnPortfolioSummaryStreamReceived(this.summary);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [summary];
|
||||
}
|
||||
|
||||
class TriggerBotPanicClose extends BotEvent {
|
||||
const TriggerBotPanicClose();
|
||||
}
|
||||
|
||||
class ExecuteManualBotProposal extends BotEvent {
|
||||
final String proposalId;
|
||||
final String? venue;
|
||||
final double? quantity;
|
||||
|
||||
const ExecuteManualBotProposal({
|
||||
required this.proposalId,
|
||||
this.venue,
|
||||
this.quantity,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [proposalId, venue, quantity];
|
||||
}
|
||||
|
||||
class UpdateBotConfigSettings extends BotEvent {
|
||||
final bool? autoExecutionEnabled;
|
||||
final int? maxPositions;
|
||||
final double? riskPerTradePercent;
|
||||
final int? minCompositeScore;
|
||||
|
||||
const UpdateBotConfigSettings({
|
||||
this.autoExecutionEnabled,
|
||||
this.maxPositions,
|
||||
this.riskPerTradePercent,
|
||||
this.minCompositeScore,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [autoExecutionEnabled, maxPositions, riskPerTradePercent, minCompositeScore];
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../models/bot_models.dart';
|
||||
|
||||
abstract class BotState extends Equatable {
|
||||
const BotState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class BotInitial extends BotState {
|
||||
const BotInitial();
|
||||
}
|
||||
|
||||
class BotLoading extends BotState {
|
||||
const BotLoading();
|
||||
}
|
||||
|
||||
class BotLoaded extends BotState {
|
||||
final BotStatusModel status;
|
||||
final AccountSummaryModel summary;
|
||||
final List<BotTradeOrderModel> positions;
|
||||
final bool isPanicClosing;
|
||||
final String? actionMessage;
|
||||
|
||||
/// True when [actionMessage] describes a failure or a partial success (e.g. a panic-close that could not
|
||||
/// confirm every position was closed) rather than a full, unqualified success - the UI must not present
|
||||
/// this the same way as a genuine success (Rules.md §4).
|
||||
final bool actionIsWarning;
|
||||
|
||||
const BotLoaded({
|
||||
required this.status,
|
||||
required this.summary,
|
||||
required this.positions,
|
||||
this.isPanicClosing = false,
|
||||
this.actionMessage,
|
||||
this.actionIsWarning = false,
|
||||
});
|
||||
|
||||
int get activePositionsCount => positions.where((p) => p.isActive).length;
|
||||
double get totalUnrealizedPnL => positions.where((p) => p.isActive).fold(0.0, (sum, p) => sum + p.unrealizedPnlEur);
|
||||
double get totalRealizedPnL => positions.fold(0.0, (sum, p) => sum + p.realizedPnlEur);
|
||||
|
||||
BotLoaded copyWith({
|
||||
BotStatusModel? status,
|
||||
AccountSummaryModel? summary,
|
||||
List<BotTradeOrderModel>? positions,
|
||||
bool? isPanicClosing,
|
||||
String? actionMessage,
|
||||
bool actionIsWarning = false,
|
||||
}) {
|
||||
return BotLoaded(
|
||||
status: status ?? this.status,
|
||||
summary: summary ?? this.summary,
|
||||
positions: positions ?? this.positions,
|
||||
isPanicClosing: isPanicClosing ?? this.isPanicClosing,
|
||||
actionMessage: actionMessage,
|
||||
actionIsWarning: actionIsWarning,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, summary, positions, isPanicClosing, actionMessage, actionIsWarning];
|
||||
}
|
||||
|
||||
class BotError extends BotState {
|
||||
final String message;
|
||||
|
||||
const BotError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
enum BotExecutionVenue {
|
||||
alpacaPaperTrading,
|
||||
syntheticPaperBroker,
|
||||
}
|
||||
|
||||
enum BotPositionStatus {
|
||||
pending,
|
||||
active,
|
||||
breakEvenTriggered,
|
||||
tp1Hit,
|
||||
tp2Hit,
|
||||
closed,
|
||||
stoppedOut,
|
||||
knockedOut,
|
||||
canceled,
|
||||
}
|
||||
|
||||
class BotTradeOrderModel extends Equatable {
|
||||
final String orderId;
|
||||
final String proposalId;
|
||||
final String isin;
|
||||
final String symbol;
|
||||
final String venue;
|
||||
final String direction;
|
||||
final double requestedQuantity;
|
||||
final double filledQuantity;
|
||||
final double entryPrice;
|
||||
final double averageBuyIn;
|
||||
final double initialStopLoss;
|
||||
final double currentStopLoss;
|
||||
final double takeProfit1;
|
||||
final double takeProfit2;
|
||||
final double currentPrice;
|
||||
final double unrealizedPnlEur;
|
||||
final double realizedPnlEur;
|
||||
final String status;
|
||||
final DateTime createdAt;
|
||||
final DateTime? filledAt;
|
||||
final DateTime? closedAt;
|
||||
|
||||
const BotTradeOrderModel({
|
||||
required this.orderId,
|
||||
required this.proposalId,
|
||||
required this.isin,
|
||||
required this.symbol,
|
||||
required this.venue,
|
||||
required this.direction,
|
||||
required this.requestedQuantity,
|
||||
required this.filledQuantity,
|
||||
required this.entryPrice,
|
||||
required this.averageBuyIn,
|
||||
required this.initialStopLoss,
|
||||
required this.currentStopLoss,
|
||||
required this.takeProfit1,
|
||||
required this.takeProfit2,
|
||||
required this.currentPrice,
|
||||
required this.unrealizedPnlEur,
|
||||
required this.realizedPnlEur,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
this.filledAt,
|
||||
this.closedAt,
|
||||
});
|
||||
|
||||
bool get isLong => direction.toUpperCase() == 'BUY' || direction.toUpperCase() == 'LONG';
|
||||
bool get isActive => status.toLowerCase() == 'active' || status.toLowerCase() == 'breakeventriggered' || status.toLowerCase() == 'tp1hit';
|
||||
bool get isBreakEven => status.toLowerCase() == 'breakeventriggered';
|
||||
bool get isTp1Hit => status.toLowerCase() == 'tp1hit';
|
||||
bool get isClosed => status.toLowerCase() == 'closed' || status.toLowerCase() == 'stoppedout' || status.toLowerCase() == 'knockedout';
|
||||
|
||||
double get pnlPercent {
|
||||
if (averageBuyIn <= 0) return 0.0;
|
||||
return isLong
|
||||
? ((currentPrice - averageBuyIn) / averageBuyIn) * 100.0
|
||||
: ((averageBuyIn - currentPrice) / averageBuyIn) * 100.0;
|
||||
}
|
||||
|
||||
double get rMultiple {
|
||||
final risk = (entryPrice - initialStopLoss).abs();
|
||||
if (risk <= 0) return 0.0;
|
||||
final reward = isLong ? (currentPrice - entryPrice) : (entryPrice - currentPrice);
|
||||
return reward / risk;
|
||||
}
|
||||
|
||||
factory BotTradeOrderModel.fromJson(Map<String, dynamic> json) {
|
||||
return BotTradeOrderModel(
|
||||
orderId: json['orderId']?.toString() ?? '',
|
||||
proposalId: json['proposalId']?.toString() ?? '',
|
||||
isin: json['isin']?.toString() ?? '',
|
||||
symbol: json['symbol']?.toString() ?? '',
|
||||
venue: json['venue']?.toString() ?? 'SyntheticPaperBroker',
|
||||
direction: json['direction']?.toString() ?? 'BUY',
|
||||
requestedQuantity: (json['requestedQuantity'] as num?)?.toDouble() ?? 0.0,
|
||||
filledQuantity: (json['filledQuantity'] as num?)?.toDouble() ?? 0.0,
|
||||
entryPrice: (json['entryPrice'] as num?)?.toDouble() ?? 0.0,
|
||||
averageBuyIn: (json['averageBuyIn'] as num?)?.toDouble() ?? 0.0,
|
||||
initialStopLoss: (json['initialStopLoss'] as num?)?.toDouble() ?? 0.0,
|
||||
currentStopLoss: (json['currentStopLoss'] as num?)?.toDouble() ?? 0.0,
|
||||
takeProfit1: (json['takeProfit1'] as num?)?.toDouble() ?? 0.0,
|
||||
takeProfit2: (json['takeProfit2'] as num?)?.toDouble() ?? 0.0,
|
||||
currentPrice: (json['currentPrice'] as num?)?.toDouble() ?? 0.0,
|
||||
unrealizedPnlEur: (json['unrealizedPnlEur'] as num?)?.toDouble() ?? 0.0,
|
||||
realizedPnlEur: (json['realizedPnlEur'] as num?)?.toDouble() ?? 0.0,
|
||||
status: json['status']?.toString() ?? 'Active',
|
||||
createdAt: json['createdAtUtc'] != null ? DateTime.tryParse(json['createdAtUtc'].toString()) ?? DateTime.now() : DateTime.now(),
|
||||
filledAt: json['filledAtUtc'] != null ? DateTime.tryParse(json['filledAtUtc'].toString()) : null,
|
||||
closedAt: json['closedAtUtc'] != null ? DateTime.tryParse(json['closedAtUtc'].toString()) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
orderId, proposalId, isin, symbol, venue, direction,
|
||||
requestedQuantity, filledQuantity, entryPrice, averageBuyIn,
|
||||
currentPrice, unrealizedPnlEur, realizedPnlEur, status, currentStopLoss
|
||||
];
|
||||
}
|
||||
|
||||
class AccountSummaryModel extends Equatable {
|
||||
/// Nullable: a `null` value means the server did not report this field
|
||||
/// (e.g. broker/account service unavailable). The UI MUST show an explicit
|
||||
/// "not available" state in that case rather than a fabricated number
|
||||
/// (Rules.md §4).
|
||||
final double? equity;
|
||||
final double? cash;
|
||||
final double? buyingPower;
|
||||
final String currency;
|
||||
final String status;
|
||||
|
||||
const AccountSummaryModel({
|
||||
required this.equity,
|
||||
required this.cash,
|
||||
required this.buyingPower,
|
||||
required this.currency,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
bool get hasAccountData => equity != null && buyingPower != null;
|
||||
|
||||
factory AccountSummaryModel.fromJson(Map<String, dynamic> json) {
|
||||
return AccountSummaryModel(
|
||||
equity: (json['equity'] as num?)?.toDouble(),
|
||||
cash: (json['cash'] as num?)?.toDouble(),
|
||||
buyingPower: (json['buyingPower'] as num?)?.toDouble(),
|
||||
currency: json['currency']?.toString() ?? 'EUR',
|
||||
status: json['status']?.toString() ?? 'Active',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [equity, cash, buyingPower, currency, status];
|
||||
}
|
||||
|
||||
/// Result of an emergency "panic close" (`POST /api/v1/bot/orders/panic-close`). [skippedCount] is non-zero
|
||||
/// whenever an Alpaca position could not be confirmed as liquidated by the broker (not configured, or the
|
||||
/// broker call failed) - the UI MUST surface that count rather than only celebrating [closedCount] as if the
|
||||
/// whole operation fully succeeded (Rules.md §4: no fabricated full success on a partial result).
|
||||
class PanicCloseResultModel extends Equatable {
|
||||
final int closedCount;
|
||||
final int skippedCount;
|
||||
final List<BotTradeOrderModel> closedOrders;
|
||||
|
||||
const PanicCloseResultModel({
|
||||
required this.closedCount,
|
||||
required this.skippedCount,
|
||||
required this.closedOrders,
|
||||
});
|
||||
|
||||
factory PanicCloseResultModel.fromJson(Map<String, dynamic> json) {
|
||||
final List<dynamic> orders = json['closedOrders'] as List<dynamic>? ?? const [];
|
||||
return PanicCloseResultModel(
|
||||
closedCount: (json['closedCount'] as num?)?.toInt() ?? 0,
|
||||
skippedCount: (json['skippedCount'] as num?)?.toInt() ?? 0,
|
||||
closedOrders: orders.map((o) => BotTradeOrderModel.fromJson(o as Map<String, dynamic>)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [closedCount, skippedCount, closedOrders];
|
||||
}
|
||||
|
||||
class BotStatusModel extends Equatable {
|
||||
final bool isRunning;
|
||||
final bool autoExecutionEnabled;
|
||||
final int activePositionsCount;
|
||||
final int maxPositions;
|
||||
final double riskPerTradePercent;
|
||||
final int minCompositeScore;
|
||||
final String venuesActive;
|
||||
|
||||
const BotStatusModel({
|
||||
required this.isRunning,
|
||||
required this.autoExecutionEnabled,
|
||||
required this.activePositionsCount,
|
||||
required this.maxPositions,
|
||||
required this.riskPerTradePercent,
|
||||
required this.minCompositeScore,
|
||||
required this.venuesActive,
|
||||
});
|
||||
|
||||
factory BotStatusModel.fromJson(Map<String, dynamic> json) {
|
||||
return BotStatusModel(
|
||||
isRunning: json['isRunning'] == true,
|
||||
autoExecutionEnabled: json['autoExecutionEnabled'] == true,
|
||||
activePositionsCount: (json['activePositionsCount'] as num?)?.toInt() ?? 0,
|
||||
maxPositions: (json['maxPositions'] as num?)?.toInt() ?? 5,
|
||||
riskPerTradePercent: (json['riskPerTradePercent'] as num?)?.toDouble() ?? 1.0,
|
||||
minCompositeScore: (json['minCompositeScore'] as num?)?.toInt() ?? 75,
|
||||
venuesActive: json['venuesActive']?.toString() ?? 'AlpacaPaper/Synthetic',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
isRunning, autoExecutionEnabled, activePositionsCount,
|
||||
maxPositions, riskPerTradePercent, minCompositeScore, venuesActive
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:async';
|
||||
import 'package:finlytic_app/core/network/api_client.dart';
|
||||
import '../models/bot_models.dart';
|
||||
|
||||
class BotRepository {
|
||||
final ApiClient apiClient;
|
||||
|
||||
const BotRepository({required this.apiClient});
|
||||
|
||||
Future<BotStatusModel> fetchStatus() async {
|
||||
final response = await apiClient.get('/api/v1/bot/status');
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
return BotStatusModel.fromJson(response.data);
|
||||
}
|
||||
throw Exception('Failed to fetch bot status');
|
||||
}
|
||||
|
||||
Future<AccountSummaryModel> fetchSummary() async {
|
||||
final response = await apiClient.get('/api/v1/bot/portfolio/summary');
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
return AccountSummaryModel.fromJson(response.data);
|
||||
}
|
||||
throw Exception('Failed to fetch account summary');
|
||||
}
|
||||
|
||||
Future<List<BotTradeOrderModel>> fetchActivePositions() async {
|
||||
final response = await apiClient.get('/api/v1/bot/positions/active');
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final List<dynamic> list = response.data;
|
||||
return list.map((json) => BotTradeOrderModel.fromJson(json)).toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<BotTradeOrderModel> executeProposal(String proposalId, {String? venue, double? quantity}) async {
|
||||
final response = await apiClient.post('/api/v1/bot/orders/execute', data: {
|
||||
'proposalId': proposalId,
|
||||
if (venue != null) 'preferredVenue': venue,
|
||||
if (quantity != null) 'customQuantity': quantity,
|
||||
});
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
return BotTradeOrderModel.fromJson(response.data);
|
||||
}
|
||||
throw Exception('Failed to execute bot proposal');
|
||||
}
|
||||
|
||||
Future<PanicCloseResultModel> panicCloseAll() async {
|
||||
final response = await apiClient.post('/api/v1/bot/orders/panic-close');
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
return PanicCloseResultModel.fromJson(response.data);
|
||||
}
|
||||
// A non-200 (e.g. 503 when FinlyticBot is unreachable) must NOT be swallowed into a fake "0
|
||||
// closed / 0 skipped" result for an emergency action - the caller needs to know the attempt did
|
||||
// not even go through (Rules.md §4).
|
||||
throw Exception('Failed to trigger panic close');
|
||||
}
|
||||
|
||||
/// Updates FinlyticBot's dynamic settings. The backend now responds with the raw list of persisted
|
||||
/// `DynamicSettingDto` entries (see FinlyticBackend BotController.UpdateBotSettings), not a BotStatusModel,
|
||||
/// so the canonical status is re-fetched afterward instead of being reconstructed from that list.
|
||||
Future<BotStatusModel> updateSettings({
|
||||
bool? autoExecutionEnabled,
|
||||
int? maxPositions,
|
||||
double? riskPerTradePercent,
|
||||
int? minCompositeScore,
|
||||
}) async {
|
||||
final response = await apiClient.post('/api/v1/bot/settings/update', data: {
|
||||
if (autoExecutionEnabled != null) 'autoExecutionEnabled': autoExecutionEnabled,
|
||||
if (maxPositions != null) 'maxPositions': maxPositions,
|
||||
if (riskPerTradePercent != null) 'riskPerTradePercent': riskPerTradePercent,
|
||||
if (minCompositeScore != null) 'minCompositeScore': minCompositeScore,
|
||||
});
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to update bot settings');
|
||||
}
|
||||
return fetchStatus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/signalr_service.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../bloc/bot_bloc.dart';
|
||||
import '../bloc/bot_event.dart';
|
||||
import '../bloc/bot_state.dart';
|
||||
import '../repositories/bot_repository.dart';
|
||||
import '../widgets/bot_kpi_header.dart';
|
||||
import '../widgets/bot_position_card.dart';
|
||||
import '../widgets/bot_settings_sheet.dart';
|
||||
|
||||
class BotControlPanelScreen extends StatelessWidget {
|
||||
final ApiClient apiClient;
|
||||
final SignalRService signalRService;
|
||||
|
||||
const BotControlPanelScreen({
|
||||
super.key,
|
||||
required this.apiClient,
|
||||
required this.signalRService,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => BotBloc(
|
||||
repository: BotRepository(apiClient: apiClient),
|
||||
signalRService: signalRService,
|
||||
)..add(const FetchBotDashboard()),
|
||||
child: const _BotControlPanelContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BotControlPanelContent extends StatelessWidget {
|
||||
const _BotControlPanelContent();
|
||||
|
||||
void _openSettings(BuildContext context, BotLoaded state) {
|
||||
final botBloc = context.read<BotBloc>();
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => BotSettingsSheet(
|
||||
currentStatus: state.status,
|
||||
onSave: (autoExec, maxPos, risk, minScore) {
|
||||
botBloc.add(UpdateBotConfigSettings(
|
||||
autoExecutionEnabled: autoExec,
|
||||
maxPositions: maxPos,
|
||||
riskPerTradePercent: risk,
|
||||
minCompositeScore: minScore,
|
||||
));
|
||||
},
|
||||
onPanicClose: () {
|
||||
botBloc.add(const TriggerBotPanicClose());
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.darkBackground,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: const Text('FinlyticBot Control Panel', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||
actions: [
|
||||
BlocBuilder<BotBloc, BotState>(
|
||||
builder: (context, state) {
|
||||
if (state is BotLoaded) {
|
||||
return IconButton(
|
||||
onPressed: () => _openSettings(context, state),
|
||||
icon: const Icon(Icons.settings, color: Colors.white70),
|
||||
tooltip: 'Bot Einstellungen & Kill-Switch',
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.read<BotBloc>().add(const FetchBotDashboard()),
|
||||
icon: const Icon(Icons.refresh, color: Colors.white70),
|
||||
tooltip: 'Neu laden',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: BlocConsumer<BotBloc, BotState>(
|
||||
listener: (context, state) {
|
||||
if (state is BotLoaded && state.actionMessage != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.actionMessage!),
|
||||
// A failed or partially-successful action (e.g. a panic-close that could not confirm every
|
||||
// position was closed) must never be shown in the same "all good" green as a full success
|
||||
// (Rules.md §4).
|
||||
backgroundColor: state.actionIsWarning ? AppTheme.accentRed : AppTheme.primaryEmerald,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is BotLoading) {
|
||||
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
|
||||
}
|
||||
|
||||
if (state is BotError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: AppTheme.accentRed),
|
||||
const SizedBox(height: 12),
|
||||
Text(state.message, style: TextStyle(color: AppTheme.textMuted)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.read<BotBloc>().add(const FetchBotDashboard()),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald),
|
||||
child: const Text('Erneut Versuchen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is BotLoaded) {
|
||||
final activePositions = state.positions.where((p) => p.isActive).toList();
|
||||
final closedPositions = state.positions.where((p) => p.isClosed).toList();
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<BotBloc>().add(const FetchBotDashboard());
|
||||
},
|
||||
color: AppTheme.primaryEmerald,
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
BotKpiHeader(
|
||||
summary: state.summary,
|
||||
status: state.status,
|
||||
totalUnrealizedPnL: state.totalUnrealizedPnL,
|
||||
totalRealizedPnL: state.totalRealizedPnL,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildAlphaDecayMonitor(state),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Aktive Positionen (${activePositions.length}/${state.status.maxPositions})',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
|
||||
),
|
||||
child: Text(
|
||||
'Risk: ${(activePositions.length * state.status.riskPerTradePercent).toStringAsFixed(1)}%',
|
||||
style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
]),
|
||||
),
|
||||
),
|
||||
if (activePositions.isEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: AppTheme.cardSurface.withValues(alpha: 0.5),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.radar, size: 48, color: AppTheme.textMuted),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Keine aktiven Positionen',
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Der Bot scannt das Universum nach Setup-Konfluenzen ab Score ≥ ${state.status.minCompositeScore}.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: activePositions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final position = activePositions[index];
|
||||
return BotPositionCard(
|
||||
position: position,
|
||||
onClosePressed: () {
|
||||
// Manual emergency close of this specific position
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (closedPositions.isNotEmpty) ...[
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: const Text(
|
||||
'Kürzlich Geschlossene Trades',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white70),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: closedPositions.length > 5 ? 5 : closedPositions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final position = closedPositions[index];
|
||||
return Opacity(
|
||||
opacity: 0.7,
|
||||
child: BotPositionCard(position: position),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: This used to be an "Alpha-Decay & Strategy Reliability Monitor"
|
||||
// comparing hardcoded fake "Live vs Simulation" win rates / profit factors
|
||||
// per strategy (Rules.md §4 violation). There is no real data source for
|
||||
// that comparison: `GET /api/v1/simulation/matrix/{isin}`
|
||||
// (`StrategyAssetReliabilityDto`) only provides a simulated reliability
|
||||
// score per (ISIN, StrategyKey) pair — it has no "live" counterpart, and
|
||||
// this screen isn't scoped to a single asset, so there's no ISIN to query
|
||||
// it with in the first place. Rather than inventing numbers, or bolting on
|
||||
// an asset picker that isn't part of this task, this is now an explicit
|
||||
// empty state until a real live-vs-simulation data source exists.
|
||||
Widget _buildAlphaDecayMonitor(BotLoaded state) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: AppTheme.cardSurface,
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.analytics_outlined, size: 16, color: Colors.cyanAccent),
|
||||
SizedBox(width: 6),
|
||||
Text('Alpha-Decay & Strategy Reliability Monitor', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Keine Live-vs-Simulation-Daten verfügbar.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../models/bot_models.dart';
|
||||
|
||||
class BotKpiHeader extends StatelessWidget {
|
||||
final AccountSummaryModel summary;
|
||||
final BotStatusModel status;
|
||||
final double totalUnrealizedPnL;
|
||||
final double totalRealizedPnL;
|
||||
|
||||
const BotKpiHeader({
|
||||
super.key,
|
||||
required this.summary,
|
||||
required this.status,
|
||||
required this.totalUnrealizedPnL,
|
||||
required this.totalRealizedPnL,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isPositiveUnrealized = totalUnrealizedPnL >= 0;
|
||||
final isPositiveRealized = totalRealizedPnL >= 0;
|
||||
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: status.isRunning ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: (status.isRunning ? AppTheme.primaryEmerald : AppTheme.accentRed).withValues(alpha: 0.5),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
status.isRunning ? 'AUTONOMOUS BOT ONLINE' : 'BOT PAUSED',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
color: status.isRunning ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: status.autoExecutionEnabled ? AppTheme.primaryEmerald.withValues(alpha: 0.15) : Colors.amber.withValues(alpha: 0.15),
|
||||
border: Border.all(
|
||||
color: status.autoExecutionEnabled ? AppTheme.primaryEmerald : Colors.amber,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
status.autoExecutionEnabled ? 'AUTO-EXECUTE ON' : 'MANUAL APPROVAL',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: status.autoExecutionEnabled ? AppTheme.primaryEmerald : Colors.amber,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: summary.hasAccountData
|
||||
? _buildMetricCard(
|
||||
title: 'Portfolio Equity',
|
||||
value: '€${summary.equity!.toStringAsFixed(2)}',
|
||||
subtitle: 'Buying Power: €${summary.buyingPower!.toStringAsFixed(0)}',
|
||||
valueColor: Colors.white,
|
||||
)
|
||||
: _buildUnavailableMetricCard('Portfolio Equity'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildMetricCard(
|
||||
title: 'Unrealized PnL',
|
||||
value: '${isPositiveUnrealized ? '+' : ''}€${totalUnrealizedPnL.toStringAsFixed(2)}',
|
||||
subtitle: 'Realized: ${isPositiveRealized ? '+' : ''}€${totalRealizedPnL.toStringAsFixed(2)}',
|
||||
valueColor: isPositiveUnrealized ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUnavailableMetricCard(String title) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white.withValues(alpha: 0.03),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 14, color: AppTheme.textMuted),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Portfoliodaten nicht verfügbar',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12, fontWeight: FontWeight.w600),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricCard({
|
||||
required String title,
|
||||
required String value,
|
||||
required String subtitle,
|
||||
required Color valueColor,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white.withValues(alpha: 0.03),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: TextStyle(color: valueColor, fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/asset_logo_widget.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../models/bot_models.dart';
|
||||
|
||||
class BotPositionCard extends StatelessWidget {
|
||||
final BotTradeOrderModel position;
|
||||
final VoidCallback? onClosePressed;
|
||||
|
||||
const BotPositionCard({
|
||||
super.key,
|
||||
required this.position,
|
||||
this.onClosePressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLong = position.isLong;
|
||||
final isProfitable = position.unrealizedPnlEur >= 0;
|
||||
final pnlPercent = position.pnlPercent;
|
||||
final rMult = position.rMultiple;
|
||||
|
||||
return GlassContainer(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
AssetLogoWidget(
|
||||
symbolOrName: position.symbol.isNotEmpty ? position.symbol : position.isin,
|
||||
imageUrl: '/api/v1/logo/${position.isin}',
|
||||
size: 36,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
position.symbol,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: isLong ? AppTheme.primaryEmerald.withValues(alpha: 0.2) : AppTheme.accentRed.withValues(alpha: 0.2),
|
||||
),
|
||||
child: Text(
|
||||
position.direction.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isLong ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_buildVenueBadge(position.venue),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
position.isin,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${isProfitable ? '+' : ''}€${position.unrealizedPnlEur.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isProfitable ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${isProfitable ? '+' : ''}${pnlPercent.toStringAsFixed(2)}% (${rMult >= 0 ? '+' : ''}${rMult.toStringAsFixed(1)}R)',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isProfitable ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildPriceInfo('Entry / Buy-In', '€${position.averageBuyIn > 0 ? position.averageBuyIn.toStringAsFixed(2) : position.entryPrice.toStringAsFixed(2)}'),
|
||||
_buildPriceInfo('Current Price', '€${position.currentPrice.toStringAsFixed(2)}'),
|
||||
_buildPriceInfo('Stop Loss', '€${position.currentStopLoss.toStringAsFixed(2)}'),
|
||||
_buildPriceInfo('Take Profit 1', '€${position.takeProfit1.toStringAsFixed(2)}'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildDynamicStateBadge(position),
|
||||
if (position.isActive && onClosePressed != null)
|
||||
TextButton.icon(
|
||||
onPressed: onClosePressed,
|
||||
icon: Icon(Icons.close, size: 14, color: AppTheme.accentRed),
|
||||
label: Text('Glattstellen', style: TextStyle(fontSize: 12, color: AppTheme.accentRed)),
|
||||
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPriceInfo(String label, String value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
|
||||
const SizedBox(height: 2),
|
||||
Text(value, style: const TextStyle(color: Colors.white70, fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVenueBadge(String venue) {
|
||||
final isAlpaca = venue.toLowerCase().contains('alpaca');
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: isAlpaca ? Colors.blue.withValues(alpha: 0.15) : Colors.purple.withValues(alpha: 0.15),
|
||||
),
|
||||
child: Text(
|
||||
isAlpaca ? 'Alpaca US' : 'Synthetic KO',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isAlpaca ? Colors.lightBlueAccent : Colors.purpleAccent,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDynamicStateBadge(BotTradeOrderModel position) {
|
||||
String text = 'Pending TP1';
|
||||
Color color = Colors.amber;
|
||||
|
||||
if (position.isBreakEven) {
|
||||
text = 'Free-Roll Active (BE)';
|
||||
color = AppTheme.primaryEmerald;
|
||||
} else if (position.isTp1Hit) {
|
||||
text = 'TP1 Hit (Trailing Active)';
|
||||
color = Colors.cyanAccent;
|
||||
} else if (position.isClosed) {
|
||||
text = 'Closed';
|
||||
color = Colors.grey;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: color.withValues(alpha: 0.15),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.bolt, size: 12, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../models/bot_models.dart';
|
||||
|
||||
class BotSettingsSheet extends StatefulWidget {
|
||||
final BotStatusModel currentStatus;
|
||||
final Function(bool autoExec, int maxPos, double risk, int minScore) onSave;
|
||||
final VoidCallback onPanicClose;
|
||||
|
||||
const BotSettingsSheet({
|
||||
super.key,
|
||||
required this.currentStatus,
|
||||
required this.onSave,
|
||||
required this.onPanicClose,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BotSettingsSheet> createState() => _BotSettingsSheetState();
|
||||
}
|
||||
|
||||
class _BotSettingsSheetState extends State<BotSettingsSheet> {
|
||||
late bool _autoExec;
|
||||
late int _maxPositions;
|
||||
late double _riskPerTrade;
|
||||
late int _minScore;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_autoExec = widget.currentStatus.autoExecutionEnabled;
|
||||
_maxPositions = widget.currentStatus.maxPositions;
|
||||
_riskPerTrade = widget.currentStatus.riskPerTradePercent;
|
||||
_minScore = widget.currentStatus.minCompositeScore;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Bot Konfiguration & Kill-Switch',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
title: const Text('Automatische Ausführung (Auto-Trade)', style: TextStyle(color: Colors.white)),
|
||||
subtitle: Text('Führt geprüfte Signale ab Score ≥ $_minScore automatisch aus', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
|
||||
value: _autoExec,
|
||||
activeThumbColor: AppTheme.primaryEmerald,
|
||||
onChanged: (val) => setState(() => _autoExec = val),
|
||||
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('Max. Parallele Positionen: $_maxPositions', style: const TextStyle(color: Colors.white70)),
|
||||
Slider(
|
||||
value: _maxPositions.toDouble(),
|
||||
min: 1,
|
||||
max: 10,
|
||||
divisions: 9,
|
||||
activeColor: AppTheme.primaryEmerald,
|
||||
label: '$_maxPositions',
|
||||
onChanged: (val) => setState(() => _maxPositions = val.toInt()),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('Risiko pro Trade: ${_riskPerTrade.toStringAsFixed(1)}% des Portfolios', style: const TextStyle(color: Colors.white70)),
|
||||
Slider(
|
||||
value: _riskPerTrade,
|
||||
min: 0.2,
|
||||
max: 3.0,
|
||||
divisions: 28,
|
||||
activeColor: AppTheme.primaryEmerald,
|
||||
label: '${_riskPerTrade.toStringAsFixed(1)}%',
|
||||
onChanged: (val) => setState(() => _riskPerTrade = val),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('Mindest-Score für Einstieg: $_minScore Punkte', style: const TextStyle(color: Colors.white70)),
|
||||
Slider(
|
||||
value: _minScore.toDouble(),
|
||||
min: 60,
|
||||
max: 95,
|
||||
divisions: 35,
|
||||
activeColor: AppTheme.primaryEmerald,
|
||||
label: '$_minScore Pkt',
|
||||
onChanged: (val) => setState(() => _minScore = val.toInt()),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
widget.onSave(_autoExec, _maxPositions, _riskPerTrade, _minScore);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Speichern'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryEmerald,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_showPanicConfirmation(context);
|
||||
},
|
||||
icon: const Icon(Icons.warning, color: Colors.white),
|
||||
label: const Text('PANIC CLOSE', style: TextStyle(color: Colors.white)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentRed,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPanicConfirmation(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
title: Text('🚨 Notverkauf bestätigen', style: TextStyle(color: AppTheme.accentRed)),
|
||||
|
||||
content: const Text(
|
||||
'Möchtest du wirklich SOFORT alle offenen Bot-Positionen schließen? Dieser Vorgang kann nicht rückgängig gemacht werden.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Abbrechen', style: TextStyle(color: Colors.white70)),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
widget.onPanicClose();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentRed),
|
||||
child: const Text('ALLE POSITIONEN SCHLIESSEN'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class DashboardScreen extends StatelessWidget {
|
||||
const SizedBox(height: 24),
|
||||
TradesStreamWidget(apiClient: apiClient),
|
||||
const SizedBox(height: 24),
|
||||
DailyNewsSnapshot(apiClient: apiClient),
|
||||
DailyNewsSnapshot(apiClient: apiClient, signalRService: signalRService),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/signalr_service.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/time_utils.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
@@ -14,11 +15,13 @@ import '../../news/widgets/article_sentiment_dialog.dart';
|
||||
|
||||
class DailyNewsSnapshot extends StatelessWidget {
|
||||
final ApiClient apiClient;
|
||||
final SignalRService? signalRService;
|
||||
final String? backendUrl;
|
||||
|
||||
const DailyNewsSnapshot({
|
||||
super.key,
|
||||
required this.apiClient,
|
||||
this.signalRService,
|
||||
this.backendUrl,
|
||||
});
|
||||
|
||||
@@ -26,7 +29,11 @@ class DailyNewsSnapshot extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => NewsBloc(
|
||||
repository: NewsRepository(apiClient: apiClient, backendUrl: backendUrl ?? ApiClient.baseUrl),
|
||||
repository: NewsRepository(
|
||||
apiClient: apiClient,
|
||||
backendUrl: backendUrl ?? ApiClient.baseUrl,
|
||||
signalRService: signalRService,
|
||||
),
|
||||
)..add(FetchNews(date: DateTime.now().toIso8601String().substring(0, 10))),
|
||||
child: const _DailyNewsSnapshotContent(),
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ class FavoritesCarousel extends StatelessWidget {
|
||||
children: [
|
||||
AssetLogoWidget(
|
||||
symbolOrName: isin,
|
||||
imageUrl: fav.image.isNotEmpty ? fav.image : null,
|
||||
imageUrl: isin.isNotEmpty ? '/api/v1/logo/$isin' : (fav.image.isNotEmpty ? fav.image : null),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
@@ -101,7 +101,7 @@ class _TradesStreamWidgetContent extends StatelessWidget {
|
||||
itemCount: proposals.length,
|
||||
itemBuilder: (context, index) {
|
||||
final p = proposals[index];
|
||||
final isBuy = p.signalType.toUpperCase() == 'BUY' || p.signalType.toUpperCase() == 'LONG';
|
||||
final isBuy = p.direction.isLong;
|
||||
final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
|
||||
return GestureDetector(
|
||||
@@ -110,8 +110,8 @@ class _TradesStreamWidgetContent extends StatelessWidget {
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (ctx) => AssetDetailScreen(
|
||||
isin: p.isin,
|
||||
name: p.companyName,
|
||||
isin: p.underlyingIsin,
|
||||
name: p.symbol,
|
||||
symbol: p.symbol.isNotEmpty ? p.symbol : null,
|
||||
apiClient: context.read<TradeBloc>().repository.apiClient,
|
||||
),
|
||||
@@ -158,35 +158,24 @@ class _TradesStreamWidgetContent extends StatelessWidget {
|
||||
border: Border.all(color: signalColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Text(
|
||||
p.signalType,
|
||||
p.direction.label,
|
||||
style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (p.companyName.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
p.companyName,
|
||||
p.underlyingIsin,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
if (p.reasoning.isNotEmpty) ...[
|
||||
Text(
|
||||
p.reasoning,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11, fontStyle: FontStyle.italic),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'KI Score: ${(p.winRate).toStringAsFixed(0)}%',
|
||||
p.instrumentType.label,
|
||||
style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.w600, fontSize: 13),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
@@ -51,7 +51,7 @@ class WatchlistCard extends StatelessWidget {
|
||||
children: [
|
||||
AssetLogoWidget(
|
||||
symbolOrName: asset.isin.isNotEmpty ? asset.isin : displayName,
|
||||
imageUrl: asset.image.isNotEmpty ? asset.image : null,
|
||||
imageUrl: asset.isin.isNotEmpty ? '/api/v1/logo/${asset.isin}' : (asset.image.isNotEmpty ? asset.image : null),
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
@@ -14,13 +14,11 @@ class NewsBloc extends Bloc<NewsEvent, NewsState> {
|
||||
on<LoadMoreNews>(_onLoadMoreNews);
|
||||
on<ReceiveLiveNews>(_onReceiveLiveNews);
|
||||
|
||||
// Subscribe to live news from SignalR
|
||||
// Subscribe to live news pushed over the central SignalRService's
|
||||
// `/hubs/news` connection (authenticated, managed in main.dart).
|
||||
_liveNewsSubscription = repository.liveNewsStream.listen((article) {
|
||||
add(ReceiveLiveNews(article));
|
||||
});
|
||||
|
||||
// Connect to SignalR
|
||||
repository.connectToLiveFeed();
|
||||
}
|
||||
|
||||
Future<void> _onFetchNews(FetchNews event, Emitter<NewsState> emit) async {
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:signalr_core/signalr_core.dart';
|
||||
import 'package:finlytic_app/core/network/api_client.dart';
|
||||
import 'package:finlytic_app/core/network/signalr_service.dart';
|
||||
import 'package:finlytic_app/features/news/models/news_article_model.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class NewsRepository {
|
||||
final ApiClient apiClient;
|
||||
final String backendUrl;
|
||||
final FlutterSecureStorage secureStorage = const FlutterSecureStorage();
|
||||
|
||||
HubConnection? _hubConnection;
|
||||
final _liveNewsController = StreamController<NewsArticleModel>.broadcast();
|
||||
/// Central Real-Time WebSocket service (shared, authenticated `/hubs/news` connection).
|
||||
/// May be null for call-sites that only need REST access (e.g. one-off dialogs) and
|
||||
/// don't require the live feed; in that case [liveNewsStream] yields no events.
|
||||
final SignalRService? signalRService;
|
||||
|
||||
Stream<NewsArticleModel> get liveNewsStream => _liveNewsController.stream;
|
||||
NewsRepository({
|
||||
required this.apiClient,
|
||||
required this.backendUrl,
|
||||
this.signalRService,
|
||||
});
|
||||
|
||||
NewsRepository({required this.apiClient, required this.backendUrl});
|
||||
/// Live news articles pushed by the central SignalRService's `/hubs/news` connection.
|
||||
/// The connection itself is established centrally (see `SignalRService.initSignalR()`),
|
||||
/// so this repository only maps the already-authenticated stream to typed models.
|
||||
Stream<NewsArticleModel> get liveNewsStream {
|
||||
final signalR = signalRService;
|
||||
if (signalR == null) return const Stream.empty();
|
||||
return signalR.newsArticleStream.map(NewsArticleModel.fromJson);
|
||||
}
|
||||
|
||||
Future<List<NewsArticleModel>> fetchNews({
|
||||
int page = 1,
|
||||
@@ -66,55 +75,9 @@ class NewsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connectToLiveFeed() async {
|
||||
if (_hubConnection != null && _hubConnection!.state == HubConnectionState.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final token = await secureStorage.read(key: 'auth_token');
|
||||
final hubUrl = '$backendUrl/hubs/news${token != null ? '?access_token=$token' : ''}';
|
||||
|
||||
_hubConnection = HubConnectionBuilder()
|
||||
.withUrl(hubUrl, HttpConnectionOptions(
|
||||
logging: (level, message) => print(message),
|
||||
))
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
_hubConnection!.on('ReceiveNewArticle', (arguments) {
|
||||
if (arguments != null && arguments.isNotEmpty) {
|
||||
try {
|
||||
// SignalR might send it as a Map or raw JSON depending on .NET format
|
||||
final dynamic rawPayload = arguments[0];
|
||||
final Map<String, dynamic> jsonData = rawPayload is String
|
||||
? jsonDecode(rawPayload)
|
||||
: Map<String, dynamic>.from(rawPayload);
|
||||
|
||||
final article = NewsArticleModel.fromJson(jsonData);
|
||||
_liveNewsController.add(article);
|
||||
} catch (e) {
|
||||
print('Error parsing live article: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await _hubConnection!.start();
|
||||
print('Connected to News Live Feed');
|
||||
} catch (e) {
|
||||
print('Error connecting to News Live Feed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnectFromLiveFeed() async {
|
||||
if (_hubConnection != null) {
|
||||
await _hubConnection!.stop();
|
||||
_hubConnection = null;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_liveNewsController.close();
|
||||
disconnectFromLiveFeed();
|
||||
}
|
||||
/// No-op: the underlying `/hubs/news` WebSocket connection is owned and
|
||||
/// lifecycle-managed centrally by [SignalRService] (started once in
|
||||
/// `main.dart` after authentication), so this repository has nothing of
|
||||
/// its own to dispose.
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,11 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
|
||||
_hasMore = false;
|
||||
}
|
||||
});
|
||||
|
||||
// If a sentiment filter is active and no items matched yet on this page, automatically load the next page
|
||||
if (_filteredNewsItems.isEmpty && _hasMore && _selectedSentimentFilter != null) {
|
||||
_loadNews();
|
||||
}
|
||||
} catch (_) {
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
@@ -182,11 +187,22 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
|
||||
_loadNews(refresh: true);
|
||||
},
|
||||
onSentimentToggleChanged: (val) {
|
||||
setState(() => _hasSentimentOnly = val);
|
||||
setState(() {
|
||||
_hasSentimentOnly = val;
|
||||
if (!val) {
|
||||
_selectedSentimentFilter = null;
|
||||
}
|
||||
});
|
||||
_loadNews(refresh: true);
|
||||
},
|
||||
onSentimentFilterChanged: (val) {
|
||||
setState(() => _selectedSentimentFilter = val);
|
||||
setState(() {
|
||||
_selectedSentimentFilter = val;
|
||||
if (val != null) {
|
||||
_hasSentimentOnly = true;
|
||||
}
|
||||
});
|
||||
_loadNews(refresh: true);
|
||||
},
|
||||
onResetFilters: _resetFilters,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/asset_logo_widget.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../trades/models/trade_model.dart';
|
||||
|
||||
class ProposalDecisionScreen extends StatelessWidget {
|
||||
final TradeProposalModel proposal;
|
||||
final VoidCallback? onExecuteBot;
|
||||
final VoidCallback? onManualTrade;
|
||||
|
||||
const ProposalDecisionScreen({
|
||||
super.key,
|
||||
required this.proposal,
|
||||
this.onExecuteBot,
|
||||
this.onManualTrade,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final symbol = proposal.symbol.isNotEmpty ? proposal.symbol : 'ASSET';
|
||||
final isin = proposal.underlyingIsin;
|
||||
final isLong = proposal.isLong;
|
||||
final strategyKey = proposal.strategyKey.isNotEmpty ? proposal.strategyKey : 'Unbekannte Strategie';
|
||||
final score = proposal.compositeScore;
|
||||
final entryPrice = proposal.entryPrice;
|
||||
final invalidationPrice = proposal.invalidationPrice;
|
||||
|
||||
final aiValidation = proposal.aiValidation;
|
||||
final hasAiThesisContent = aiValidation != null && aiValidation.hasContent;
|
||||
final isRuleBasedApproval = aiValidation != null && !aiValidation.isAiValidated;
|
||||
final aiConfidence = aiValidation?.confidence;
|
||||
final deriv = proposal.selectedDerivative;
|
||||
final hasDerivative = deriv != null;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.darkBackground,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text('Trade Proposal: $symbol', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Top Asset Card
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
AssetLogoWidget(
|
||||
symbolOrName: symbol,
|
||||
imageUrl: '/api/v1/logo/$isin',
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(symbol, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: isLong ? AppTheme.primaryEmerald.withValues(alpha: 0.2) : AppTheme.accentRed.withValues(alpha: 0.2),
|
||||
),
|
||||
child: Text(
|
||||
isLong ? 'LONG' : 'SHORT',
|
||||
style: TextStyle(
|
||||
color: isLong ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(isin, style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
|
||||
border: Border.all(color: AppTheme.primaryEmerald, width: 1.5),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text('${score.toStringAsFixed(0)} Pkt', style: TextStyle(color: AppTheme.primaryEmerald, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
Text('Score', style: TextStyle(color: AppTheme.textMuted, fontSize: 9)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// AI Thesis & Catalysts Card
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.psychology, size: 18, color: Colors.purpleAccent),
|
||||
SizedBox(width: 8),
|
||||
Text('KI-Guardian Thesis & Validierung', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (aiValidation != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: isRuleBasedApproval
|
||||
? Colors.amber.withValues(alpha: 0.12)
|
||||
: AppTheme.primaryEmerald.withValues(alpha: 0.12),
|
||||
border: Border.all(color: isRuleBasedApproval ? Colors.amber : AppTheme.primaryEmerald),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isRuleBasedApproval ? Icons.rule_outlined : Icons.smart_toy_outlined,
|
||||
size: 14,
|
||||
color: isRuleBasedApproval ? Colors.amber : AppTheme.primaryEmerald,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isRuleBasedApproval
|
||||
? 'Regelbasierte Freigabe – keine KI-Bewertung durchgeführt'
|
||||
: (aiConfidence != null
|
||||
? 'KI-validiert · Konfidenz ${(aiConfidence * 100).toStringAsFixed(0)}%'
|
||||
: 'KI-validiert · Konfidenz nicht verfügbar'),
|
||||
style: TextStyle(
|
||||
color: isRuleBasedApproval ? Colors.amber : AppTheme.primaryEmerald,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!hasAiThesisContent)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: AppTheme.textMuted),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
aiValidation == null
|
||||
? 'Keine KI-Validierung für diesen Vorschlag verfügbar.'
|
||||
: 'Keine weiteren Details zur Freigabe hinterlegt.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 13, height: 1.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else ...[
|
||||
Text(aiValidation.thesisSummary, style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4)),
|
||||
if (aiValidation.keyCatalysts.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text('Katalysatoren & Stärken ($strategyKey @ €${entryPrice.toStringAsFixed(2)}):', style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
...aiValidation.keyCatalysts.map((c) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, size: 12, color: AppTheme.primaryEmerald),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: Text(c, style: TextStyle(color: AppTheme.textMuted, fontSize: 12))),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
if (aiValidation.identifiedRisks.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Text('Identifizierte Risiken:', style: TextStyle(color: Colors.amber, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
...aiValidation.identifiedRisks.map((r) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber, size: 12, color: Colors.amber),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: Text(r, style: TextStyle(color: AppTheme.textMuted, fontSize: 12))),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Knock-Out Derivative & Safety Buffer Card
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.shield_outlined, size: 18, color: Colors.cyanAccent),
|
||||
SizedBox(width: 8),
|
||||
Text('Optimaler Knock-Out Schein', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (!hasDerivative)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: AppTheme.textMuted),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Kein passendes Knock-Out Produkt für diesen Vorschlag gefunden.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 13, height: 1.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildDetailCol('Emittent', deriv.issuer.isNotEmpty ? deriv.issuer : '–'),
|
||||
_buildDetailCol('Hebel', '${deriv.leverage.toStringAsFixed(1)}x'),
|
||||
_buildDetailCol('KO-Barriere', '€${deriv.barrier.toStringAsFixed(2)}'),
|
||||
_buildDetailCol('Sicherheitspuffer', '${deriv.safetyBufferPercent.toStringAsFixed(1)}%'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value: (deriv.safetyBufferPercent / 20.0).clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.white10,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(deriv.safetyBufferPercent >= 5.0 ? AppTheme.primaryEmerald : Colors.amber),
|
||||
minHeight: 6,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Barriere liegt ${deriv.safetyBufferPercent.toStringAsFixed(1)}% unter dem Chart Stop-Loss (€${invalidationPrice.toStringAsFixed(2)}).',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 10),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
deriv.derivativeWkn != null ? 'WKN: ${deriv.derivativeWkn}' : 'WKN: nicht verfügbar',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textMuted,
|
||||
fontSize: 10,
|
||||
fontStyle: deriv.derivativeWkn != null ? FontStyle.normal : FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action Buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: onExecuteBot,
|
||||
icon: const Icon(Icons.smart_toy_outlined),
|
||||
label: const Text('An Bot übergeben'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryEmerald,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onManualTrade,
|
||||
icon: const Icon(Icons.touch_app_outlined),
|
||||
label: const Text('Manuell eröffnen'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white30),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailCol(String label, String val) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
|
||||
const SizedBox(height: 2),
|
||||
Text(val, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,14 @@ extension SwitchMapExtension<T> on Stream<T> {
|
||||
onError: controller?.addError,
|
||||
);
|
||||
}, onDone: () {
|
||||
// Wait for last sub to finish or close
|
||||
outputSub?.cancel();
|
||||
controller?.close();
|
||||
});
|
||||
},
|
||||
onCancel: () {
|
||||
outputSub?.cancel();
|
||||
inputSub?.cancel();
|
||||
controller?.close();
|
||||
},
|
||||
);
|
||||
return controller.stream;
|
||||
|
||||
@@ -10,12 +10,19 @@ import '../bloc/search_bloc.dart';
|
||||
import '../repositories/search_repository.dart';
|
||||
|
||||
/// Focused Spotlight Asset Search Dialog with reactive Favorite Star Button, Hero transitions, and Shimmer loading states.
|
||||
///
|
||||
/// By default, tapping a result navigates straight to [AssetDetailScreen] (the main search-feature use case).
|
||||
/// Pass [onAssetSelected] to repurpose this as a reusable asset PICKER instead - e.g. the simulation screen's
|
||||
/// ISIN input - in which case a tap pops the dialog and invokes the callback with the picked ISIN/name instead
|
||||
/// of navigating anywhere.
|
||||
class AssetSearchDialog extends StatelessWidget {
|
||||
final ApiClient apiClient;
|
||||
final void Function(String isin, String name)? onAssetSelected;
|
||||
|
||||
const AssetSearchDialog({
|
||||
super.key,
|
||||
required this.apiClient,
|
||||
this.onAssetSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -24,15 +31,16 @@ class AssetSearchDialog extends StatelessWidget {
|
||||
create: (context) => SearchBloc(
|
||||
repository: SearchRepository(apiClient: apiClient),
|
||||
)..add(const SearchQueryChanged('')),
|
||||
child: _AssetSearchDialogContent(apiClient: apiClient),
|
||||
child: _AssetSearchDialogContent(apiClient: apiClient, onAssetSelected: onAssetSelected),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetSearchDialogContent extends StatefulWidget {
|
||||
final ApiClient apiClient;
|
||||
final void Function(String isin, String name)? onAssetSelected;
|
||||
|
||||
const _AssetSearchDialogContent({required this.apiClient});
|
||||
const _AssetSearchDialogContent({required this.apiClient, this.onAssetSelected});
|
||||
|
||||
@override
|
||||
State<_AssetSearchDialogContent> createState() => _AssetSearchDialogContentState();
|
||||
@@ -162,6 +170,10 @@ class _AssetSearchDialogContentState extends State<_AssetSearchDialogContent> {
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
if (widget.onAssetSelected != null) {
|
||||
widget.onAssetSelected!(isin, assetName);
|
||||
return;
|
||||
}
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../models/backtest_history_entry_model.dart';
|
||||
import '../models/backtest_report_model.dart';
|
||||
import '../repositories/simulation_repository.dart';
|
||||
|
||||
class BacktestState extends Equatable {
|
||||
final bool isLoading;
|
||||
final BacktestReportModel? report;
|
||||
final String? errorMessage;
|
||||
|
||||
/// True while [report] is a past run fetched via [BacktestCubit.viewHistoricalRun] rather than a freshly
|
||||
/// executed backtest - lets the UI label the summary/chart as "aus dem Verlauf" instead of implying a new
|
||||
/// run just completed.
|
||||
final bool isViewingHistoricalRun;
|
||||
|
||||
final bool isHistoryLoading;
|
||||
final List<BacktestHistoryEntryModel> history;
|
||||
final String? historyErrorMessage;
|
||||
|
||||
/// Tunable indicator-parameter overrides for the currently selected strategy, keyed by bare parameter name
|
||||
/// (e.g. `"EmaFast"`, NOT `"TrendPullbackFvg.EmaFast"`) - the `"{strategyKey}."` prefix required by the
|
||||
/// backend (`TechnicalContext.ParameterOverrides`) is applied only when sending/loading, so this map stays
|
||||
/// meaningful regardless of which strategy is currently selected. Reset whenever the strategy changes (see
|
||||
/// [BacktestCubit.resetParameterOverrides]) - the same bare name means something different per strategy.
|
||||
final Map<String, double> parameterOverrides;
|
||||
|
||||
final bool isParametersLoading;
|
||||
final String? parametersMessage;
|
||||
|
||||
const BacktestState({
|
||||
this.isLoading = false,
|
||||
this.report,
|
||||
this.errorMessage,
|
||||
this.isViewingHistoricalRun = false,
|
||||
this.isHistoryLoading = false,
|
||||
this.history = const [],
|
||||
this.historyErrorMessage,
|
||||
this.parameterOverrides = const {},
|
||||
this.isParametersLoading = false,
|
||||
this.parametersMessage,
|
||||
});
|
||||
|
||||
BacktestState copyWith({
|
||||
bool? isLoading,
|
||||
BacktestReportModel? report,
|
||||
String? errorMessage,
|
||||
bool clearError = false,
|
||||
bool? isViewingHistoricalRun,
|
||||
bool? isHistoryLoading,
|
||||
List<BacktestHistoryEntryModel>? history,
|
||||
String? historyErrorMessage,
|
||||
bool clearHistoryError = false,
|
||||
Map<String, double>? parameterOverrides,
|
||||
bool? isParametersLoading,
|
||||
String? parametersMessage,
|
||||
bool clearParametersMessage = false,
|
||||
}) {
|
||||
return BacktestState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
report: report ?? this.report,
|
||||
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
|
||||
isViewingHistoricalRun: isViewingHistoricalRun ?? this.isViewingHistoricalRun,
|
||||
isHistoryLoading: isHistoryLoading ?? this.isHistoryLoading,
|
||||
history: history ?? this.history,
|
||||
historyErrorMessage: clearHistoryError ? null : (historyErrorMessage ?? this.historyErrorMessage),
|
||||
parameterOverrides: parameterOverrides ?? this.parameterOverrides,
|
||||
isParametersLoading: isParametersLoading ?? this.isParametersLoading,
|
||||
parametersMessage: clearParametersMessage ? null : (parametersMessage ?? this.parametersMessage),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
isLoading,
|
||||
report,
|
||||
errorMessage,
|
||||
isViewingHistoricalRun,
|
||||
isHistoryLoading,
|
||||
history,
|
||||
historyErrorMessage,
|
||||
parameterOverrides,
|
||||
isParametersLoading,
|
||||
parametersMessage,
|
||||
];
|
||||
}
|
||||
|
||||
class BacktestCubit extends Cubit<BacktestState> {
|
||||
final SimulationRepository repository;
|
||||
|
||||
BacktestCubit({required this.repository}) : super(const BacktestState());
|
||||
|
||||
Future<void> runBacktest({
|
||||
required String isin,
|
||||
required String symbol,
|
||||
required String strategyKey,
|
||||
required String timeframe,
|
||||
}) async {
|
||||
emit(state.copyWith(isLoading: true, clearError: true, isViewingHistoricalRun: false));
|
||||
try {
|
||||
final prefixedParams = state.parameterOverrides.isEmpty
|
||||
? null
|
||||
: state.parameterOverrides.map((name, value) => MapEntry('$strategyKey.$name', value));
|
||||
|
||||
final report = await repository.runBacktest(
|
||||
isin: isin,
|
||||
symbol: symbol,
|
||||
strategyKey: strategyKey,
|
||||
timeframe: timeframe,
|
||||
strategyParameters: prefixedParams,
|
||||
);
|
||||
emit(state.copyWith(isLoading: false, report: report, clearError: true, isViewingHistoricalRun: false));
|
||||
// The run that just completed is now the newest history entry - refresh so it shows up immediately
|
||||
// instead of the user only seeing it after manually reopening the history list.
|
||||
await loadHistory(isin: isin, strategyKey: strategyKey);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, errorMessage: 'Fehler beim Starten des Backtests: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadHistory({required String isin, String? strategyKey}) async {
|
||||
if (isin.trim().isEmpty) {
|
||||
emit(state.copyWith(history: const [], clearHistoryError: true));
|
||||
return;
|
||||
}
|
||||
|
||||
emit(state.copyWith(isHistoryLoading: true, clearHistoryError: true));
|
||||
try {
|
||||
final history = await repository.getBacktestHistory(isin: isin, strategyKey: strategyKey);
|
||||
emit(state.copyWith(isHistoryLoading: false, history: history, clearHistoryError: true));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isHistoryLoading: false, historyErrorMessage: 'Verlauf konnte nicht geladen werden: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> viewHistoricalRun(String runId) async {
|
||||
emit(state.copyWith(isLoading: true, clearError: true));
|
||||
try {
|
||||
final report = await repository.getBacktestRunDetail(runId);
|
||||
emit(state.copyWith(isLoading: false, report: report, clearError: true, isViewingHistoricalRun: true));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, errorMessage: 'Backtest-Lauf konnte nicht geladen werden: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a single tunable-parameter override (bare name, e.g. `"EmaFast"`) for the currently selected strategy.
|
||||
void setParameterOverride(String paramName, double value) {
|
||||
final updated = Map<String, double>.from(state.parameterOverrides);
|
||||
updated[paramName] = value;
|
||||
emit(state.copyWith(parameterOverrides: updated, clearParametersMessage: true));
|
||||
}
|
||||
|
||||
/// Clears all overrides - called whenever the selected strategy changes, since a bare parameter name means
|
||||
/// something different per strategy (e.g. `"Period"` is an RSI period for one strategy, a Donchian-channel
|
||||
/// length for another).
|
||||
void resetParameterOverrides() {
|
||||
emit(state.copyWith(parameterOverrides: const {}, clearParametersMessage: true));
|
||||
}
|
||||
|
||||
/// Loads a previously saved parameter profile for (isin, strategyKey) into [BacktestState.parameterOverrides].
|
||||
Future<void> loadSavedParameters({required String isin, required String strategyKey}) async {
|
||||
emit(state.copyWith(isParametersLoading: true, clearParametersMessage: true));
|
||||
try {
|
||||
final saved = await repository.getStrategyParameters(isin: isin, strategyKey: strategyKey);
|
||||
if (saved == null) {
|
||||
emit(state.copyWith(
|
||||
isParametersLoading: false,
|
||||
parametersMessage: 'Kein gespeichertes Profil für dieses Asset/diese Strategie.',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
final prefix = '$strategyKey.';
|
||||
final bare = <String, double>{};
|
||||
saved.forEach((key, value) {
|
||||
if (key.startsWith(prefix)) bare[key.substring(prefix.length)] = value;
|
||||
});
|
||||
|
||||
emit(state.copyWith(
|
||||
isParametersLoading: false,
|
||||
parameterOverrides: bare,
|
||||
parametersMessage: 'Gespeichertes Profil geladen.',
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isParametersLoading: false, parametersMessage: 'Fehler beim Laden des Profils: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the current [BacktestState.parameterOverrides] as a reusable profile for (isin, strategyKey).
|
||||
Future<void> saveCurrentParameters({required String isin, required String strategyKey}) async {
|
||||
emit(state.copyWith(isParametersLoading: true, clearParametersMessage: true));
|
||||
try {
|
||||
final prefixed = state.parameterOverrides.map((name, value) => MapEntry('$strategyKey.$name', value));
|
||||
await repository.saveStrategyParameters(isin: isin, strategyKey: strategyKey, parameters: prefixed);
|
||||
emit(state.copyWith(isParametersLoading: false, parametersMessage: 'Parameter-Profil gespeichert.'));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isParametersLoading: false, parametersMessage: 'Fehler beim Speichern des Profils: $e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Typed counterpart of the backend `BacktestHistoryEntryDto`
|
||||
/// (see `FinlyticCore/Dtos/Simulation/SimulationDtos.cs`) - one lightweight
|
||||
/// row of a past backtest run, without the full trade list/equity curve
|
||||
/// (fetch those via `SimulationRepository.getBacktestRunDetail` when the
|
||||
/// user drills into a specific entry).
|
||||
class BacktestHistoryEntryModel extends Equatable {
|
||||
final String runId;
|
||||
final String isin;
|
||||
final String symbol;
|
||||
final String strategyKey;
|
||||
final String timeframe;
|
||||
final DateTime startDateUtc;
|
||||
final DateTime endDateUtc;
|
||||
final int totalTrades;
|
||||
final double winRatePercent;
|
||||
final double profitFactor;
|
||||
final double maxDrawdownPercent;
|
||||
final double totalReturnPercent;
|
||||
final double sharpeRatio;
|
||||
final DateTime createdAtUtc;
|
||||
|
||||
const BacktestHistoryEntryModel({
|
||||
required this.runId,
|
||||
required this.isin,
|
||||
required this.symbol,
|
||||
required this.strategyKey,
|
||||
required this.timeframe,
|
||||
required this.startDateUtc,
|
||||
required this.endDateUtc,
|
||||
required this.totalTrades,
|
||||
required this.winRatePercent,
|
||||
required this.profitFactor,
|
||||
required this.maxDrawdownPercent,
|
||||
required this.totalReturnPercent,
|
||||
required this.sharpeRatio,
|
||||
required this.createdAtUtc,
|
||||
});
|
||||
|
||||
factory BacktestHistoryEntryModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic val) {
|
||||
if (val == null) return 0.0;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
DateTime parseDate(dynamic val) {
|
||||
return DateTime.tryParse(val?.toString() ?? '')?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
}
|
||||
|
||||
return BacktestHistoryEntryModel(
|
||||
runId: json['runId']?.toString() ?? '',
|
||||
isin: json['isin']?.toString() ?? '',
|
||||
symbol: json['symbol']?.toString() ?? '',
|
||||
strategyKey: json['strategyKey']?.toString() ?? '',
|
||||
timeframe: json['timeframe']?.toString() ?? '',
|
||||
startDateUtc: parseDate(json['startDateUtc']),
|
||||
endDateUtc: parseDate(json['endDateUtc']),
|
||||
totalTrades: (json['totalTrades'] as num?)?.toInt() ?? 0,
|
||||
winRatePercent: parseDbl(json['winRatePercent']),
|
||||
profitFactor: parseDbl(json['profitFactor']),
|
||||
maxDrawdownPercent: parseDbl(json['maxDrawdownPercent']),
|
||||
totalReturnPercent: parseDbl(json['totalReturnPercent']),
|
||||
sharpeRatio: parseDbl(json['sharpeRatio']),
|
||||
createdAtUtc: parseDate(json['createdAtUtc']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
runId,
|
||||
isin,
|
||||
symbol,
|
||||
strategyKey,
|
||||
timeframe,
|
||||
startDateUtc,
|
||||
endDateUtc,
|
||||
totalTrades,
|
||||
winRatePercent,
|
||||
profitFactor,
|
||||
maxDrawdownPercent,
|
||||
totalReturnPercent,
|
||||
sharpeRatio,
|
||||
createdAtUtc,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Typed counterpart of the backend `EquityPointDto`
|
||||
/// (see `FinlyticCore/Dtos/Simulation/SimulationDtos.cs`).
|
||||
class EquityPointModel extends Equatable {
|
||||
final DateTime timestampUtc;
|
||||
final double portfolioValue;
|
||||
final double drawdownPercent;
|
||||
|
||||
const EquityPointModel({
|
||||
required this.timestampUtc,
|
||||
required this.portfolioValue,
|
||||
required this.drawdownPercent,
|
||||
});
|
||||
|
||||
factory EquityPointModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic val) {
|
||||
if (val == null) return 0.0;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
return EquityPointModel(
|
||||
timestampUtc: DateTime.tryParse(json['timestampUtc']?.toString() ?? '') ?? DateTime.now(),
|
||||
portfolioValue: parseDbl(json['portfolioValue']),
|
||||
drawdownPercent: parseDbl(json['drawdownPercent']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [timestampUtc, portfolioValue, drawdownPercent];
|
||||
}
|
||||
|
||||
/// Typed counterpart of the backend `BacktestReportDto`
|
||||
/// (see `FinlyticCore/Dtos/Simulation/SimulationDtos.cs`).
|
||||
///
|
||||
/// `equityCurve` is intentionally a plain (possibly empty) list rather than
|
||||
/// a fallback with a synthetic starting point: an empty list means "no
|
||||
/// equity curve data returned" and MUST be rendered as an explicit empty
|
||||
/// state, never as an invented chart (Rules.md §4).
|
||||
class BacktestReportModel extends Equatable {
|
||||
final String runId;
|
||||
final String isin;
|
||||
final String symbol;
|
||||
final String strategyKey;
|
||||
final String timeframe;
|
||||
final int totalTrades;
|
||||
final int winningTrades;
|
||||
final int losingTrades;
|
||||
final double winRatePercent;
|
||||
final double profitFactor;
|
||||
final double maxDrawdownPercent;
|
||||
final double totalReturnPercent;
|
||||
final double expectancyEur;
|
||||
final double sharpeRatio;
|
||||
final List<EquityPointModel> equityCurve;
|
||||
|
||||
const BacktestReportModel({
|
||||
this.runId = '',
|
||||
this.isin = '',
|
||||
this.symbol = '',
|
||||
this.strategyKey = '',
|
||||
this.timeframe = '',
|
||||
this.totalTrades = 0,
|
||||
this.winningTrades = 0,
|
||||
this.losingTrades = 0,
|
||||
this.winRatePercent = 0.0,
|
||||
this.profitFactor = 0.0,
|
||||
this.maxDrawdownPercent = 0.0,
|
||||
this.totalReturnPercent = 0.0,
|
||||
this.expectancyEur = 0.0,
|
||||
this.sharpeRatio = 0.0,
|
||||
this.equityCurve = const [],
|
||||
});
|
||||
|
||||
factory BacktestReportModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic val) {
|
||||
if (val == null) return 0.0;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
final rawCurve = json['equityCurve'];
|
||||
final curve = rawCurve is List
|
||||
? rawCurve
|
||||
.whereType<Map>()
|
||||
.map((e) => EquityPointModel.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList()
|
||||
: const <EquityPointModel>[];
|
||||
|
||||
return BacktestReportModel(
|
||||
runId: json['runId']?.toString() ?? '',
|
||||
isin: json['isin']?.toString() ?? '',
|
||||
symbol: json['symbol']?.toString() ?? '',
|
||||
strategyKey: json['strategyKey']?.toString() ?? '',
|
||||
timeframe: json['timeframe']?.toString() ?? '',
|
||||
totalTrades: (json['totalTrades'] as num?)?.toInt() ?? 0,
|
||||
winningTrades: (json['winningTrades'] as num?)?.toInt() ?? 0,
|
||||
losingTrades: (json['losingTrades'] as num?)?.toInt() ?? 0,
|
||||
winRatePercent: parseDbl(json['winRatePercent']),
|
||||
profitFactor: parseDbl(json['profitFactor']),
|
||||
maxDrawdownPercent: parseDbl(json['maxDrawdownPercent']),
|
||||
totalReturnPercent: parseDbl(json['totalReturnPercent']),
|
||||
expectancyEur: parseDbl(json['expectancyEur']),
|
||||
sharpeRatio: parseDbl(json['sharpeRatio']),
|
||||
equityCurve: curve,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
runId,
|
||||
isin,
|
||||
symbol,
|
||||
strategyKey,
|
||||
timeframe,
|
||||
totalTrades,
|
||||
winningTrades,
|
||||
losingTrades,
|
||||
winRatePercent,
|
||||
profitFactor,
|
||||
maxDrawdownPercent,
|
||||
totalReturnPercent,
|
||||
expectancyEur,
|
||||
sharpeRatio,
|
||||
equityCurve,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../models/backtest_history_entry_model.dart';
|
||||
import '../models/backtest_report_model.dart';
|
||||
|
||||
/// Backend endpoint contract: `FinlyticBackend/Controllers/SimulationController.cs`
|
||||
/// (`POST /api/v1/simulation/run`, body shape `BacktestRequestDto` in
|
||||
/// `FinlyticCore/Dtos/Simulation/SimulationDtos.cs`).
|
||||
class SimulationRepository {
|
||||
final ApiClient apiClient;
|
||||
|
||||
const SimulationRepository({required this.apiClient});
|
||||
|
||||
Future<BacktestReportModel> runBacktest({
|
||||
required String isin,
|
||||
required String symbol,
|
||||
required String strategyKey,
|
||||
required String timeframe,
|
||||
DateTime? startDateUtc,
|
||||
DateTime? endDateUtc,
|
||||
double startingCapital = 10000.0,
|
||||
double riskPerTradePercent = 1.0,
|
||||
bool includeFeesAndSlippage = true,
|
||||
bool simulateKnockOutDerivatives = false,
|
||||
double? targetLeverage,
|
||||
Map<String, double>? strategyParameters,
|
||||
}) async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final start = (startDateUtc ?? now.subtract(const Duration(days: 180))).toUtc();
|
||||
final end = (endDateUtc ?? now).toUtc();
|
||||
|
||||
final response = await apiClient.post('/api/v1/simulation/run', data: {
|
||||
'isin': isin,
|
||||
'symbol': symbol,
|
||||
'strategyKey': strategyKey,
|
||||
'timeframe': timeframe,
|
||||
'startDateUtc': start.toIso8601String(),
|
||||
'endDateUtc': end.toIso8601String(),
|
||||
'startingCapital': startingCapital,
|
||||
'riskPerTradePercent': riskPerTradePercent,
|
||||
'includeFeesAndSlippage': includeFeesAndSlippage,
|
||||
'simulateKnockOutDerivatives': simulateKnockOutDerivatives,
|
||||
if (targetLeverage != null) 'targetLeverage': targetLeverage,
|
||||
if (strategyParameters != null && strategyParameters.isNotEmpty) 'strategyParameters': strategyParameters,
|
||||
});
|
||||
|
||||
if (response.statusCode == 200 && response.data is Map) {
|
||||
return BacktestReportModel.fromJson(Map<String, dynamic>.from(response.data as Map));
|
||||
}
|
||||
|
||||
throw Exception('Simulation fehlgeschlagen: Status ${response.statusCode}');
|
||||
}
|
||||
|
||||
/// Lightweight history of past backtest runs for an asset (`GET /api/v1/simulation/history/{isin}`).
|
||||
Future<List<BacktestHistoryEntryModel>> getBacktestHistory({
|
||||
required String isin,
|
||||
String? strategyKey,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await apiClient.get(
|
||||
'/api/v1/simulation/history/$isin',
|
||||
queryParameters: {
|
||||
if (strategyKey != null && strategyKey.isNotEmpty) 'strategyKey': strategyKey,
|
||||
'limit': limit,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data is List) {
|
||||
return (response.data as List)
|
||||
.whereType<Map>()
|
||||
.map((e) => BacktestHistoryEntryModel.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Backtest-Verlauf konnte nicht geladen werden: Status ${response.statusCode}');
|
||||
}
|
||||
|
||||
/// Full report (trades + equity curve) for one specific past run (`GET /api/v1/simulation/history/run/{runId}`).
|
||||
Future<BacktestReportModel> getBacktestRunDetail(String runId) async {
|
||||
final response = await apiClient.get('/api/v1/simulation/history/run/$runId');
|
||||
|
||||
if (response.statusCode == 200 && response.data is Map) {
|
||||
return BacktestReportModel.fromJson(Map<String, dynamic>.from(response.data as Map));
|
||||
}
|
||||
|
||||
throw Exception('Backtest-Lauf konnte nicht geladen werden: Status ${response.statusCode}');
|
||||
}
|
||||
|
||||
/// Saved indicator-parameter profile for one (isin, strategyKey) pair
|
||||
/// (`GET /api/v1/simulation/parameters/{isin}/{strategyKey}`). Returns `null` when none was ever saved -
|
||||
/// an expected, legitimate empty state (the server responds 404), not an error to surface to the user.
|
||||
Future<Map<String, double>?> getStrategyParameters({
|
||||
required String isin,
|
||||
required String strategyKey,
|
||||
}) async {
|
||||
try {
|
||||
final response = await apiClient.get('/api/v1/simulation/parameters/$isin/$strategyKey');
|
||||
if (response.statusCode == 200 && response.data is Map) {
|
||||
final raw = Map<String, dynamic>.from(response.data as Map)['parameters'];
|
||||
if (raw is Map) {
|
||||
return raw.map((key, value) => MapEntry(key.toString(), (value as num).toDouble()));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves/updates a per-asset/per-strategy indicator-parameter profile (`POST /api/v1/simulation/parameters`).
|
||||
Future<void> saveStrategyParameters({
|
||||
required String isin,
|
||||
required String strategyKey,
|
||||
required Map<String, double> parameters,
|
||||
}) async {
|
||||
final response = await apiClient.post('/api/v1/simulation/parameters', data: {
|
||||
'isin': isin,
|
||||
'strategyKey': strategyKey,
|
||||
'parameters': parameters,
|
||||
});
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Parameter-Profil konnte nicht gespeichert werden: Status ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
|
||||
/// Plain-language explanations of all 10 backtestable strategies (`FinlyticTechnicals/Strategies/CoreStrategies.cs`,
|
||||
/// shared 1:1 by FinlyticSimulation - "100% code reuse", see that file's own doc comments). Mirrors
|
||||
/// `FinlyticApp/lib/features/asset_detail/utils/pattern_explanations.dart`'s dictionary shape/detail-sheet
|
||||
/// pattern rather than inventing a new one. Every description below is a plain-language restatement of that
|
||||
/// strategy's actual `Evaluate()` logic as written, not a generic template (Rules.md §4). Every strategy fires
|
||||
/// both a long AND a short setup (mirror-image conditions in the same `Evaluate()` method) - hence
|
||||
/// `'bias': 'BIDIREKTIONAL'` throughout, rather than a single-direction label.
|
||||
class StrategyExplanations {
|
||||
static const Map<String, Map<String, String>> dictionary = {
|
||||
'TrendPullbackFvg': {
|
||||
'title': 'Trend Pullback FVG Retracement',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Ein Trend ist bereits im Gange (EMA20/EMA50/EMA200 in Trendreihenfolge) und der Kurs zieht sich kurz '
|
||||
'in eine Fair-Value-Gap (eine übersprungene Preiszone) zurück. Sobald der Kurs aus dieser Zone '
|
||||
'wieder in Trendrichtung zu laufen beginnt, gilt das als Bestätigung, dass der Trend weiterläuft. '
|
||||
'Long bei Aufwärtstrend + bullischer FVG, Short bei Abwärtstrend + bärischer FVG (Spiegelbild).',
|
||||
'significance':
|
||||
'Genau das "kurzer Rücksetzer, dann Bestätigung durchs erneute Laufen"-Muster: EMA50 bestätigt den '
|
||||
'übergeordneten Trend, die Fair-Value-Gap markiert die Rücksetzer-Zone, in der eingestiegen wird.',
|
||||
'action': 'Einstieg beim erneuten Lauf aus der Fair-Value-Gap-Zone in Trendrichtung, Stop-Loss hinter der Gap bzw. 1,2×ATR vom Entry.',
|
||||
'reliability': 'Hoch (Quality-Score 88, Top-Pick, Rating A+)',
|
||||
'target': 'Gestaffelter Ausstieg: TP1 bei 1,5× Risiko (50% Teilverkauf, Stop wandert auf Break-Even), TP2 bei 3,0× Risiko (30%).',
|
||||
'stop_loss': 'Hinter der Fair-Value-Gap-Grenze bzw. 1,2× ATR vom Einstieg - je nachdem, was enger ist.',
|
||||
},
|
||||
'VolatilitySqueeze': {
|
||||
'title': 'Bollinger/Keltner Squeeze Breakout',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Bollinger-Bänder ziehen sich innerhalb der Keltner-Kanäle zusammen (niedrige Volatilität) und "feuern" '
|
||||
'dann mit Momentum in eine Richtung - der klassische Ausbruch aus einer ruhigen Konsolidierungsphase. '
|
||||
'Long bei positivem, Short bei negativem Momentum-Histogramm.',
|
||||
'significance': 'Eine Phase geringer Schwankung geht typischerweise einer impulsiven Bewegung voraus; das Momentum-Histogramm bestätigt die Richtung.',
|
||||
'action': 'Einstieg direkt beim Squeeze-Ausbruch in Richtung des Momentums.',
|
||||
'reliability': 'Hoch (Quality-Score 84, Top-Pick, Rating A)',
|
||||
'target': 'Festes Ziel bei ±2,0× ATR ab Einstieg (100% Ausstieg).',
|
||||
'stop_loss': '1,0× ATR gegen die Einstiegsrichtung.',
|
||||
},
|
||||
'SmcLiquiditySweep': {
|
||||
'title': 'Smart Money Liquidity Sweep & CHoCH',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Der Kurs "fegt" kurz über ein bekanntes Hoch oder unter ein bekanntes Tief (Stop-Loss-Jagd durch '
|
||||
'große Marktteilnehmer) und dreht danach abrupt in die Gegenrichtung - erkennbar an einem '
|
||||
'Liquidity-Sweep-Muster und/oder einem Wechsel der Marktstruktur (Change of Character). Long nach '
|
||||
'einem Sweep unter einem Tief, Short nach einem Sweep über einem Hoch.',
|
||||
'significance': 'Interpretiert als Zeichen, dass institutionelle Marktteilnehmer die durch den Sweep freigesetzte Liquidität aufgenommen haben.',
|
||||
'action': 'Einstieg nach Bestätigung der Ablehnung des Sweeps.',
|
||||
'reliability': 'Sehr hoch (Quality-Score 91, Top-Pick, Rating A+)',
|
||||
'target': 'Gestaffelter Ausstieg: TP1 bei 2,0× Risiko (60%, danach Break-Even), TP2 bei 4,0× Risiko (40%).',
|
||||
'stop_loss': 'Knapp hinter dem Extrempunkt des Sweeps (0,2% Puffer).',
|
||||
},
|
||||
'MeanReversion': {
|
||||
'title': 'Bollinger 2,5-Sigma Mean Reversion',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Nur in ruhigen/schwankenden Seitwärtsmärkten (nicht im Trend) aktiv: Der Kurs berührt das untere '
|
||||
'(Long) oder obere (Short) 2,5-Sigma-Bollingerband bei niedrigem ADX (<22, kein starker Trend) und '
|
||||
'überverkauftem (≤32) bzw. überkauftem (≥68) RSI - eine statistisch übertriebene Bewegung, die '
|
||||
'zum Durchschnitt zurückkehren sollte.',
|
||||
'significance': 'Setzt auf die Rückkehr zum VWAP/Mittelwert nach einer überzogenen kurzfristigen Bewegung, nicht auf eine Trendfortsetzung.',
|
||||
'action': 'Einstieg bei Berührung des äußeren Bandes mit passendem RSI-Extrem.',
|
||||
'reliability': 'Mittel (Quality-Score 79, kein Top-Pick, Rating B) - bewusst konservativer eingestuft als die trendfolgenden Strategien.',
|
||||
'target': 'Ziel ist der VWAP bzw. das mittlere Bollingerband (SMA20), 100% Ausstieg.',
|
||||
'stop_loss': '0,8× ATR hinter dem Extrem der Signalkerze.',
|
||||
},
|
||||
'SuperTrendMultiTf': {
|
||||
'title': 'SuperTrend Multi-Timeframe Alignment',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Zwei Zeitebenen müssen übereinstimmen: Der SuperTrend-Indikator auf 1h UND auf 15m stehen beide auf '
|
||||
'dieselbe Richtung (bullisch für Long, bärisch für Short). Erst wenn beide Zeitrahmen im Einklang '
|
||||
'sind, gilt das Setup als bestätigt.',
|
||||
'significance': 'Reduziert Fehlsignale einzelner Zeitebenen - der übergeordnete (1h) und der feinere (15m) Trend müssen sich decken.',
|
||||
'action': 'Einstieg bei Bestätigung der Multi-Timeframe-Übereinstimmung.',
|
||||
'reliability': 'Hoch (Quality-Score 86, Top-Pick, Rating A)',
|
||||
'target': 'Kein festes Kursziel - reine Trailing-Stop-Strategie entlang der 15m-SuperTrend-Linie, Ausstieg erst bei Trendwechsel.',
|
||||
'stop_loss': 'Dynamisch an der 15m-SuperTrend-Linie, sofortiger Exit bei Flip der Gegenrichtung.',
|
||||
},
|
||||
'MacdCrossover': {
|
||||
'title': 'MACD Signal Line Crossover',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Klassischer Momentum-Wechsel: Die MACD-Linie kreuzt die Signal-Linie (verglichen mit derselben '
|
||||
'Berechnung eine Kerze zuvor) und das Histogramm bestätigt die Richtung. Long bei Kreuzung nach '
|
||||
'oben mit positivem Histogramm, Short bei Kreuzung nach unten mit negativem Histogramm.',
|
||||
'significance': 'Der MACD-Crossover ist eines der bekanntesten Momentum-Signale der technischen Analyse und markiert einen Wechsel im kurzfristigen Trend.',
|
||||
'action': 'Einstieg direkt bei bestätigter Kreuzung.',
|
||||
'reliability': 'Mittel (Quality-Score 80, Rating B)',
|
||||
'target': 'Festes Ziel bei ±2,0× Risiko (100% Ausstieg).',
|
||||
'stop_loss': '1,5× ATR gegen die Einstiegsrichtung.',
|
||||
},
|
||||
'MovingAverageCrossover': {
|
||||
'title': 'EMA50/EMA200 Golden & Death Cross',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Der langfristige Trendwechsel-Klassiker: Kreuzt EMA50 von unten nach oben durch EMA200, ist das ein '
|
||||
'"Golden Cross" (Long). Kreuzt sie von oben nach unten, ist das ein "Death Cross" (Short).',
|
||||
'significance': 'Einer der ältesten und am weitesten verbreiteten langfristigen Trendindikatoren - signalisiert einen strukturellen Wechsel der Marktrichtung.',
|
||||
'action': 'Einstieg direkt bei bestätigter Kreuzung.',
|
||||
'reliability': 'Hoch (Quality-Score 82, Top-Pick, Rating A) - aber selten, da EMA50/EMA200 sich nicht oft kreuzen.',
|
||||
'target': 'Kein festes Kursziel - Trailing-Stop mit 2,5× ATR-Abstand, für langfristige Trends gedacht.',
|
||||
'stop_loss': '2,0× ATR gegen die Einstiegsrichtung.',
|
||||
},
|
||||
'RsiReversal': {
|
||||
'title': 'RSI Overbought/Oversold Threshold Cross',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Einfaches, richtungsoffenes Momentum-Reversal: Long, wenn der RSI von unterhalb 30 wieder darüber '
|
||||
'steigt (Ende der Überverkauftheit). Short, wenn der RSI von oberhalb 70 wieder darunter fällt '
|
||||
'(Ende der Überkauftheit). Anders als "Bollinger 2,5-Sigma Mean Reversion" wird hier NICHT '
|
||||
'zusätzlich ein niedriger ADX oder eine Bollinger-Band-Berührung verlangt - rein der RSI-Schwellenwert zählt.',
|
||||
'significance': 'Der RSI-Schwellenwert-Durchbruch ist eines der einfachsten und am längsten genutzten Reversal-Signale.',
|
||||
'action': 'Einstieg direkt beim erneuten Über- bzw. Unterschreiten der 30/70-Schwelle.',
|
||||
'reliability': 'Mittel (Quality-Score 75, Rating B) - bewusst einfacher/häufiger auslösend als die kombinierten Strategien.',
|
||||
'target': 'Festes Ziel bei ±1,5× Risiko (100% Ausstieg).',
|
||||
'stop_loss': '1,2× ATR gegen die Einstiegsrichtung.',
|
||||
},
|
||||
'DonchianBreakout': {
|
||||
'title': '20-Perioden Donchian-Kanal-Ausbruch',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Der klassische "Turtle Trading"-Ausbruch: Long, wenn der Schlusskurs über das Hoch der letzten 20 '
|
||||
'Kerzen (vor der aktuellen) ausbricht. Short, wenn er unter das Tief der letzten 20 Kerzen fällt.',
|
||||
'significance': 'Einer der ältesten systematischen Breakout-Ansätze - setzt darauf, dass ein Ausbruch aus einer etablierten Handelsspanne eine neue Bewegung einleitet.',
|
||||
'action': 'Einstieg direkt beim Ausbruch über/unter den 20-Perioden-Kanal.',
|
||||
'reliability': 'Hoch (Quality-Score 83, Top-Pick, Rating A)',
|
||||
'target': 'Kein festes Kursziel - Trailing-Stop mit 2,0× ATR-Abstand.',
|
||||
'stop_loss': 'Am gegenüberliegenden Kanalrand (Tief bei Long, Hoch bei Short).',
|
||||
},
|
||||
'VwapBounce': {
|
||||
'title': 'VWAP Pullback & Bounce Confirmation',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Im Aufwärtstrend (EMA20>EMA50): Der Kurs sinkt kurz zum/unter den VWAP und schließt wieder darüber '
|
||||
'(Bounce) - Long-Einstieg. Im Abwärtstrend (EMA20<EMA50): Der Kurs steigt kurz zum/über den VWAP '
|
||||
'und schließt wieder darunter (Ablehnung) - Short-Einstieg.',
|
||||
'significance': 'Der VWAP wird von vielen institutionellen Marktteilnehmern als fairer Durchschnittspreis behandelt - ein Rücksetzer dorthin mit anschließender Ablehnung gilt als starkes Fortsetzungssignal.',
|
||||
'action': 'Einstieg bei Bestätigung des Bounces/der Ablehnung am VWAP.',
|
||||
'reliability': 'Mittel (Quality-Score 81, Rating B)',
|
||||
'target': 'Festes Ziel bei ±2,0× Risiko (100% Ausstieg).',
|
||||
'stop_loss': 'Hinter dem Hoch/Tief der Rücksetzer-Kerze bzw. 1,0× ATR - je nachdem, was weiter entfernt ist.',
|
||||
},
|
||||
};
|
||||
|
||||
static Map<String, String> _infoFor(String strategyKey) {
|
||||
return dictionary[strategyKey] ??
|
||||
{
|
||||
'title': strategyKey,
|
||||
'bias': 'NEUTRAL',
|
||||
'description': 'Eine von FinlyticTechnicals bereitgestellte technische Strategie ($strategyKey).',
|
||||
'significance': 'Details zu dieser Strategie sind noch nicht dokumentiert.',
|
||||
'action': 'Backtest-Ergebnisse und Score-Aufschlüsselung beachten.',
|
||||
'reliability': 'Unbekannt',
|
||||
'target': 'Abhängig vom jeweiligen Setup.',
|
||||
'stop_loss': 'Siehe individuelles Setup.',
|
||||
};
|
||||
}
|
||||
|
||||
static void showStrategyDetails(BuildContext context, String strategyKey) {
|
||||
final info = _infoFor(strategyKey);
|
||||
final isBullish = info['bias'] == 'BULLISH';
|
||||
final isBearish = info['bias'] == 'BEARISH';
|
||||
final biasColor = isBullish ? AppTheme.primaryEmerald : (isBearish ? AppTheme.accentRed : AppTheme.accentCyan);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (modalContext) => AlertDialog(
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: AppTheme.glassBorder),
|
||||
),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
info['title']!,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: biasColor.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: biasColor),
|
||||
),
|
||||
child: Text(
|
||||
info['bias']!,
|
||||
style: TextStyle(color: biasColor, fontWeight: FontWeight.bold, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Funktionsweise:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(info['description']!, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13, height: 1.4)),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
const Text('Warum das ein Signal ist:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(info['significance']!, style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4)),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
const Text('Einstufung:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 2),
|
||||
Text(info['reliability']!, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
const Text('Kursziel:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(info['target']!, style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 13, height: 1.4)),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
const Text('Stop-Loss:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(info['stop_loss']!, style: TextStyle(color: AppTheme.accentRed, fontSize: 13, height: 1.4)),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
const Text('Einstiegsauslöser:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
child: Text(info['action']!, style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.w600, height: 1.4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(modalContext),
|
||||
child: const Text('Schließen', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/// Describes one tunable indicator parameter for a strategy - mirrors the `context.GetParameter(StrategyKey, "...", default)`
|
||||
/// calls in `FinlyticTechnicals/Strategies/CoreStrategies.cs`. Keys sent to the backend are always
|
||||
/// `"{strategyKey}.{name}"`, matching `TechnicalContext.ParameterOverrides` exactly.
|
||||
class StrategyParameterDef {
|
||||
final String name;
|
||||
final String label;
|
||||
final double defaultValue;
|
||||
|
||||
/// Short plain-language explanation of what changing this value actually does, shown via an info tooltip
|
||||
/// next to the field (Rules.md-style transparency: a bare abbreviation like "EMA schnell" means nothing to
|
||||
/// someone who didn't just read the strategy's source code).
|
||||
final String hint;
|
||||
|
||||
/// Whether the field should be entered/rounded as a whole number (indicator periods) vs. a decimal
|
||||
/// (multipliers, thresholds).
|
||||
final bool isInteger;
|
||||
|
||||
const StrategyParameterDef({
|
||||
required this.name,
|
||||
required this.label,
|
||||
required this.defaultValue,
|
||||
required this.hint,
|
||||
this.isInteger = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Per-strategy list of tunable parameters, for the "Erweiterte Parameter" section of the backtest screen.
|
||||
/// Every default value here must match the corresponding hardcoded default in `CoreStrategies.cs` exactly -
|
||||
/// these are the same values the strategy already uses when nothing is overridden (Rules.md §4: never imply a
|
||||
/// different default than what the backend actually falls back to).
|
||||
class StrategyParameterDefinitions {
|
||||
static const Map<String, List<StrategyParameterDef>> byStrategy = {
|
||||
'TrendPullbackFvg': [
|
||||
StrategyParameterDef(
|
||||
name: 'EmaFast',
|
||||
label: 'EMA schnell',
|
||||
defaultValue: 20,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der kurzen EMA. Kleiner = reagiert schneller auf Kursänderungen, aber mehr Fehlsignale.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'EmaMid',
|
||||
label: 'EMA mittel',
|
||||
defaultValue: 50,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der mittleren EMA, bestätigt zusammen mit der langsamen EMA den übergeordneten Trend.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'EmaSlow',
|
||||
label: 'EMA langsam',
|
||||
defaultValue: 200,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der langsamen EMA. Größer = stabilerer, aber träger erkannter Trend.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'StopAtrMultiplier',
|
||||
label: 'Stop-Loss (× ATR)',
|
||||
defaultValue: 1.2,
|
||||
hint: 'Abstand des Stop-Loss vom Einstieg, als Vielfaches der aktuellen Volatilität (ATR). Größer = weiterer Stop, weniger vorzeitige Ausstiege.',
|
||||
),
|
||||
],
|
||||
'VolatilitySqueeze': [
|
||||
StrategyParameterDef(
|
||||
name: 'StopAtrMultiplier',
|
||||
label: 'Stop-Loss (× ATR)',
|
||||
defaultValue: 1.0,
|
||||
hint: 'Abstand des Stop-Loss vom Einstieg, als Vielfaches der aktuellen Volatilität (ATR).',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'TargetAtrMultiplier',
|
||||
label: 'Kursziel (× ATR)',
|
||||
defaultValue: 2.0,
|
||||
hint: 'Abstand des Kursziels vom Einstieg, als Vielfaches der aktuellen Volatilität (ATR).',
|
||||
),
|
||||
],
|
||||
'SmcLiquiditySweep': [
|
||||
StrategyParameterDef(
|
||||
name: 'StopBufferPercent',
|
||||
label: 'Stop-Puffer (%)',
|
||||
defaultValue: 0.2,
|
||||
hint: 'Zusätzlicher Sicherheitsabstand des Stop-Loss hinter dem Sweep-Extrempunkt, in Prozent.',
|
||||
),
|
||||
],
|
||||
'MeanReversion': [
|
||||
StrategyParameterDef(
|
||||
name: 'BollingerMultiplier',
|
||||
label: 'Bollinger-Multiplikator (σ)',
|
||||
defaultValue: 2.5,
|
||||
hint: 'Breite der Bollinger-Bänder in Standardabweichungen. Größer = seltenere, aber extremere Signale.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'AdxThreshold',
|
||||
label: 'ADX-Schwelle (max.)',
|
||||
defaultValue: 22,
|
||||
hint: 'Nur unterhalb dieser ADX-Trendstärke gilt der Markt als "seitwärts" - Voraussetzung für ein Signal.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'RsiOversold',
|
||||
label: 'RSI überverkauft (≤)',
|
||||
defaultValue: 32,
|
||||
hint: 'RSI-Wert, ab dem der Kurs als überverkauft gilt (Long-Signal-Voraussetzung).',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'RsiOverbought',
|
||||
label: 'RSI überkauft (≥)',
|
||||
defaultValue: 68,
|
||||
hint: 'RSI-Wert, ab dem der Kurs als überkauft gilt (Short-Signal-Voraussetzung).',
|
||||
),
|
||||
],
|
||||
'SuperTrendMultiTf': [
|
||||
StrategyParameterDef(
|
||||
name: 'Period',
|
||||
label: 'SuperTrend-Periode',
|
||||
defaultValue: 10,
|
||||
isInteger: true,
|
||||
hint: 'ATR-Periode, die der SuperTrend-Indikator auf beiden Zeitebenen (1h und 15m) zugrunde legt.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'Multiplier',
|
||||
label: 'SuperTrend-Multiplikator',
|
||||
defaultValue: 3.0,
|
||||
hint: 'Wie weit die SuperTrend-Linie von der ATR-Bandbreite entfernt liegt. Größer = trägere, glattere Linie.',
|
||||
),
|
||||
],
|
||||
'MacdCrossover': [
|
||||
StrategyParameterDef(
|
||||
name: 'FastPeriod',
|
||||
label: 'MACD schnell',
|
||||
defaultValue: 12,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der schnellen EMA im MACD.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'SlowPeriod',
|
||||
label: 'MACD langsam',
|
||||
defaultValue: 26,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der langsamen EMA im MACD.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'SignalPeriod',
|
||||
label: 'MACD Signal',
|
||||
defaultValue: 9,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der Signal-Linie (EMA der MACD-Linie), gegen die auf Kreuzung geprüft wird.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'StopAtrMultiplier',
|
||||
label: 'Stop-Loss (× ATR)',
|
||||
defaultValue: 1.5,
|
||||
hint: 'Abstand des Stop-Loss vom Einstieg, als Vielfaches der aktuellen Volatilität (ATR).',
|
||||
),
|
||||
],
|
||||
'MovingAverageCrossover': [
|
||||
StrategyParameterDef(
|
||||
name: 'FastPeriod',
|
||||
label: 'EMA schnell',
|
||||
defaultValue: 50,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der schnellen EMA (kreuzt für ein Golden/Death Cross durch die langsame EMA).',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'SlowPeriod',
|
||||
label: 'EMA langsam',
|
||||
defaultValue: 200,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der langsamen EMA - je größer, desto seltener und langfristiger die Signale.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'StopAtrMultiplier',
|
||||
label: 'Stop-Loss (× ATR)',
|
||||
defaultValue: 2.0,
|
||||
hint: 'Abstand des Stop-Loss vom Einstieg, als Vielfaches der aktuellen Volatilität (ATR).',
|
||||
),
|
||||
],
|
||||
'RsiReversal': [
|
||||
StrategyParameterDef(
|
||||
name: 'Period',
|
||||
label: 'RSI-Periode',
|
||||
defaultValue: 14,
|
||||
isInteger: true,
|
||||
hint: 'Anzahl Kerzen, über die der RSI berechnet wird.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'OversoldThreshold',
|
||||
label: 'Überverkauft-Schwelle',
|
||||
defaultValue: 30,
|
||||
hint: 'RSI-Schwelle, deren erneutes Überschreiten von unten ein Long-Signal auslöst.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'OverboughtThreshold',
|
||||
label: 'Überkauft-Schwelle',
|
||||
defaultValue: 70,
|
||||
hint: 'RSI-Schwelle, deren erneutes Unterschreiten von oben ein Short-Signal auslöst.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'StopAtrMultiplier',
|
||||
label: 'Stop-Loss (× ATR)',
|
||||
defaultValue: 1.2,
|
||||
hint: 'Abstand des Stop-Loss vom Einstieg, als Vielfaches der aktuellen Volatilität (ATR).',
|
||||
),
|
||||
],
|
||||
'DonchianBreakout': [
|
||||
StrategyParameterDef(
|
||||
name: 'Period',
|
||||
label: 'Kanal-Periode',
|
||||
defaultValue: 20,
|
||||
isInteger: true,
|
||||
hint: 'Anzahl vorheriger Kerzen, deren Hoch/Tief den Ausbruchskanal bilden. Größer = seltenere, dafür signifikantere Ausbrüche.',
|
||||
),
|
||||
],
|
||||
'VwapBounce': [
|
||||
StrategyParameterDef(
|
||||
name: 'EmaFast',
|
||||
label: 'EMA schnell',
|
||||
defaultValue: 20,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der schnellen EMA, bestimmt zusammen mit der langsamen EMA die Trendrichtung.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'EmaSlow',
|
||||
label: 'EMA langsam',
|
||||
defaultValue: 50,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der langsamen EMA, bestimmt zusammen mit der schnellen EMA die Trendrichtung.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'StopAtrMultiplier',
|
||||
label: 'Stop-Loss (× ATR)',
|
||||
defaultValue: 1.0,
|
||||
hint: 'Abstand des Stop-Loss vom Einstieg, als Vielfaches der aktuellen Volatilität (ATR).',
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
static List<StrategyParameterDef> forStrategy(String strategyKey) => byStrategy[strategyKey] ?? const [];
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../search/widgets/asset_search_dialog.dart';
|
||||
import '../cubit/backtest_cubit.dart';
|
||||
import '../models/backtest_history_entry_model.dart';
|
||||
import '../models/backtest_report_model.dart';
|
||||
import '../repositories/simulation_repository.dart';
|
||||
import '../utils/strategy_explanations.dart';
|
||||
import '../utils/strategy_parameter_definitions.dart';
|
||||
|
||||
class BacktestVisualizerScreen extends StatelessWidget {
|
||||
final ApiClient apiClient;
|
||||
final String? initialIsin;
|
||||
|
||||
const BacktestVisualizerScreen({
|
||||
super.key,
|
||||
required this.apiClient,
|
||||
this.initialIsin,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => BacktestCubit(repository: SimulationRepository(apiClient: apiClient)),
|
||||
child: _BacktestVisualizerContent(apiClient: apiClient, initialIsin: initialIsin),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BacktestVisualizerContent extends StatefulWidget {
|
||||
final ApiClient apiClient;
|
||||
final String? initialIsin;
|
||||
|
||||
const _BacktestVisualizerContent({required this.apiClient, this.initialIsin});
|
||||
|
||||
@override
|
||||
State<_BacktestVisualizerContent> createState() => _BacktestVisualizerContentState();
|
||||
}
|
||||
|
||||
class _BacktestVisualizerContentState extends State<_BacktestVisualizerContent> {
|
||||
static const String _defaultIsin = 'US67066G1040'; // NVDA - only used until the user picks a real asset.
|
||||
|
||||
String _selectedIsin = _defaultIsin;
|
||||
String _selectedAssetName = '';
|
||||
String _selectedStrategy = 'TrendPullbackFvg';
|
||||
String _selectedTimeframe = '1h';
|
||||
|
||||
final List<String> _strategies = [
|
||||
'TrendPullbackFvg',
|
||||
'SmcLiquiditySweep',
|
||||
'VolatilitySqueeze',
|
||||
'MeanReversion',
|
||||
'SuperTrendMultiTf',
|
||||
'MacdCrossover',
|
||||
'MovingAverageCrossover',
|
||||
'RsiReversal',
|
||||
'DonchianBreakout',
|
||||
'VwapBounce',
|
||||
];
|
||||
|
||||
// Yahoo Finance only retains fine-grained intraday history for a limited recent window (documented,
|
||||
// publicly-known limits: 1m ~7 days, 5m/15m/30m ~60 days), then serves 1h/1d/1wk bars over many years.
|
||||
// FinlyticSimulation.QuantSimulationEngine.ResolveYahooRange picks the matching fetch window per timeframe,
|
||||
// so a shorter timeframe here genuinely means "less total history available for this backtest" - see the
|
||||
// info tooltip on the TF field.
|
||||
final List<String> _timeframes = ['1m', '5m', '15m', '30m', '1h', '1d', '1wk'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedIsin = widget.initialIsin ?? _defaultIsin;
|
||||
// Fire-and-forget: the history panel shows an explicit loading/empty/error state on its own, so the
|
||||
// initial screen build does not need to wait on this.
|
||||
context.read<BacktestCubit>().loadHistory(isin: _selectedIsin);
|
||||
}
|
||||
|
||||
Future<void> _pickAsset() async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (_) => AssetSearchDialog(
|
||||
apiClient: widget.apiClient,
|
||||
onAssetSelected: (isin, name) {
|
||||
setState(() {
|
||||
_selectedIsin = isin;
|
||||
_selectedAssetName = name;
|
||||
});
|
||||
context.read<BacktestCubit>().loadHistory(isin: isin, strategyKey: _selectedStrategy);
|
||||
// A saved parameter profile is scoped to (isin, strategyKey) - the overrides for the previous
|
||||
// asset almost certainly don't apply to this one.
|
||||
context.read<BacktestCubit>().resetParameterOverrides();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _runBacktest() {
|
||||
if (_selectedIsin.trim().isEmpty) return;
|
||||
|
||||
context.read<BacktestCubit>().runBacktest(
|
||||
isin: _selectedIsin.trim(),
|
||||
symbol: '', // Left blank on purpose: FinlyticSimulation resolves the ticker from the ISIN itself.
|
||||
strategyKey: _selectedStrategy,
|
||||
timeframe: _selectedTimeframe,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.darkBackground,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: const Text('Quant & Backtest Engine', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Parameter Card
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Backtest Parameter', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white)),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: _pickAsset,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.search, size: 18, color: AppTheme.textMuted),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_selectedAssetName.isNotEmpty ? _selectedAssetName : 'Asset auswählen',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(_selectedIsin, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right_rounded, color: AppTheme.textMuted, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _selectedStrategy,
|
||||
dropdownColor: AppTheme.cardSurface,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Strategie',
|
||||
labelStyle: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.05),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
items: _strategies.map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
||||
onChanged: (val) {
|
||||
if (val == null) return;
|
||||
setState(() => _selectedStrategy = val);
|
||||
context.read<BacktestCubit>().loadHistory(isin: _selectedIsin, strategyKey: val);
|
||||
// A bare parameter name (e.g. "Period") means something different per strategy -
|
||||
// overrides from the previous strategy must not silently carry over.
|
||||
context.read<BacktestCubit>().resetParameterOverrides();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: 'Wie funktioniert diese Strategie?',
|
||||
icon: Icon(Icons.info_outline, color: AppTheme.accentCyan, size: 20),
|
||||
onPressed: () => StrategyExplanations.showStrategyDetails(context, _selectedStrategy),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _selectedTimeframe,
|
||||
dropdownColor: AppTheme.cardSurface,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'TF',
|
||||
labelStyle: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.05),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
items: _timeframes.map((tf) => DropdownMenuItem(value: tf, child: Text(tf))).toList(),
|
||||
onChanged: (val) => setState(() => _selectedTimeframe = val ?? _selectedTimeframe),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip:
|
||||
'Kerzen-Zeitrahmen für den Backtest. Yahoo Finance liefert feine Zeitrahmen nur für '
|
||||
'ein begrenztes, aktuelles Zeitfenster (1m ≈ 7 Tage, 5m/15m/30m ≈ 60 Tage), während '
|
||||
'1h/1d/1wk viele Jahre Historie abdecken - kürzere Zeitrahmen bedeuten also '
|
||||
'automatisch weniger verfügbare Backtest-Historie.',
|
||||
icon: Icon(Icons.info_outline, color: AppTheme.accentCyan, size: 20),
|
||||
onPressed: () => _showTimeframeInfo(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
BlocBuilder<BacktestCubit, BacktestState>(
|
||||
builder: (context, state) => _buildParameterSection(state),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
BlocBuilder<BacktestCubit, BacktestState>(
|
||||
builder: (context, state) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: state.isLoading ? null : _runBacktest,
|
||||
icon: state.isLoading
|
||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
|
||||
: const Icon(Icons.play_arrow),
|
||||
label: Text(state.isLoading ? 'Replay läuft...' : 'Backtest Ausführen'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryEmerald,
|
||||
// Without an explicit foregroundColor, the default theme-derived text color on
|
||||
// this bright background was effectively invisible until the pressed-state
|
||||
// overlay darkened it enough to read - black is the established convention for
|
||||
// primaryEmerald buttons elsewhere in the app (e.g. admin_users_screen.dart).
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
BlocBuilder<BacktestCubit, BacktestState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (state.errorMessage != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: AppTheme.accentRed.withValues(alpha: 0.15),
|
||||
border: Border.all(color: AppTheme.accentRed),
|
||||
),
|
||||
child: Text(state.errorMessage!, style: TextStyle(color: AppTheme.accentRed)),
|
||||
),
|
||||
if (state.report != null) ...[
|
||||
if (state.isViewingHistoricalRun)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.history, size: 14, color: AppTheme.accentCyan),
|
||||
const SizedBox(width: 6),
|
||||
Text('Aus dem Verlauf geladen', style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildMetricsSummary(state.report!),
|
||||
const SizedBox(height: 16),
|
||||
_buildEquityCurveChart(state.report!),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
_buildHistorySection(state),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showTimeframeInfo(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: AppTheme.glassBorder),
|
||||
),
|
||||
title: const Text('Zeitrahmen (TF)', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
content: Text(
|
||||
'Der Zeitrahmen bestimmt, wie groß eine einzelne Kerze im Backtest ist (z. B. "1h" = eine Kerze pro Stunde).\n\n'
|
||||
'Warum nicht jeder Zeitrahmen die gleiche Historie liefert: Yahoo Finance speichert feine, '
|
||||
'minutengenaue Kursdaten nur für ein begrenztes, aktuelles Zeitfenster:\n\n'
|
||||
'• 1m: nur die letzten ~7 Tage\n'
|
||||
'• 5m / 15m / 30m: nur die letzten ~60 Tage\n'
|
||||
'• 1h: bis zu ~2 Jahre\n'
|
||||
'• 1d / 1wk: viele Jahre\n\n'
|
||||
'Ein Backtest auf "1m" liefert also automatisch nur sehr wenige Trades, weil kaum Historie '
|
||||
'verfügbar ist - für aussagekräftige Backtests eignen sich meist 1h, 1d oder 1wk besser.',
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13, height: 1.4),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Verstanden', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// "Erweiterte Parameter": lets the user override the currently selected strategy's tunable indicator
|
||||
/// parameters for this backtest run only (see `TechnicalContext.ParameterOverrides`), and optionally save
|
||||
/// the current set as a reusable profile for this (asset, strategy) pair. Renders nothing for a strategy
|
||||
/// with no tunable parameters defined.
|
||||
Widget _buildParameterSection(BacktestState state) {
|
||||
final defs = StrategyParameterDefinitions.forStrategy(_selectedStrategy);
|
||||
if (defs.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
childrenPadding: const EdgeInsets.only(top: 4, bottom: 12),
|
||||
iconColor: AppTheme.textMuted,
|
||||
collapsedIconColor: AppTheme.textMuted,
|
||||
title: Text(
|
||||
'Erweiterte Parameter (${defs.length})',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
children: [
|
||||
...defs.map((def) => _buildParameterRow(def, state)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: state.isParametersLoading
|
||||
? null
|
||||
: () => context
|
||||
.read<BacktestCubit>()
|
||||
.loadSavedParameters(isin: _selectedIsin, strategyKey: _selectedStrategy),
|
||||
icon: const Icon(Icons.folder_open_outlined, size: 16),
|
||||
label: const Text('Laden', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: state.isParametersLoading
|
||||
? null
|
||||
: () => context
|
||||
.read<BacktestCubit>()
|
||||
.saveCurrentParameters(isin: _selectedIsin, strategyKey: _selectedStrategy),
|
||||
icon: const Icon(Icons.save_outlined, size: 16),
|
||||
label: const Text('Speichern', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (state.isParametersLoading) ...[
|
||||
const SizedBox(height: 10),
|
||||
const Center(child: SizedBox(height: 14, width: 14, child: CircularProgressIndicator(strokeWidth: 2))),
|
||||
] else if (state.parametersMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(state.parametersMessage!, style: TextStyle(color: AppTheme.accentCyan, fontSize: 11)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// One full-width row per tunable parameter: a readable label (wraps instead of truncating), a tap-to-show
|
||||
/// info icon explaining what it controls, and a comfortably-sized value field - replaces the previous
|
||||
/// `Wrap` of fixed-140px fields with floating labels, which squeezed the (often long) German labels down to
|
||||
/// the point of being unreadable.
|
||||
Widget _buildParameterRow(StrategyParameterDef def, BacktestState state) {
|
||||
final currentValue = state.parameterOverrides[def.name] ?? def.defaultValue;
|
||||
final displayValue = def.isInteger ? currentValue.toStringAsFixed(0) : currentValue.toString();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
def.label,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Tooltip(
|
||||
message: def.hint,
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
textStyle: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
child: Icon(Icons.info_outline, size: 16, color: AppTheme.accentCyan),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 92,
|
||||
child: TextFormField(
|
||||
// Forces the field to redraw with the new value after "Laden" replaces the whole override map -
|
||||
// a plain `initialValue` is otherwise only honored on the very first build.
|
||||
key: ValueKey('$_selectedIsin-$_selectedStrategy-${def.name}-$displayValue'),
|
||||
initialValue: displayValue,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.06),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: AppTheme.glassBorder),
|
||||
),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (text) {
|
||||
final parsed = double.tryParse(text.trim().replaceAll(',', '.'));
|
||||
if (parsed != null) {
|
||||
context.read<BacktestCubit>().setParameterOverride(def.name, parsed);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistorySection(BacktestState state) {
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text('Backtest-Verlauf für dieses Asset', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white)),
|
||||
),
|
||||
if (state.isHistoryLoading) const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (state.historyErrorMessage != null)
|
||||
Text(state.historyErrorMessage!, style: TextStyle(color: AppTheme.accentRed, fontSize: 12))
|
||||
else if (!state.isHistoryLoading && state.history.isEmpty)
|
||||
Text(
|
||||
'Für $_selectedIsin wurde noch kein Backtest ausgeführt.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
)
|
||||
else
|
||||
...state.history.map(_buildHistoryRow),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryRow(BacktestHistoryEntryModel entry) {
|
||||
final isPositive = entry.totalReturnPercent >= 0;
|
||||
final returnColor = isPositive ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => context.read<BacktestCubit>().viewHistoricalRun(entry.runId),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${entry.strategyKey} · ${entry.timeframe}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
DateFormat('dd.MM.yy HH:mm').format(entry.createdAtUtc.toLocal()),
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${entry.totalTrades} Trades',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'${isPositive ? '+' : ''}${entry.totalReturnPercent.toStringAsFixed(1)}%',
|
||||
style: TextStyle(color: returnColor, fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(Icons.chevron_right_rounded, color: AppTheme.textMuted, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricsSummary(BacktestReportModel report) {
|
||||
final isPositive = report.totalReturnPercent >= 0;
|
||||
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Performance Metriken', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildMetricTile('Winrate', '${report.winRatePercent.toStringAsFixed(1)}%', report.winRatePercent >= 50 ? AppTheme.primaryEmerald : AppTheme.accentRed)),
|
||||
Expanded(child: _buildMetricTile('Profit Factor', report.profitFactor.toStringAsFixed(2), report.profitFactor >= 1.5 ? AppTheme.primaryEmerald : Colors.amber)),
|
||||
Expanded(child: _buildMetricTile('Gesamtrendite', '${isPositive ? '+' : ''}${report.totalReturnPercent.toStringAsFixed(1)}%', isPositive ? AppTheme.primaryEmerald : AppTheme.accentRed)),
|
||||
Expanded(child: _buildMetricTile('Max Drawdown', '-${report.maxDrawdownPercent.toStringAsFixed(1)}%', Colors.orangeAccent)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
report.totalTrades > 0
|
||||
? 'Geprüft über ${report.totalTrades} Trades (${report.winningTrades} Gewinner / ${report.losingTrades} Verlierer).'
|
||||
: 'Keine Trades in diesem Backtest-Zeitraum ausgeführt.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricTile(String label, String value, Color color) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEquityCurveChart(BacktestReportModel report) {
|
||||
final equityCurve = report.equityCurve;
|
||||
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Simulierte Equity-Kurve (€)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white)),
|
||||
const SizedBox(height: 16),
|
||||
if (equityCurve.isEmpty)
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.show_chart, size: 32, color: AppTheme.textMuted),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Keine Equity-Kurve für diesen Backtest verfügbar.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
getDrawingHorizontalLine: (_) => FlLine(color: Colors.white10, strokeWidth: 1),
|
||||
),
|
||||
titlesData: const FlTitlesData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: [
|
||||
for (int i = 0; i < equityCurve.length; i++) FlSpot(i.toDouble(), equityCurve[i].portfolioValue),
|
||||
],
|
||||
isCurved: true,
|
||||
color: AppTheme.primaryEmerald,
|
||||
barWidth: 2,
|
||||
isStrokeCapRound: true,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,35 @@ class TradeRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches system-wide, currently active trade proposals
|
||||
/// (`GET /api/v1/user/trades?status=Proposed`).
|
||||
///
|
||||
/// Unlike [fetchTrades], the server does **not** return `ActiveTradeDto`
|
||||
/// (`TradeModel`) for this query: `UserTradesController.GetUserTrades`
|
||||
/// branches on `status == "Proposed"` and instead calls `engine_GetProposals`,
|
||||
/// which returns `List<TradeProposalDto>` — a structurally different shape
|
||||
/// (`proposalId` instead of `id`, no `userId`/PnL fields, since proposals are
|
||||
/// system-wide opportunities not owned by any user). Parsing that response
|
||||
/// as `TradeModel` would silently produce garbage/empty fields, so this is a
|
||||
/// dedicated method that parses `TradeProposalModel` instead of overloading
|
||||
/// [fetchTrades] for two incompatible server-side contracts.
|
||||
Future<List<TradeProposalModel>> fetchProposals() async {
|
||||
try {
|
||||
final response = await apiClient.get('/api/v1/user/trades', queryParameters: {
|
||||
'status': 'Proposed',
|
||||
'_t': DateTime.now().millisecondsSinceEpoch,
|
||||
});
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => TradeProposalModel.fromJson(Map<String, dynamic>.from(json))).toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
throw Exception('Vorschläge konnten nicht geladen werden: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<DerivativeItemModel>> fetchDerivatives(
|
||||
String isin, {
|
||||
String optionType = 'long',
|
||||
@@ -73,12 +102,13 @@ class TradeRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> rejectTrade(String tradeId) async {
|
||||
final response = await apiClient.post('/api/v1/user/trades/$tradeId/reject');
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Trade konnte nicht abgelehnt werden');
|
||||
}
|
||||
}
|
||||
// NOTE: there is intentionally no `rejectTrade` here anymore. A trade
|
||||
// proposal is a system-wide opportunity that many users may accept
|
||||
// independently; "rejecting" it server-side would have no meaning and
|
||||
// the corresponding endpoint (`POST /api/v1/user/trades/{id}/reject`) has
|
||||
// been removed. Dismissing a proposal is now a purely local UI action
|
||||
// (see `AssetTradesBloc`'s `DismissTradeEvent`) — the proposal keeps
|
||||
// existing server-side until it naturally expires (24h TTL).
|
||||
|
||||
Future<void> closeTrade(String id, {CloseTradeRequestDto? dto}) async {
|
||||
final response = await apiClient.post('/api/v1/user/trades/$id/close', data: dto?.toJson());
|
||||
@@ -86,5 +116,34 @@ class TradeRepository {
|
||||
throw Exception('Trade konnte nicht geschlossen werden');
|
||||
}
|
||||
}
|
||||
|
||||
/// Records an additional/corrective fill against an already-active trade
|
||||
/// (`EngineController.AddTradeFill` -> `engine_AddFill`). This is the
|
||||
/// correct server-side counterpart for the "review/edit execution
|
||||
/// numbers" path on an active trade — unlike `acceptTrade`, which targets
|
||||
/// a proposal, not an existing trade. `userId`/`tradeId` are always
|
||||
/// overwritten server-side from the JWT claim/route, never trusted from
|
||||
/// this payload.
|
||||
Future<TradeModel> addTradeFill(
|
||||
String tradeId, {
|
||||
required double executedPrice,
|
||||
required double quantity,
|
||||
double fee = 0,
|
||||
String? note,
|
||||
}) async {
|
||||
final response = await apiClient.post(
|
||||
'/api/v1/engine/trades/$tradeId/fills',
|
||||
data: {
|
||||
'executedPrice': executedPrice,
|
||||
'quantity': quantity,
|
||||
'fee': fee,
|
||||
if (note != null) 'note': note,
|
||||
},
|
||||
);
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
return TradeModel.fromJson(response.data);
|
||||
}
|
||||
throw Exception('Ausführung konnte nicht gespeichert werden');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/signalr_service.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../bot/repositories/bot_repository.dart';
|
||||
import '../../proposals/views/proposal_decision_screen.dart';
|
||||
import '../bloc/trade_bloc.dart';
|
||||
import '../bloc/trade_event.dart';
|
||||
import '../bloc/trade_state.dart';
|
||||
@@ -31,13 +34,22 @@ class TradesFeedScreen extends StatelessWidget {
|
||||
create: (context) => TradeBloc(
|
||||
repository: TradeRepository(apiClient: apiClient),
|
||||
)..add(const FetchTrades()),
|
||||
child: const _TradesFeedScreenContent(),
|
||||
child: _TradesFeedScreenContent(
|
||||
apiClient: apiClient,
|
||||
signalRService: signalRService,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TradesFeedScreenContent extends StatefulWidget {
|
||||
const _TradesFeedScreenContent();
|
||||
final ApiClient apiClient;
|
||||
final SignalRService signalRService;
|
||||
|
||||
const _TradesFeedScreenContent({
|
||||
required this.apiClient,
|
||||
required this.signalRService,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TradesFeedScreenContent> createState() => _TradesFeedScreenContentState();
|
||||
@@ -47,10 +59,129 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
String _selectedFilter = 'Offen';
|
||||
String _searchQuery = '';
|
||||
final TextEditingController _searchCtrl = TextEditingController();
|
||||
StreamSubscription<Map<String, dynamic>>? _proposalSubscription;
|
||||
late final BotRepository _botRepository;
|
||||
late final TradeRepository _tradeRepository;
|
||||
|
||||
// Persistent, REST-backed list of currently active, system-wide trade
|
||||
// proposals (`GET /api/v1/user/trades?status=Proposed`). This is
|
||||
// deliberately independent of `TradeBloc`/`allTrades`: proposals are not
|
||||
// owned by any user, so they never show up in `engine_GetTrades`
|
||||
// (`ActiveTradeDto`/`TradeModel`), which is what `TradeBloc` fetches. Before
|
||||
// this list existed, the only way a proposal ever reached the UI was the
|
||||
// live SignalR push below — a user who wasn't online with the app
|
||||
// connected at the exact moment a proposal was created would never see it,
|
||||
// even though it stays valid for up to 24h server-side.
|
||||
List<TradeProposalModel> _proposals = [];
|
||||
bool _proposalsLoading = true;
|
||||
String? _proposalsError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_botRepository = BotRepository(apiClient: widget.apiClient);
|
||||
_tradeRepository = TradeRepository(apiClient: widget.apiClient);
|
||||
|
||||
_loadProposals();
|
||||
|
||||
// Live proposals pushed by the strategy engine (`/hubs/trade-stream`,
|
||||
// event `ReceiveTradeProposal`) are surfaced immediately as a decision
|
||||
// screen instead of only appearing once persisted in the trades list.
|
||||
_proposalSubscription = widget.signalRService.tradeProposalStream.listen((json) {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
final proposal = TradeProposalModel.fromJson(json);
|
||||
_mergeLiveProposal(proposal);
|
||||
_showProposalDecision(proposal);
|
||||
} catch (_) {
|
||||
// Malformed live payload: ignore rather than show a broken screen.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadProposals() async {
|
||||
setState(() {
|
||||
_proposalsLoading = true;
|
||||
_proposalsError = null;
|
||||
});
|
||||
try {
|
||||
final proposals = await _tradeRepository.fetchProposals();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_proposals = proposals;
|
||||
_proposalsLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_proposalsError = 'Vorschläge konnten nicht geladen werden.';
|
||||
_proposalsLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts/updates a proposal received live over SignalR into the
|
||||
/// persistent list without waiting for a full refetch, so the list stays
|
||||
/// consistent if it happens to be visible when the push arrives.
|
||||
void _mergeLiveProposal(TradeProposalModel proposal) {
|
||||
setState(() {
|
||||
final idx = _proposals.indexWhere((p) => p.proposalId == proposal.proposalId);
|
||||
if (idx >= 0) {
|
||||
_proposals[idx] = proposal;
|
||||
} else {
|
||||
_proposals = [proposal, ..._proposals];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showProposalDecision(TradeProposalModel proposal) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProposalDecisionScreen(
|
||||
proposal: proposal,
|
||||
onExecuteBot: () => _executeProposalViaBot(proposal),
|
||||
onManualTrade: () {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Manuelle Eröffnung: Bitte über die Order-Maske deines Brokers ausführen.'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _executeProposalViaBot(TradeProposalModel proposal) async {
|
||||
Navigator.of(context).pop();
|
||||
try {
|
||||
await _botRepository.executeProposal(proposal.proposalId);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Vorschlag für ${proposal.symbol} an den Bot übergeben.'),
|
||||
backgroundColor: AppTheme.primaryEmerald,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Fehler bei der Bot-Übergabe: $e'),
|
||||
backgroundColor: AppTheme.accentRed,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
_proposalSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -60,7 +191,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
TradeExecutionCockpit.show(
|
||||
context,
|
||||
trade: trade,
|
||||
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.isin,
|
||||
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.underlyingIsin,
|
||||
isActive: isActive,
|
||||
onAccept: (dto) {
|
||||
tradeBloc.add(AcceptTradeProposalEvent(dto));
|
||||
@@ -81,7 +212,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
TradeClosingCockpit.show(
|
||||
context,
|
||||
trade: trade,
|
||||
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.isin,
|
||||
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.underlyingIsin,
|
||||
onClose: (CloseTradeRequestDto dto) {
|
||||
tradeBloc.add(CloseTrade(trade.id, dto: dto));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -103,40 +234,10 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<TradeBloc>().add(const FetchTrades());
|
||||
await _loadProposals();
|
||||
},
|
||||
color: AppTheme.primaryEmerald,
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Live Portfolio & Trades',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'KI-Guardian Überwachung, Drift-Radar & Order-Cockpit',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.read<TradeBloc>().add(const FetchTrades()),
|
||||
icon: const Icon(Icons.refresh, color: Colors.white70),
|
||||
tooltip: 'Trades Aktualisieren',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: BlocBuilder<TradeBloc, TradeState>(
|
||||
builder: (context, state) {
|
||||
if (state is TradeLoading) {
|
||||
@@ -163,35 +264,85 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
}
|
||||
|
||||
if (state is TradeLoaded) {
|
||||
// `allTrades` comes from `GET /api/v1/user/trades`, which only ever
|
||||
// returns trades belonging to the signed-in user (own proposals,
|
||||
// own open positions, own history) — never other users' or
|
||||
// system-wide data. `bookedTrades` excludes still-open proposals so
|
||||
// the "Trades" chip's count matches what it actually displays,
|
||||
// instead of silently disagreeing with the "Vorschläge" chip.
|
||||
final allTrades = state.trades;
|
||||
final proposals = allTrades.where((t) => t.isProposed).toList();
|
||||
final bookedTrades = allTrades.where((t) => !t.isProposed).toList();
|
||||
final activeTrades = allTrades.where((t) => t.isActive).toList();
|
||||
final closedTrades = allTrades.where((t) => t.isClosed).toList();
|
||||
final rejectedTrades = allTrades.where((t) => t.isRejected).toList();
|
||||
|
||||
// The "Vorschläge" chip does NOT render `proposals` (derived
|
||||
// from `allTrades`/`TradeModel`): proposals are system-wide
|
||||
// opportunities not owned by any user, so `engine_GetTrades`
|
||||
// (what `TradeBloc`/`allTrades` fetches) never returns them —
|
||||
// that list is structurally always empty. The real,
|
||||
// persistent set of active proposals is `_proposals`, fetched
|
||||
// separately via `GET /api/v1/user/trades?status=Proposed`
|
||||
// and rendered by `_buildProposalsSliver` below.
|
||||
final showingProposalsTab = _selectedFilter == 'Vorschläge';
|
||||
|
||||
List<TradeModel> filteredList = allTrades;
|
||||
if (_selectedFilter == 'Alle') {
|
||||
filteredList = allTrades.where((t) => !t.isProposed).toList();
|
||||
if (_selectedFilter == 'Trades') {
|
||||
filteredList = bookedTrades;
|
||||
} else if (_selectedFilter == 'Offen') {
|
||||
filteredList = activeTrades;
|
||||
} else if (_selectedFilter == 'Vorschläge') {
|
||||
filteredList = proposals;
|
||||
filteredList = const [];
|
||||
} else if (_selectedFilter == 'Geschlossen') {
|
||||
filteredList = closedTrades;
|
||||
} else if (_selectedFilter == 'Abgelehnt') {
|
||||
filteredList = rejectedTrades;
|
||||
}
|
||||
|
||||
List<TradeProposalModel> filteredProposals = _proposals;
|
||||
|
||||
if (_searchQuery.trim().isNotEmpty) {
|
||||
final q = _searchQuery.toLowerCase().trim();
|
||||
filteredList = filteredList.where((t) =>
|
||||
t.symbol.toLowerCase().contains(q) ||
|
||||
t.isin.toLowerCase().contains(q) ||
|
||||
t.companyName.toLowerCase().contains(q)).toList();
|
||||
t.underlyingIsin.toLowerCase().contains(q)).toList();
|
||||
filteredProposals = filteredProposals.where((p) =>
|
||||
p.symbol.toLowerCase().contains(q) ||
|
||||
p.underlyingIsin.toLowerCase().contains(q)).toList();
|
||||
}
|
||||
|
||||
return ListView(
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Live Portfolio & Trades',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'KI-Guardian Überwachung, Drift-Radar & Order-Cockpit',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.read<TradeBloc>().add(const FetchTrades()),
|
||||
icon: const Icon(Icons.refresh, color: Colors.white70),
|
||||
tooltip: 'Trades Aktualisieren',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TradePerformanceBar(
|
||||
activeTrades: activeTrades,
|
||||
allTrades: allTrades,
|
||||
@@ -228,17 +379,23 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
child: Row(
|
||||
children: [
|
||||
_filterChip('Offen', activeTrades.length),
|
||||
_filterChip('Vorschläge', proposals.length),
|
||||
_filterChip('Vorschläge', _proposals.length),
|
||||
_filterChip('Geschlossen', closedTrades.length),
|
||||
_filterChip('Abgelehnt', rejectedTrades.length),
|
||||
_filterChip('Alle', allTrades.length),
|
||||
_filterChip('Trades', bookedTrades.length),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (filteredList.isEmpty)
|
||||
Padding(
|
||||
]),
|
||||
),
|
||||
),
|
||||
if (showingProposalsTab)
|
||||
..._buildProposalsSlivers(filteredProposals)
|
||||
else if (filteredList.isEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -248,11 +405,12 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: filteredList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final trade = filteredList[index];
|
||||
@@ -264,6 +422,8 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -272,11 +432,144 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the sliver(s) for the "Vorschläge" tab: a persistent, REST-backed
|
||||
/// list of every currently active proposal (see `_proposals`/`_loadProposals`),
|
||||
/// not just whatever happened to arrive live over SignalR while this screen
|
||||
/// was open. Loading/error/empty are all explicit states (Rules.md §4) —
|
||||
/// there is no silent "nothing shown" case.
|
||||
List<Widget> _buildProposalsSlivers(List<TradeProposalModel> proposals) {
|
||||
if (_proposalsLoading) {
|
||||
return [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald)),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (_proposalsError != null) {
|
||||
return [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 40, color: AppTheme.accentRed),
|
||||
const SizedBox(height: 8),
|
||||
Text(_proposalsError!, style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _loadProposals,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald),
|
||||
child: const Text('Erneut Versuchen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (proposals.isEmpty) {
|
||||
return [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.inbox, size: 40, color: AppTheme.textMuted),
|
||||
const SizedBox(height: 8),
|
||||
Text('Keine aktiven Vorschläge.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: proposals.length,
|
||||
itemBuilder: (context, index) => _buildProposalListCard(proposals[index]),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildProposalListCard(TradeProposalModel proposal) {
|
||||
final isLong = proposal.isLong;
|
||||
final signalColor = isLong ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
final symbol = proposal.symbol.isNotEmpty ? proposal.symbol : proposal.underlyingIsin;
|
||||
final expiresAtUtc = proposal.expiresAtUtc;
|
||||
final remaining = expiresAtUtc?.difference(DateTime.now().toUtc());
|
||||
final expiryLabel = remaining == null
|
||||
? 'Ablauf unbekannt'
|
||||
: remaining.isNegative
|
||||
? 'Abgelaufen'
|
||||
: remaining.inHours >= 1
|
||||
? 'Läuft in ${remaining.inHours}h ab'
|
||||
: 'Läuft in ${remaining.inMinutes}min ab';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
color: AppTheme.cardSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () => _showProposalDecision(proposal),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: signalColor.withValues(alpha: 0.15),
|
||||
border: Border.all(color: signalColor.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
isLong ? 'LONG' : 'SHORT',
|
||||
style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 11),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(symbol, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Einstieg €${proposal.entryPrice.toStringAsFixed(2)} · Score ${proposal.compositeScore.toStringAsFixed(0)} · $expiryLabel',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.chevron_right, color: AppTheme.textMuted, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,4 +617,3 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class ProposedAutoTradesCard extends StatelessWidget {
|
||||
|
||||
// Top proposal
|
||||
final topProposal = proposals.first;
|
||||
final isBuy = topProposal.signalType.toUpperCase() == 'BUY' || topProposal.signalType.toUpperCase() == 'LONG';
|
||||
final isBuy = topProposal.direction.isLong;
|
||||
final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
|
||||
return Container(
|
||||
@@ -147,7 +147,7 @@ class ProposedAutoTradesCard extends StatelessWidget {
|
||||
const Icon(Icons.auto_awesome, size: 14, color: Colors.amber),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Score: ${topProposal.winRate.toStringAsFixed(0)}%',
|
||||
topProposal.instrumentType.label,
|
||||
style: const TextStyle(color: Colors.amber, fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
],
|
||||
@@ -170,7 +170,7 @@ class ProposedAutoTradesCard extends StatelessWidget {
|
||||
border: Border.all(color: signalColor.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
topProposal.signalType.toUpperCase(),
|
||||
topProposal.direction.label,
|
||||
style: TextStyle(color: signalColor, fontWeight: FontWeight.w900, fontSize: 14, letterSpacing: 1),
|
||||
),
|
||||
),
|
||||
@@ -180,14 +180,11 @@ class ProposedAutoTradesCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
topProposal.symbol.isNotEmpty && topProposal.symbol != 'UNKNOWN'
|
||||
? topProposal.symbol
|
||||
: (topProposal.companyName.isNotEmpty && topProposal.companyName != 'UNKNOWN' ? topProposal.companyName : (topProposal.isin.isNotEmpty ? topProposal.isin : 'Aktie')),
|
||||
topProposal.symbol.isNotEmpty ? topProposal.symbol : topProposal.underlyingIsin,
|
||||
style: const TextStyle(fontWeight: FontWeight.w900, fontSize: 24, color: Colors.white, height: 1.1),
|
||||
),
|
||||
if (topProposal.companyName.isNotEmpty && topProposal.companyName != topProposal.symbol && topProposal.companyName != 'UNKNOWN')
|
||||
Text(
|
||||
topProposal.companyName,
|
||||
topProposal.underlyingIsin,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -198,7 +195,6 @@ class ProposedAutoTradesCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
|
||||
if (topProposal.reasoning.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -214,7 +210,8 @@ class ProposedAutoTradesCard extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
topProposal.reasoning,
|
||||
'Einstieg €${topProposal.averageBuyIn.toStringAsFixed(2)} • Stop-Loss €${topProposal.currentStopLoss.toStringAsFixed(2)}'
|
||||
'${topProposal.primaryTakeProfit != null ? ' • Take-Profit €${topProposal.primaryTakeProfit!.toStringAsFixed(2)}' : ''}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -223,7 +220,6 @@ class ProposedAutoTradesCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
@@ -35,16 +35,15 @@ class _TradeAcceptanceDialogState extends State<TradeAcceptanceDialog> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_entryPriceCtrl = TextEditingController(text: widget.trade.entryPrice.toStringAsFixed(2));
|
||||
_entryPriceCtrl = TextEditingController(text: widget.trade.averageBuyIn.toStringAsFixed(2));
|
||||
_positionSizeCtrl = TextEditingController(text: '1000');
|
||||
final lev = widget.trade.maxLeverage > 0 ? widget.trade.maxLeverage : 1.0;
|
||||
_leverageCtrl = TextEditingController(text: lev == lev.roundToDouble() ? lev.toInt().toString() : lev.toStringAsFixed(2));
|
||||
_stopLossCtrl = TextEditingController(text: widget.trade.stopLoss.toStringAsFixed(2));
|
||||
_takeProfitCtrl = TextEditingController(text: widget.trade.takeProfit.toStringAsFixed(2));
|
||||
_leverageCtrl = TextEditingController(text: '1');
|
||||
_stopLossCtrl = TextEditingController(text: widget.trade.currentStopLoss.toStringAsFixed(2));
|
||||
_takeProfitCtrl = TextEditingController(text: (widget.trade.primaryTakeProfit ?? 0.0).toStringAsFixed(2));
|
||||
_notesCtrl = TextEditingController();
|
||||
_entryFeeCtrl = TextEditingController(text: '0.00');
|
||||
_exitFeeCtrl = TextEditingController(text: '0.00');
|
||||
double price = widget.trade.entryPrice;
|
||||
double price = widget.trade.averageBuyIn;
|
||||
_quantityCtrl = TextEditingController(text: (1000 / (price > 0 ? price : 1)).toStringAsFixed(4));
|
||||
}
|
||||
|
||||
@@ -176,8 +175,7 @@ class _TradeAcceptanceDialogState extends State<TradeAcceptanceDialog> {
|
||||
final dto = TradeAcceptanceDto(
|
||||
userId: widget.userId,
|
||||
tradeId: widget.trade.id,
|
||||
analysisId: widget.trade.analysisId,
|
||||
isin: widget.trade.isin,
|
||||
isin: widget.trade.underlyingIsin,
|
||||
symbol: widget.trade.symbol,
|
||||
actualEntryPrice: _parseNum(_entryPriceCtrl.text),
|
||||
positionSize: _parseNum(_positionSizeCtrl.text),
|
||||
@@ -187,13 +185,11 @@ class _TradeAcceptanceDialogState extends State<TradeAcceptanceDialog> {
|
||||
quantity: _parseNum(_quantityCtrl.text),
|
||||
isRecurring: _isRecurring,
|
||||
executionTimestamp: DateTime.now().toUtc(),
|
||||
signalType: widget.trade.signalType,
|
||||
entryPrice: widget.trade.entryPrice,
|
||||
stopLoss: widget.trade.stopLoss,
|
||||
takeProfit: widget.trade.takeProfit,
|
||||
instrumentType: widget.trade.instrumentType,
|
||||
timeframe: widget.trade.timeframe,
|
||||
reasoning: widget.trade.reasoning,
|
||||
signalType: widget.trade.direction.label,
|
||||
entryPrice: widget.trade.averageBuyIn,
|
||||
stopLoss: widget.trade.currentStopLoss,
|
||||
takeProfit: widget.trade.primaryTakeProfit,
|
||||
instrumentType: widget.trade.instrumentType.label,
|
||||
);
|
||||
Navigator.of(context).pop(dto);
|
||||
},
|
||||
|
||||
@@ -14,62 +14,25 @@ class TradeCalculationCard extends StatelessWidget {
|
||||
this.isCollapsible = false,
|
||||
});
|
||||
|
||||
String _formatLeverage(double lev) {
|
||||
if (lev <= 0) return '1x';
|
||||
if (lev == lev.roundToDouble()) {
|
||||
return '${lev.toInt()}x';
|
||||
}
|
||||
var s = lev.toStringAsFixed(2);
|
||||
if (s.endsWith('0')) {
|
||||
s = s.substring(0, s.length - 1);
|
||||
}
|
||||
return '${s.replaceAll('.', ',')}x';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entry = trade.actualEntryPrice > 0
|
||||
? trade.actualEntryPrice
|
||||
: (trade.entryPrice > 0 ? trade.entryPrice : 1.0);
|
||||
final posSize = trade.positionSize > 0 ? trade.positionSize : 1000.0;
|
||||
final lev = trade.leverageUsed > 0 ? trade.leverageUsed : 1.0;
|
||||
final isShort = trade.signalType.toUpperCase() == 'SELL' ||
|
||||
trade.signalType.toUpperCase() == 'SHORT';
|
||||
final totalFees = trade.entryFee + (trade.exitFee > 0 ? trade.exitFee : 1.0);
|
||||
final entry = trade.averageBuyIn;
|
||||
final quantity = trade.totalQuantity;
|
||||
final posSize = entry * quantity;
|
||||
final isShort = !trade.direction.isLong;
|
||||
|
||||
final quantity = entry > 0 ? (posSize / entry) : 0.0;
|
||||
// Distance-to-stop risk, computed only from real DTO fields (averageBuyIn,
|
||||
// currentStopLoss, totalQuantity) — no fee/leverage assumptions are
|
||||
// invented since ActiveTradeDto no longer carries either (Rules.md §4).
|
||||
final sl = trade.currentStopLoss;
|
||||
final riskAmountAbs = (isShort ? (sl - entry) : (entry - sl)).abs() * quantity;
|
||||
|
||||
// SL Risk
|
||||
final sl = trade.stopLoss;
|
||||
final movePctSL = entry > 0 && sl > 0
|
||||
? (isShort ? ((sl - entry) / entry) : ((entry - sl) / entry))
|
||||
: 0.0;
|
||||
final rawLoss = (movePctSL * posSize * lev).abs();
|
||||
final isDerivative = trade.instrumentType.toLowerCase().contains('knock') ||
|
||||
trade.instrumentType.toLowerCase().contains('option') ||
|
||||
trade.instrumentType.toLowerCase().contains('factor') ||
|
||||
trade.instrumentType.toLowerCase().contains('turbo');
|
||||
final cappedLoss = isDerivative ? rawLoss.clamp(0.0, posSize) : rawLoss;
|
||||
final riskAmountAbs = cappedLoss + totalFees;
|
||||
// Reward-to-target(s), same principle.
|
||||
final stages = trade.exitPlan.takeProfitStages;
|
||||
final primaryTp = trade.primaryTakeProfit;
|
||||
final rewardAmountAbs = primaryTp != null ? (isShort ? (entry - primaryTp) : (primaryTp - entry)).abs() * quantity : null;
|
||||
|
||||
// TP Reward
|
||||
final tp = trade.takeProfit;
|
||||
final movePctTP = entry > 0 && tp > 0
|
||||
? (isShort ? ((entry - tp) / entry) : ((tp - entry) / entry))
|
||||
: 0.0;
|
||||
final rawProfit = (movePctTP * posSize * lev);
|
||||
final profitAfterFees = rawProfit - totalFees;
|
||||
final rewardAmountAbs = profitAfterFees > 0 ? profitAfterFees : 0.0;
|
||||
|
||||
// CRV
|
||||
final crv = (riskAmountAbs > 0 && rewardAmountAbs > 0)
|
||||
? (rewardAmountAbs / riskAmountAbs)
|
||||
: 0.0;
|
||||
|
||||
// Multi-Targets
|
||||
final targets = trade.takeProfitTargets.isNotEmpty
|
||||
? trade.takeProfitTargets
|
||||
: (trade.takeProfit > 0 ? [trade.takeProfit] : <double>[]);
|
||||
final crv = (rewardAmountAbs != null && riskAmountAbs > 0) ? (rewardAmountAbs / riskAmountAbs) : null;
|
||||
|
||||
final content = Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
@@ -85,47 +48,33 @@ class TradeCalculationCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_statTile(
|
||||
'Stückzahl (Basiswert)',
|
||||
'Stückzahl',
|
||||
'${quantity.toStringAsFixed(2)} Stk.',
|
||||
Colors.white,
|
||||
),
|
||||
_statTile(
|
||||
'Max. Verlust (SL)',
|
||||
'Risiko bis Stop-Loss',
|
||||
'-€${riskAmountAbs.toStringAsFixed(2)}',
|
||||
AppTheme.accentRed,
|
||||
),
|
||||
_statTile(
|
||||
'Gewinn-Potenzial (TP)',
|
||||
'+€${rewardAmountAbs.toStringAsFixed(2)}',
|
||||
'Potenzial bis 1. Ziel',
|
||||
rewardAmountAbs != null ? '+€${rewardAmountAbs.toStringAsFixed(2)}' : 'Trailing-Exit',
|
||||
AppTheme.primaryEmerald,
|
||||
),
|
||||
_statTile(
|
||||
'Chance-Risiko (CRV)',
|
||||
crv > 0 ? '1 : ${crv.toStringAsFixed(2)}' : '-',
|
||||
crv != null ? '1 : ${crv.toStringAsFixed(2)}' : '-',
|
||||
AppTheme.accentCyan,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(color: Colors.white10, height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Pauschalgebühren: €${totalFees.toStringAsFixed(2)} (€${trade.entryFee.toStringAsFixed(2)} Kauf + €${(trade.exitFee > 0 ? trade.exitFee : 1.0).toStringAsFixed(2)} Verkauf)',
|
||||
'Investiertes Kapital: €${posSize.toStringAsFixed(2)} • Exit-Strategie: ${trade.exitPlan.strategyType.label}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
if (lev > 1.0)
|
||||
Text(
|
||||
'Effektiver Hebel: ${_formatLeverage(lev)}',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentCyan,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (targets.length > 1) ...[
|
||||
if (stages.length > 1) ...[
|
||||
const Divider(color: Colors.white10, height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
@@ -140,30 +89,24 @@ class TradeCalculationCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${targets.length} Ziele',
|
||||
'${stages.length} Ziele',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...targets.asMap().entries.map((entryItem) {
|
||||
final idx = entryItem.key;
|
||||
final targetPrice = entryItem.value;
|
||||
final isCurrent = (tp - targetPrice).abs() < 0.001;
|
||||
...stages.map((stage) {
|
||||
final targetPrice = stage.targetPrice;
|
||||
final isCurrent = primaryTp != null && (primaryTp - targetPrice).abs() < 0.001;
|
||||
|
||||
final targetMovePct = entry > 0
|
||||
? (isShort
|
||||
? ((entry - targetPrice) / entry)
|
||||
: ((targetPrice - entry) / entry))
|
||||
: 0.0;
|
||||
final rawTargetProfit = targetMovePct * posSize * lev;
|
||||
final netTargetProfit = rawTargetProfit - totalFees;
|
||||
final targetMove = (isShort ? (entry - targetPrice) : (targetPrice - entry));
|
||||
final netTargetProfit = targetMove * quantity;
|
||||
final cappedNet = netTargetProfit > 0 ? netTargetProfit : 0.0;
|
||||
final retPct = posSize > 0 ? (cappedNet / posSize * 100) : 0.0;
|
||||
final targetCrv = (riskAmountAbs > 0 && cappedNet > 0)
|
||||
? (cappedNet / riskAmountAbs)
|
||||
: 0.0;
|
||||
final baseMove = (targetMovePct * 100).abs();
|
||||
final baseMove = entry > 0 ? (targetMove / entry * 100).abs() : 0.0;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
@@ -195,7 +138,7 @@ class TradeCalculationCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'TP${idx + 1}',
|
||||
'TP${stage.stageNumber}',
|
||||
style: TextStyle(
|
||||
color: isCurrent ? Colors.black : Colors.white,
|
||||
fontSize: 10,
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../core/widgets/status_badge.dart';
|
||||
import '../../favorites/cubit/favorites_cubit.dart';
|
||||
import '../../favorites/models/favorite_asset_model.dart';
|
||||
import '../models/trade_model.dart';
|
||||
import 'trade_calculation_card.dart';
|
||||
import 'trade_detail_modal.dart';
|
||||
|
||||
/// Migrated onto `ActiveTradeDto`. `trade.currentPrice` is the engine's own
|
||||
/// tracked live price, so this card no longer needs to cross-reference the
|
||||
/// favorites feed for a "live" quote — doing so would just be a second,
|
||||
/// possibly-stale source of truth for a number the trade payload already
|
||||
/// carries. The old "is this actually a derivative quote or the underlying's"
|
||||
/// heuristic (comparing `entryPrice`/`actualEntryPrice` magnitudes) is gone
|
||||
/// too: there is only one entry price now (`averageBuyIn`, the real
|
||||
/// fill-weighted average), so there is nothing left to disambiguate.
|
||||
class TradeCard extends StatelessWidget {
|
||||
final TradeModel trade;
|
||||
final VoidCallback? onAccept;
|
||||
@@ -23,31 +28,28 @@ class TradeCard extends StatelessWidget {
|
||||
this.onSettings,
|
||||
});
|
||||
|
||||
String _fmt(double val) => val.toStringAsFixed(2);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isBuy = trade.signalType == 'BUY' || trade.signalType == 'LONG';
|
||||
final isBuy = trade.direction.isLong;
|
||||
final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
final isProposed = trade.isProposed;
|
||||
final isActive = trade.isActive;
|
||||
final isClosed = trade.isClosed;
|
||||
|
||||
return BlocBuilder<FavoritesCubit, FavoritesState>(
|
||||
builder: (context, favState) {
|
||||
double livePrice = 0.0;
|
||||
final keyUpper = (trade.isin.isNotEmpty ? trade.isin : trade.symbol).toUpperCase();
|
||||
final match = favState.favoriteDetails.firstWhere(
|
||||
(f) => f.isin.toUpperCase() == keyUpper || f.symbol.toUpperCase() == keyUpper,
|
||||
orElse: () => const FavoriteAssetModel(isin: '', symbol: '', name: '', currentPrice: 0.0, change24h: 0.0),
|
||||
);
|
||||
if (match.currentPrice > 0) {
|
||||
livePrice = match.currentPrice;
|
||||
}
|
||||
|
||||
final pnlAbs = livePrice > 0 ? trade.calculateLivePnlAbs(livePrice) : trade.calculatedPnlAbs;
|
||||
final pnlPct = livePrice > 0 ? trade.calculateLivePnlPct(livePrice) : trade.calculatedPnlPct;
|
||||
// Server-computed, never recalculated client-side (Rules.md: don't
|
||||
// re-derive P&L — `pnlEur` picks realized vs. unrealized, `
|
||||
// unrealizedPnlPercent` is populated by the engine for both open and
|
||||
// closed trades since `CurrentPrice` is pinned to the close price once
|
||||
// a trade is closed).
|
||||
final pnlAbs = trade.pnlEur;
|
||||
final pnlPct = trade.unrealizedPnlPercent;
|
||||
final isPnlPos = pnlAbs >= 0;
|
||||
final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
final currPrice = livePrice > 0 ? livePrice : trade.effectiveCurrentPrice;
|
||||
final currPrice = trade.currentPrice;
|
||||
final tpStages = trade.exitPlan.takeProfitStages;
|
||||
final primaryTp = trade.primaryTakeProfit;
|
||||
|
||||
return GlassContainer(
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
@@ -70,7 +72,7 @@ class TradeCard extends StatelessWidget {
|
||||
children: [
|
||||
Icon(isBuy ? Icons.trending_up : Icons.trending_down, size: 14, color: signalColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(trade.signalType, style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
Text(trade.direction.label, style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -83,14 +85,11 @@ class TradeCard extends StatelessWidget {
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
trade.companyName.isNotEmpty && trade.companyName != 'UNKNOWN'
|
||||
? trade.companyName
|
||||
: (trade.symbol.isNotEmpty && trade.symbol != 'UNKNOWN' ? trade.symbol : (trade.isin.isNotEmpty ? trade.isin : 'Position')),
|
||||
trade.symbol.isNotEmpty ? trade.symbol : (trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : 'Position'),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (trade.instrumentType.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
@@ -98,14 +97,13 @@ class TradeCard extends StatelessWidget {
|
||||
color: Colors.white10,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(trade.instrumentType, style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
child: Text(trade.instrumentType.label, style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${trade.symbol.isNotEmpty ? trade.symbol : ""} ${trade.isin.isNotEmpty ? "• " + trade.isin : ""}',
|
||||
'${trade.symbol.isNotEmpty ? trade.symbol : ""}${trade.underlyingIsin.isNotEmpty ? " • ${trade.underlyingIsin}" : ""}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
],
|
||||
@@ -136,9 +134,9 @@ class TradeCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
] else if (trade.isRejected) ...[
|
||||
StatusBadge(label: 'ABGELEHNT', color: AppTheme.accentRed),
|
||||
StatusBadge(label: trade.status.label, color: AppTheme.accentRed),
|
||||
] else ...[
|
||||
StatusBadge(label: 'VORSCHLAG', color: Colors.amber),
|
||||
StatusBadge(label: trade.status.label, color: Colors.amber),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -149,55 +147,6 @@ class TradeCard extends StatelessWidget {
|
||||
_buildDriftRadarBar(trade),
|
||||
],
|
||||
|
||||
// PENDING EXIT ALERT BANNER (Zero Auto-Close notification)
|
||||
if (isActive && trade.hasPendingExitAlert) ...[
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentRed.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: AppTheme.accentRed, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'KI-Guardian Ratschlag: Position schließen!',
|
||||
style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
if (trade.pendingExitReason.isNotEmpty)
|
||||
Text(
|
||||
trade.pendingExitReason,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onClose != null)
|
||||
ElevatedButton(
|
||||
onPressed: onClose,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentRed,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text('Schließen', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Price Metrics Grid with Live Kurs & Trailing SL
|
||||
@@ -211,29 +160,23 @@ class TradeCard extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_priceItem(
|
||||
isActive || isClosed ? 'Einstieg' : 'Ziel-Einstieg',
|
||||
trade.actualEntryPrice > 0
|
||||
? '€${trade.actualEntryPrice.toStringAsFixed(2)}'
|
||||
: (trade.entryPrice > 0 ? '€${trade.entryPrice.toStringAsFixed(2)}' : '-'),
|
||||
Colors.white,
|
||||
),
|
||||
_priceItem('Live-Kurs', '€${currPrice.toStringAsFixed(2)}', AppTheme.accentCyan),
|
||||
_priceItem('Einstieg', trade.averageBuyIn > 0 ? '€${_fmt(trade.averageBuyIn)}' : '–', Colors.white),
|
||||
_priceItem('Live-Kurs', currPrice > 0 ? '€${_fmt(currPrice)}' : '–', AppTheme.accentCyan),
|
||||
_priceItem(
|
||||
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
|
||||
'€${trade.stopLoss.toStringAsFixed(2)}',
|
||||
'€${_fmt(trade.currentStopLoss)}',
|
||||
AppTheme.accentRed,
|
||||
),
|
||||
_priceItem(
|
||||
trade.takeProfitTargets.length > 1 ? 'TP (Aktuell)' : 'Take-Profit',
|
||||
'€${trade.takeProfit.toStringAsFixed(2)}',
|
||||
tpStages.length > 1 ? 'TP (1. Stufe)' : 'Take-Profit',
|
||||
primaryTp != null ? '€${_fmt(primaryTp)}' : 'Trailing-Exit',
|
||||
AppTheme.primaryEmerald,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (trade.takeProfitTargets.length > 1) ...[
|
||||
if (tpStages.length > 1) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
@@ -245,10 +188,8 @@ class TradeCard extends StatelessWidget {
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: trade.takeProfitTargets.asMap().entries.map((entry) {
|
||||
final idx = entry.key;
|
||||
final tpVal = entry.value;
|
||||
final isCurrent = (trade.takeProfit - tpVal).abs() < 0.01;
|
||||
children: tpStages.map((stage) {
|
||||
final isCurrent = primaryTp != null && (primaryTp - stage.targetPrice).abs() < 0.01;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
@@ -264,7 +205,7 @@ class TradeCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'TP${idx + 1}: €${tpVal.toStringAsFixed(2)}',
|
||||
'TP${stage.stageNumber}: €${_fmt(stage.targetPrice)}',
|
||||
style: TextStyle(
|
||||
color: isCurrent ? AppTheme.primaryEmerald : Colors.white70,
|
||||
fontSize: 10.5,
|
||||
@@ -279,18 +220,10 @@ class TradeCard extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
|
||||
if (trade.reasoning.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
trade.reasoning,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
// KI-Timeline Expansion if updates exist
|
||||
if (trade.hourlyUpdates.isNotEmpty) ...[
|
||||
// Execution history (replaces the removed AI-Guardian hourly
|
||||
// check-in timeline, which no backend DTO produces anymore — this
|
||||
// is the trade's real fill history instead).
|
||||
if (trade.fills.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
@@ -298,10 +231,10 @@ class TradeCard extends StatelessWidget {
|
||||
dense: true,
|
||||
leading: Icon(Icons.history_toggle_off, color: AppTheme.accentCyan, size: 18),
|
||||
title: Text(
|
||||
'KI-Guardian Verlauf (${trade.hourlyUpdates.length} Prüfungen)',
|
||||
'Ausführungshistorie (${trade.fills.length} Fills)',
|
||||
style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
children: trade.hourlyUpdates.reversed.take(4).map((u) {
|
||||
children: trade.fills.reversed.take(4).map((f) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -312,25 +245,13 @@ class TradeCard extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${u.timestamp.hour.toString().padLeft(2, '0')}:${u.timestamp.minute.toString().padLeft(2, '0')}',
|
||||
'${f.executedAtUtc.hour.toString().padLeft(2, '0')}:${f.executedAtUtc.minute.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (u.recommendation.toLowerCase().contains('close')
|
||||
? AppTheme.accentRed
|
||||
: (u.recommendation.toLowerCase().contains('adjust') ? Colors.blue : AppTheme.primaryEmerald))
|
||||
.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(u.recommendation, style: const TextStyle(color: Colors.white70, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
u.reasoning.isNotEmpty ? u.reasoning : 'Kurs: €${u.currentPrice.toStringAsFixed(2)} | VIX: ${u.vixValue.toStringAsFixed(1)}',
|
||||
'${_fmt(f.quantity)} Stk. @ €${_fmt(f.price)}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -355,7 +276,7 @@ class TradeCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${trade.timeframe.isNotEmpty ? trade.timeframe : "1D"}${trade.leverageUsed > 1 ? " • ${trade.leverageUsed.toStringAsFixed(1)}x Hebel" : ""}',
|
||||
trade.exitPlan.strategyType.label,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
|
||||
@@ -369,7 +290,7 @@ class TradeCard extends StatelessWidget {
|
||||
onClose: onClose,
|
||||
),
|
||||
icon: Icon(Icons.info_outline, size: 18, color: AppTheme.accentCyan),
|
||||
tooltip: 'KI-Begründung & Details',
|
||||
tooltip: 'Details',
|
||||
),
|
||||
if (isProposed && onAccept != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
@@ -416,8 +337,6 @@ class TradeCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDriftRadarBar(TradeModel t) {
|
||||
@@ -426,11 +345,6 @@ class TradeCard extends StatelessWidget {
|
||||
IconData icon;
|
||||
|
||||
switch (t.driftStatus) {
|
||||
case DriftStatus.exitAlert:
|
||||
col = AppTheme.accentRed;
|
||||
label = 'Drift-Radar: Ausstieg empfohlen';
|
||||
icon = Icons.warning_rounded;
|
||||
break;
|
||||
case DriftStatus.trailingActive:
|
||||
col = AppTheme.accentCyan;
|
||||
label = 'Drift-Radar: Trailing-Stop aktiv nachgezogen';
|
||||
@@ -443,7 +357,7 @@ class TradeCard extends StatelessWidget {
|
||||
break;
|
||||
case DriftStatus.onTrack:
|
||||
col = AppTheme.primaryEmerald;
|
||||
label = 'Drift-Radar: Prognose intakt • KI überwacht stündlich';
|
||||
label = 'Drift-Radar: Prognose intakt';
|
||||
icon = Icons.radar;
|
||||
break;
|
||||
}
|
||||
@@ -467,12 +381,16 @@ class TradeCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _priceItem(String label, String val, Color valColor) {
|
||||
Widget _priceItem(String label, String val, Color valColor, {String? subtitle}) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
|
||||
const SizedBox(height: 2),
|
||||
Text(val, style: TextStyle(color: valColor, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
if (subtitle != null && subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 1),
|
||||
Text(subtitle, style: TextStyle(color: AppTheme.accentCyan, fontSize: 9, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,9 +47,7 @@ class _TradeClosingCockpitState extends State<TradeClosingCockpit> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final defaultPrice = widget.trade.currentPrice > 0
|
||||
? widget.trade.currentPrice
|
||||
: (widget.trade.actualEntryPrice > 0 ? widget.trade.actualEntryPrice : widget.trade.entryPrice);
|
||||
final defaultPrice = widget.trade.currentPrice > 0 ? widget.trade.currentPrice : widget.trade.averageBuyIn;
|
||||
|
||||
_exitPriceCtrl = TextEditingController(text: defaultPrice.toStringAsFixed(2));
|
||||
_exitFeeCtrl = TextEditingController(text: '1.00');
|
||||
@@ -72,27 +70,29 @@ class _TradeClosingCockpitState extends State<TradeClosingCockpit> {
|
||||
return double.tryParse(clean) ?? fallback;
|
||||
}
|
||||
|
||||
double get _exitPrice => _parse(_exitPriceCtrl, widget.trade.entryPrice);
|
||||
double get _exitPrice => _parse(_exitPriceCtrl, widget.trade.averageBuyIn);
|
||||
double get _exitFee => _parse(_exitFeeCtrl, 1.0);
|
||||
|
||||
double get _entryPrice => widget.trade.actualEntryPrice > 0 ? widget.trade.actualEntryPrice : (widget.trade.entryPrice > 0 ? widget.trade.entryPrice : 1.0);
|
||||
double get _posSize => widget.trade.positionSize > 0 ? widget.trade.positionSize : 1000.0;
|
||||
double get _quantity => widget.trade.quantity > 0 ? widget.trade.quantity : (_posSize / _entryPrice);
|
||||
double get _entryPrice => widget.trade.averageBuyIn;
|
||||
double get _quantity => widget.trade.totalQuantity;
|
||||
double get _posSize => _entryPrice * _quantity;
|
||||
|
||||
double get _calculatedProceeds {
|
||||
if (_exitPrice <= 0 || _quantity <= 0) return 0.0;
|
||||
return _quantity * _exitPrice;
|
||||
}
|
||||
|
||||
/// Forward preview of the realized P&L this closing input would produce —
|
||||
/// computed from real trade fields only (averageBuyIn, totalQuantity,
|
||||
/// direction) plus the fee the user is entering right now. This is NOT a
|
||||
/// re-derivation of the server's `realizedPnlEur`: the trade isn't closed
|
||||
/// yet, so the server has no such value to show (Rules.md §4 — a genuine
|
||||
/// "what happens if I close now" preview, not an invented duplicate).
|
||||
double get _calculatedPnlAbs {
|
||||
if (_entryPrice <= 0 || _exitPrice <= 0) return 0.0;
|
||||
final isShort = widget.trade.signalType.toUpperCase() == 'SELL' || widget.trade.signalType.toUpperCase() == 'SHORT';
|
||||
final movePct = isShort ? ((_entryPrice - _exitPrice) / _entryPrice) : ((_exitPrice - _entryPrice) / _entryPrice);
|
||||
final lev = (widget.trade.instrumentType.toLowerCase().contains('knock') || widget.trade.instrumentType.toLowerCase().contains('option'))
|
||||
? 1.0
|
||||
: (widget.trade.leverageUsed > 0 ? widget.trade.leverageUsed : 1.0);
|
||||
final totalFees = (widget.trade.entryFee > 0 ? widget.trade.entryFee : 1.0) + _exitFee;
|
||||
return (movePct * _posSize * lev) - totalFees;
|
||||
final isShort = !widget.trade.direction.isLong;
|
||||
final rawMove = isShort ? (_entryPrice - _exitPrice) : (_exitPrice - _entryPrice);
|
||||
return (rawMove * _quantity) - _exitFee;
|
||||
}
|
||||
|
||||
double get _calculatedPnlPct {
|
||||
@@ -188,7 +188,7 @@ class _TradeClosingCockpitState extends State<TradeClosingCockpit> {
|
||||
style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'Trade #${widget.trade.id} • ${widget.trade.companyName.isNotEmpty ? widget.trade.companyName : widget.defaultSymbol}',
|
||||
'Trade #${widget.trade.id} • ${widget.trade.symbol.isNotEmpty ? widget.trade.symbol : widget.defaultSymbol}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
@@ -224,7 +224,7 @@ class _TradeClosingCockpitState extends State<TradeClosingCockpit> {
|
||||
_summaryCol('Einstiegskurs', '€${_entryPrice.toStringAsFixed(2)}'),
|
||||
_summaryCol('Investition', '€${_posSize.toStringAsFixed(0)}'),
|
||||
_summaryCol('Stückzahl', '${_quantity.toStringAsFixed(2)} Stk.'),
|
||||
_summaryCol('Instrument', widget.trade.instrumentType),
|
||||
_summaryCol('Instrument', widget.trade.instrumentType.label),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../models/trade_model.dart';
|
||||
import 'trade_calculation_card.dart';
|
||||
|
||||
/// Migrated onto `ActiveTradeDto`. The old "KI-Analysen, Bewertungen &
|
||||
/// Begründungen" expansion (reasoning/technicalRationale/
|
||||
/// fundamentalRationale/riskWarning, plus the hourly AI-Guardian check-in
|
||||
/// timeline) has no backend equivalent anymore — none of those fields exist
|
||||
/// on `ActiveTradeDto`, so the section was removed rather than shown empty
|
||||
/// (Rules.md §4). This does leave the detail view noticeably thinner than
|
||||
/// before: today it can only show the mechanical trade state (prices, exit
|
||||
/// plan, fills), not any narrative "why" behind the trade.
|
||||
class TradeDetailContent extends StatelessWidget {
|
||||
final TradeModel trade;
|
||||
|
||||
const TradeDetailContent({super.key, required this.trade});
|
||||
|
||||
String _fmt(double val) => val.toStringAsFixed(2);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pnlAbs = trade.calculatedPnlAbs;
|
||||
final pnlPct = trade.calculatedPnlPct;
|
||||
final pnlAbs = trade.pnlEur;
|
||||
final pnlPct = trade.unrealizedPnlPercent;
|
||||
final isPnlPos = pnlAbs >= 0;
|
||||
final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
final currPrice = trade.effectiveCurrentPrice;
|
||||
final currPrice = trade.currentPrice;
|
||||
final tpStages = trade.exitPlan.takeProfitStages;
|
||||
final primaryTp = trade.primaryTakeProfit;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -25,38 +37,6 @@ class TradeDetailContent extends StatelessWidget {
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
// Exit Alert if pending
|
||||
if (trade.isActive && trade.hasPendingExitAlert) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentRed.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: AppTheme.accentRed, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Ausstiegs-Empfehlung der KI!', style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
trade.pendingExitReason.isNotEmpty ? trade.pendingExitReason : 'Die Indikatoren raten zum Verlassen der Position zur Gewinnsicherung / Risikominimierung.',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
// Metrics Grid
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -69,27 +49,25 @@ class TradeDetailContent extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_metricItem(
|
||||
trade.isActive || trade.isClosed ? 'Ausführung' : 'Ziel-Einstieg',
|
||||
trade.actualEntryPrice > 0
|
||||
? '€${trade.actualEntryPrice.toStringAsFixed(2)}'
|
||||
: (trade.entryPrice > 0 ? '€${trade.entryPrice.toStringAsFixed(2)}' : '-'),
|
||||
trade.isActive || trade.isClosed ? 'Einstiegskurs' : 'Ziel-Einstieg',
|
||||
trade.averageBuyIn > 0 ? '€${_fmt(trade.averageBuyIn)}' : '–',
|
||||
Colors.white,
|
||||
),
|
||||
_metricItem('Live-Kurs', '€${currPrice.toStringAsFixed(2)}', AppTheme.accentCyan),
|
||||
_metricItem('Live-Kurs', currPrice > 0 ? '€${_fmt(currPrice)}' : '–', AppTheme.accentCyan),
|
||||
_metricItem(
|
||||
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
|
||||
'€${trade.stopLoss.toStringAsFixed(2)}',
|
||||
'€${_fmt(trade.currentStopLoss)}',
|
||||
AppTheme.accentRed,
|
||||
),
|
||||
_metricItem(
|
||||
trade.takeProfitTargets.length > 1 ? 'TP (Aktuell)' : 'Take-Profit',
|
||||
'€${trade.takeProfit.toStringAsFixed(2)}',
|
||||
tpStages.length > 1 ? 'TP (1. Stufe)' : 'Take-Profit',
|
||||
primaryTp != null ? '€${_fmt(primaryTp)}' : 'Trailing-Exit',
|
||||
AppTheme.primaryEmerald,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trade.takeProfitTargets.length > 1) ...[
|
||||
if (tpStages.length > 1) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
@@ -101,10 +79,8 @@ class TradeDetailContent extends StatelessWidget {
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: trade.takeProfitTargets.asMap().entries.map((entry) {
|
||||
final idx = entry.key;
|
||||
final tpVal = entry.value;
|
||||
final isCurrent = (trade.takeProfit - tpVal).abs() < 0.01;
|
||||
children: tpStages.map((stage) {
|
||||
final isCurrent = primaryTp != null && (primaryTp - stage.targetPrice).abs() < 0.01;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
@@ -120,7 +96,7 @@ class TradeDetailContent extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'TP${idx + 1}: €${tpVal.toStringAsFixed(2)}',
|
||||
'TP${stage.stageNumber}: €${_fmt(stage.targetPrice)}',
|
||||
style: TextStyle(
|
||||
color: isCurrent ? AppTheme.primaryEmerald : Colors.white70,
|
||||
fontSize: 11,
|
||||
@@ -146,7 +122,7 @@ class TradeDetailContent extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Aktueller PnL:', style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
Text(trade.isClosed ? 'Realisierter PnL:' : 'Aktueller PnL:', style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
Text(
|
||||
'${isPnlPos ? '+' : ''}€${pnlAbs.abs().toStringAsFixed(2)} (${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%)',
|
||||
style: TextStyle(color: pnlColor, fontWeight: FontWeight.bold, fontSize: 15),
|
||||
@@ -157,7 +133,7 @@ class TradeDetailContent extends StatelessWidget {
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 3. LIVE-KALKULATION (AUTOMATISCH) & MEHRSTUFIGE TP-ZIELE
|
||||
// LIVE-KALKULATION (AUTOMATISCH) & MEHRSTUFIGE TP-ZIELE
|
||||
TradeCalculationCard(trade: trade),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
@@ -173,46 +149,22 @@ class TradeDetailContent extends StatelessWidget {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_paramRow('Instrument Typ:', trade.instrumentType.isNotEmpty ? trade.instrumentType : 'Stock'),
|
||||
if (trade.derivativeIsin.isNotEmpty) _paramRow('Derivat / Hebel ISIN:', trade.derivativeIsin),
|
||||
_paramRow('Zeithorizont:', trade.timeframe.isNotEmpty ? trade.timeframe : '1D'),
|
||||
if (trade.leverageUsed > 1) _paramRow('Hebel:', '${trade.leverageUsed.toStringAsFixed(1)}x'),
|
||||
if (trade.positionSize > 0) _paramRow('Positionsgröße:', '€${trade.positionSize.toStringAsFixed(2)}'),
|
||||
_paramRow('Instrument Typ:', trade.instrumentType.label),
|
||||
if (trade.derivativeIsin != null && trade.derivativeIsin!.isNotEmpty) _paramRow('Derivat ISIN:', trade.derivativeIsin!),
|
||||
_paramRow('Ausführungsart:', trade.executionMode.label),
|
||||
_paramRow('Exit-Strategie:', trade.exitPlan.strategyType.label),
|
||||
_paramRow('Eröffnet am:', _formatDate(trade.openedAtUtc)),
|
||||
if (trade.closedAtUtc != null) _paramRow('Geschlossen am:', _formatDate(trade.closedAtUtc!)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Execution history (replaces the removed AI-Guardian hourly
|
||||
// check-in timeline, which no backend DTO produces anymore — this is
|
||||
// the trade's real fill history instead).
|
||||
if (trade.fills.isNotEmpty) ...[
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// AUFKLAPPBARE KARTE: KI-Analysen, Bewertungen & Begründungen
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.03),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Theme(
|
||||
data: ThemeData(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: false,
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
||||
iconColor: AppTheme.accentCyan,
|
||||
collapsedIconColor: Colors.white70,
|
||||
leading: Icon(Icons.auto_awesome, color: AppTheme.primaryEmerald, size: 20),
|
||||
title: const Text(
|
||||
'KI-Analysen, Bewertungen & Begründungen',
|
||||
style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Technische & fundamentale Begründung, Risikowarnung & Guardian-Protokoll',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
children: [
|
||||
const Divider(color: Colors.white10, height: 16),
|
||||
|
||||
// Hourly Updates Timeline
|
||||
if (trade.hourlyUpdates.isNotEmpty) ...[
|
||||
_sectionTitle(Icons.history_toggle_off, 'KI-Guardian Überwachungsprotokoll (${trade.hourlyUpdates.length} Checks)', AppTheme.accentCyan),
|
||||
_sectionTitle(Icons.history_toggle_off, 'Ausführungshistorie (${trade.fills.length} Fills)', AppTheme.accentCyan),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -222,43 +174,22 @@ class TradeDetailContent extends StatelessWidget {
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
|
||||
),
|
||||
child: Column(
|
||||
children: trade.hourlyUpdates.reversed.map((u) {
|
||||
children: trade.fills.reversed.map((f) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${u.timestamp.day.toString().padLeft(2, '0')}.${u.timestamp.month.toString().padLeft(2, '0')} ${u.timestamp.hour.toString().padLeft(2, '0')}:${u.timestamp.minute.toString().padLeft(2, '0')}',
|
||||
_formatDate(f.executedAtUtc),
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (u.recommendation.toLowerCase().contains('close')
|
||||
? AppTheme.accentRed
|
||||
: (u.recommendation.toLowerCase().contains('adjust') ? Colors.blue : AppTheme.primaryEmerald))
|
||||
.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(u.recommendation, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
u.reasoning.isNotEmpty ? u.reasoning : 'Stündliche Überprüfung durchgeführt.',
|
||||
child: Text(
|
||||
'${_fmt(f.quantity)} Stk. @ €${_fmt(f.price)}${f.fee > 0 ? ' • Gebühr €${_fmt(f.fee)}' : ''}${f.note != null && f.note!.isNotEmpty ? ' • ${f.note}' : ''}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
'Kurs: €${u.currentPrice.toStringAsFixed(2)}${u.suggestedStopLoss != null ? " • Neuer SL: €${u.suggestedStopLoss!.toStringAsFixed(2)}" : ""} • VIX: ${u.vixValue.toStringAsFixed(1)}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -266,104 +197,34 @@ class TradeDetailContent extends StatelessWidget {
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
if (trade.reasoning.isNotEmpty) ...[
|
||||
_sectionTitle(Icons.auto_awesome, 'KI-Gesamteinschätzung & Begründung', AppTheme.primaryEmerald),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Text(trade.reasoning, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.4)),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
if (trade.technicalRationale.isNotEmpty) ...[
|
||||
_sectionTitle(Icons.show_chart, 'Technische Analyse & Indikatoren', AppTheme.accentCyan),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.03),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Text(trade.technicalRationale, style: TextStyle(color: AppTheme.textMuted, fontSize: 12.5, height: 1.4)),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
if (trade.fundamentalRationale.isNotEmpty) ...[
|
||||
_sectionTitle(Icons.account_balance, 'Fundamentale Bewertung', Colors.purpleAccent),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.03),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Text(trade.fundamentalRationale, style: TextStyle(color: AppTheme.textMuted, fontSize: 12.5, height: 1.4)),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
if (trade.riskWarning.isNotEmpty) ...[
|
||||
_sectionTitle(Icons.warning_amber_rounded, 'Risikohinweis & Marktumfeld', AppTheme.accentRed),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentRed.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Text(trade.riskWarning, style: TextStyle(color: AppTheme.accentRed, fontSize: 12, height: 1.4)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime d) =>
|
||||
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
Widget _buildDriftRadarCard(TradeModel t) {
|
||||
Color col;
|
||||
String title;
|
||||
String desc;
|
||||
|
||||
switch (t.driftStatus) {
|
||||
case DriftStatus.exitAlert:
|
||||
col = AppTheme.accentRed;
|
||||
title = 'Ausstiegssignal aktiv';
|
||||
desc = 'Die Marktbedingungen oder Stop-Limits deuten auf einen Ausstieg hin.';
|
||||
break;
|
||||
case DriftStatus.trailingActive:
|
||||
col = AppTheme.accentCyan;
|
||||
title = 'Trailing Stop aktiv nachgezogen';
|
||||
desc = 'Die KI hat den Stop-Loss zur Absicherung von Gewinnen nachgezogen.';
|
||||
desc = 'Der aktuelle Stop-Loss wurde zur Absicherung von Gewinnen nachgezogen.';
|
||||
break;
|
||||
case DriftStatus.driftWarning:
|
||||
col = Colors.orangeAccent;
|
||||
title = 'Leichte Drift / Kursabweichung';
|
||||
desc = 'Der Kurs bewegt sich leicht entgegen der primären Prognose.';
|
||||
desc = 'Der unrealisierte Verlust hat die Warnschwelle überschritten.';
|
||||
break;
|
||||
case DriftStatus.onTrack:
|
||||
col = AppTheme.primaryEmerald;
|
||||
title = 'Auf Kurs • Prognose intakt';
|
||||
desc = 'Die Entwicklung entspricht der statistischen KI-Prognose.';
|
||||
title = 'Auf Kurs';
|
||||
desc = 'Keine besonderen Abweichungen erkannt.';
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -425,4 +286,3 @@ class TradeDetailContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class TradeDetailModal extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isBuy = trade.signalType == 'BUY';
|
||||
final isBuy = trade.direction.isLong;
|
||||
final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
|
||||
return Container(
|
||||
@@ -78,7 +78,7 @@ class TradeDetailModal extends StatelessWidget {
|
||||
border: Border.all(color: signalColor.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
trade.signalType,
|
||||
trade.direction.label,
|
||||
style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
),
|
||||
@@ -88,19 +88,11 @@ class TradeDetailModal extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
trade.symbol.isNotEmpty && trade.symbol != 'UNKNOWN'
|
||||
? trade.symbol
|
||||
: (trade.companyName.isNotEmpty && trade.companyName != 'UNKNOWN' ? trade.companyName : (trade.isin.isNotEmpty ? trade.isin : 'Aktie')),
|
||||
trade.symbol.isNotEmpty ? trade.symbol : trade.underlyingIsin,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.white),
|
||||
),
|
||||
if (trade.companyName.isNotEmpty && trade.companyName != trade.symbol && trade.companyName != 'UNKNOWN')
|
||||
Text(
|
||||
trade.companyName,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
|
||||
),
|
||||
if (trade.isin.isNotEmpty || trade.sector.isNotEmpty)
|
||||
Text(
|
||||
'${trade.isin.isNotEmpty ? trade.isin : ""}${trade.isin.isNotEmpty && trade.sector.isNotEmpty ? " • " : ""}${trade.sector.isNotEmpty ? trade.sector : ""}',
|
||||
trade.underlyingIsin,
|
||||
style: TextStyle(color: AppTheme.textMuted.withValues(alpha: 0.7), fontSize: 11),
|
||||
),
|
||||
],
|
||||
@@ -118,7 +110,7 @@ class TradeDetailModal extends StatelessWidget {
|
||||
const Icon(Icons.star, size: 14, color: Colors.amber),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${trade.winRate.toStringAsFixed(0)}% Win-Rate',
|
||||
trade.status.label,
|
||||
style: const TextStyle(color: Colors.amber, fontWeight: FontWeight.bold, fontSize: 11),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -28,39 +28,17 @@ class TradeExecutionAiPlanCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildRationaleBlock(String title, String content, Color color) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Text(content, style: const TextStyle(color: Colors.white70, fontSize: 12, height: 1.4)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final signal = trade.signalType.toUpperCase();
|
||||
final isLong = signal == 'BUY' || signal == 'LONG';
|
||||
final isLong = trade.direction.isLong;
|
||||
final signalColor = isLong ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
|
||||
final entryZoneMin = trade.entryZoneMin;
|
||||
final entryZoneMax = trade.entryZoneMax;
|
||||
final entryPrice = trade.entryPrice;
|
||||
final stopLoss = trade.stopLoss;
|
||||
final takeProfit = trade.takeProfit;
|
||||
final takeProfitTargets = trade.takeProfitTargets;
|
||||
final crv = (takeProfit - entryPrice) / (entryPrice - stopLoss).abs();
|
||||
final maxLeverage = trade.maxLeverage;
|
||||
|
||||
final reasoning = trade.reasoning;
|
||||
final techRationale = trade.technicalRationale;
|
||||
final fundRationale = trade.fundamentalRationale;
|
||||
final riskWarning = trade.riskWarning;
|
||||
final entryPrice = trade.averageBuyIn;
|
||||
final stopLoss = trade.currentStopLoss;
|
||||
final takeProfitStages = trade.exitPlan.takeProfitStages;
|
||||
final primaryTp = trade.primaryTakeProfit;
|
||||
final riskDistance = (entryPrice - stopLoss).abs();
|
||||
final crv = (primaryTp != null && riskDistance > 0) ? (primaryTp - entryPrice).abs() / riskDistance : null;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -79,79 +57,47 @@ class TradeExecutionAiPlanCard extends StatelessWidget {
|
||||
children: [
|
||||
StatusBadge(label: isLong ? 'LONG / KAUFEN' : 'SHORT / VERKAUFEN', color: signalColor),
|
||||
const SizedBox(width: 8),
|
||||
if (trade.instrumentType.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(trade.instrumentType, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold)),
|
||||
child: Text(trade.instrumentType.label, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (trade.winRate > 0)
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.bolt, size: 14, color: AppTheme.accentCyan),
|
||||
Text('Win-Rate: ${trade.winRate}%', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
Text(trade.status.label, style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Haltedauer: ${trade.timeframe.isNotEmpty ? trade.timeframe : '1-14 Tage'}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
Text('Risiko: ${trade.riskTolerance}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
if (trade.vixValue > 0)
|
||||
Text('VIX: ${_fmt(trade.vixValue)} (${trade.vixRegime})', style: const TextStyle(color: Colors.orangeAccent, fontSize: 11, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
Text('Exit-Strategie: ${trade.exitPlan.strategyType.label}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
const Divider(color: Colors.white12, height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '€${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '€${_fmt(entryPrice)}', Colors.white),
|
||||
_buildTradeStat('Stop-Loss Target', '€${_fmt(stopLoss)}', AppTheme.accentRed),
|
||||
_buildTradeStat('Take-Profit Target', takeProfitTargets.isNotEmpty ? takeProfitTargets.map((t) => '€${_fmt(t)}').join(' / ') : '€${_fmt(takeProfit)}', AppTheme.primaryEmerald),
|
||||
_buildTradeStat('Einstieg', '€${_fmt(entryPrice)}', Colors.white),
|
||||
_buildTradeStat('Stop-Loss', '€${_fmt(stopLoss)}', AppTheme.accentRed),
|
||||
_buildTradeStat(
|
||||
takeProfitStages.length > 1 ? 'Take-Profit Stufen' : 'Take-Profit',
|
||||
takeProfitStages.isNotEmpty ? takeProfitStages.map((s) => '€${_fmt(s.targetPrice)}').join(' / ') : 'Trailing-Exit (kein Fixziel)',
|
||||
AppTheme.primaryEmerald,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (crv > 0) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan),
|
||||
if (maxLeverage > 0) _buildTradeStat('Empf. Max Hebel', '${_fmt(maxLeverage)}x', Colors.orangeAccent),
|
||||
if (crv != null) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan),
|
||||
_buildTradeStat('Signal Typ', isLong ? 'LONG / BULLISH' : 'SHORT / BEARISH', signalColor),
|
||||
],
|
||||
),
|
||||
if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
childrenPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text('Ausführliche KI-Analyse & Begründung anzeigen', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
children: [
|
||||
if (reasoning.isNotEmpty) ...[
|
||||
_buildRationaleBlock('Gesamt-Strategie & KI-Entscheidung', reasoning, Colors.white70),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
if (techRationale.isNotEmpty) ...[
|
||||
_buildRationaleBlock('Technische Analyse', techRationale, Colors.white70),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
if (fundRationale.isNotEmpty) ...[
|
||||
_buildRationaleBlock('Fundamentale Analyse', fundRationale, Colors.white70),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
if (riskWarning.isNotEmpty)
|
||||
_buildRationaleBlock('Risikowarnung', riskWarning, AppTheme.accentRed),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,429 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../asset_detail/repositories/asset_repository.dart';
|
||||
import '../models/trade_model.dart';
|
||||
import '../models/trade_acceptance_dto.dart';
|
||||
import 'trade_execution_ai_plan_card.dart';
|
||||
|
||||
class TradeExecutionDialog {
|
||||
static const double _defaultPositionSize = 1000.0;
|
||||
static const double _defaultLeverage = 1.0;
|
||||
|
||||
static String _normalizeInstrumentType(String raw) {
|
||||
final clean = raw.toLowerCase().trim();
|
||||
if (clean.contains('knock') || clean.contains('zertifikat') || clean.contains('turbo')) {
|
||||
return 'KnockOut';
|
||||
}
|
||||
if (clean.contains('option')) {
|
||||
return 'Option';
|
||||
}
|
||||
if (clean.contains('cfd')) {
|
||||
return 'CFD';
|
||||
}
|
||||
if (clean.contains('crypto') || clean.contains('krypto')) {
|
||||
return 'Crypto';
|
||||
}
|
||||
if (clean.contains('stock') || clean.contains('aktie') || clean.contains('etf')) {
|
||||
return 'Stock';
|
||||
}
|
||||
return 'KnockOut';
|
||||
}
|
||||
|
||||
static void show(
|
||||
BuildContext context, {
|
||||
required TradeModel trade,
|
||||
required String defaultSymbol,
|
||||
bool isActive = false,
|
||||
required Function(TradeAcceptanceDto dto) onAccept,
|
||||
Function(String tradeId)? onReject,
|
||||
}) {
|
||||
final initEntry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : (trade.entryPrice > 0 ? trade.entryPrice : 100.0);
|
||||
final initPos = trade.positionSize > 0 ? trade.positionSize : _defaultPositionSize;
|
||||
final initLev = trade.leverageUsed > 0 ? trade.leverageUsed : _defaultLeverage;
|
||||
final calcQty = (initEntry > 0 && initPos > 0) ? (initPos / initEntry) : 10.0;
|
||||
final initQty = trade.quantity > 0 ? trade.quantity : calcQty;
|
||||
|
||||
final actualEntryController = TextEditingController(text: initEntry.toStringAsFixed(2));
|
||||
final positionSizeController = TextEditingController(text: initPos.toStringAsFixed(2));
|
||||
final leverageController = TextEditingController(text: initLev.toStringAsFixed(1));
|
||||
final quantityController = TextEditingController(text: initQty.toStringAsFixed(4));
|
||||
|
||||
final entryFeeController = TextEditingController(text: trade.entryFee.toStringAsFixed(2));
|
||||
final exitFeeController = TextEditingController(text: trade.exitFee.toStringAsFixed(2));
|
||||
|
||||
final slController = TextEditingController(text: trade.stopLoss.toString());
|
||||
final tpController = TextEditingController(text: trade.takeProfit.toString());
|
||||
|
||||
final derivativeIsinController = TextEditingController(text: trade.derivativeIsin);
|
||||
|
||||
String selectedInstrumentType = _normalizeInstrumentType(
|
||||
trade.instrumentType.isNotEmpty ? trade.instrumentType : 'KnockOut',
|
||||
);
|
||||
|
||||
bool isFetchingDerivativePrice = false;
|
||||
|
||||
void recalculateQuantity() {
|
||||
final entryStr = actualEntryController.text.replaceAll(',', '.').trim();
|
||||
final posStr = positionSizeController.text.replaceAll(',', '.').trim();
|
||||
|
||||
final entry = double.tryParse(entryStr) ?? 0.0;
|
||||
final posSize = double.tryParse(posStr) ?? 0.0;
|
||||
if (entry > 0 && posSize > 0) {
|
||||
final q = posSize / entry;
|
||||
quantityController.text = q.toStringAsFixed(4);
|
||||
}
|
||||
}
|
||||
|
||||
TradeAcceptanceDto buildDto() {
|
||||
final isinVal = trade.isin.isNotEmpty ? trade.isin : (trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol);
|
||||
final symbolVal = trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol;
|
||||
|
||||
double parseNum(String text, double fallback) {
|
||||
final clean = text.replaceAll(',', '.').trim();
|
||||
return double.tryParse(clean) ?? fallback;
|
||||
}
|
||||
|
||||
return TradeAcceptanceDto(
|
||||
userId: trade.userId,
|
||||
tradeId: trade.id,
|
||||
analysisId: trade.analysisId,
|
||||
isin: isinVal,
|
||||
symbol: symbolVal,
|
||||
actualEntryPrice: parseNum(actualEntryController.text, trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice),
|
||||
positionSize: parseNum(positionSizeController.text, trade.positionSize > 0 ? trade.positionSize : 1000.0),
|
||||
leverageUsed: parseNum(leverageController.text, trade.leverageUsed > 0 ? trade.leverageUsed : 1.0),
|
||||
entryFee: parseNum(entryFeeController.text, trade.entryFee),
|
||||
exitFee: parseNum(exitFeeController.text, trade.exitFee),
|
||||
quantity: parseNum(quantityController.text, trade.quantity),
|
||||
executionTimestamp: DateTime.now().toUtc(),
|
||||
signalType: trade.signalType,
|
||||
entryPrice: trade.entryPrice,
|
||||
stopLoss: parseNum(slController.text, trade.stopLoss),
|
||||
takeProfit: parseNum(tpController.text, trade.takeProfit),
|
||||
instrumentType: selectedInstrumentType,
|
||||
derivativeIsin: derivativeIsinController.text.trim(),
|
||||
timeframe: trade.timeframe,
|
||||
reasoning: trade.reasoning,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> fetchDerivativePrice(StateSetter setModalState, String inputIsin) async {
|
||||
final cleanIsin = inputIsin.trim().toUpperCase();
|
||||
if (cleanIsin.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Bitte gib eine gültige Derivat/Knock-Out ISIN ein.'), backgroundColor: Colors.amber, behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setModalState(() => isFetchingDerivativePrice = true);
|
||||
try {
|
||||
final apiClient = context.read<ApiClient>();
|
||||
final assetRepo = AssetRepository(apiClient: apiClient);
|
||||
final technicals = await assetRepo.getAssetTechnical(cleanIsin, true);
|
||||
|
||||
double? fetchedPrice;
|
||||
if (technicals != null) {
|
||||
if (technicals.candles.isNotEmpty) {
|
||||
fetchedPrice = technicals.candles.last.close;
|
||||
} else if (technicals.currentPrice != null && technicals.currentPrice! > 0) {
|
||||
fetchedPrice = technicals.currentPrice;
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchedPrice != null && fetchedPrice > 0) {
|
||||
actualEntryController.text = fetchedPrice.toStringAsFixed(2);
|
||||
recalculateQuantity();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Live-Kurs für Derivat $cleanIsin abgerufen: €${fetchedPrice.toStringAsFixed(2)}'),
|
||||
backgroundColor: AppTheme.primaryEmerald,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Kein Kurs für Derivat ISIN $cleanIsin gefunden.'), backgroundColor: Colors.amber, behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Fehler beim Abrufen des Kurses für $cleanIsin: $e'), backgroundColor: AppTheme.accentRed, behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
} finally {
|
||||
setModalState(() => isFetchingDerivativePrice = false);
|
||||
}
|
||||
}
|
||||
|
||||
actualEntryController.addListener(recalculateQuantity);
|
||||
positionSizeController.addListener(recalculateQuantity);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (stfContext, setModalState) {
|
||||
final isKnockout = selectedInstrumentType.toLowerCase().contains('knock') ||
|
||||
selectedInstrumentType.toLowerCase().contains('option') ||
|
||||
selectedInstrumentType.toLowerCase().contains('factor') ||
|
||||
selectedInstrumentType.toLowerCase().contains('derivat');
|
||||
|
||||
final assetType = trade.assetType.toLowerCase();
|
||||
final categories = trade.derivativeProductCategories;
|
||||
final hasCfd = trade.hasCfd;
|
||||
|
||||
final availableOptions = <MapEntry<String, String>>[];
|
||||
if (assetType == 'crypto') {
|
||||
availableOptions.add(const MapEntry('Crypto', 'Krypto'));
|
||||
if (hasCfd) availableOptions.add(const MapEntry('CFD', 'Krypto CFD'));
|
||||
} else if (assetType == 'etf') {
|
||||
availableOptions.add(const MapEntry('Stock', 'ETF (Direktinvestment)'));
|
||||
if (categories.isEmpty || categories.contains('knockOutProduct')) {
|
||||
availableOptions.add(const MapEntry('KnockOut', 'Knock-Out Zertifikat'));
|
||||
}
|
||||
if (categories.contains('vanillaWarrant')) {
|
||||
availableOptions.add(const MapEntry('Option', 'Optionsschein'));
|
||||
}
|
||||
if (categories.contains('factorCertificate')) {
|
||||
availableOptions.add(const MapEntry('Factor', 'Faktor-Zertifikat'));
|
||||
}
|
||||
if (hasCfd) availableOptions.add(const MapEntry('CFD', 'CFD (Hebel-Derivat)'));
|
||||
} else {
|
||||
availableOptions.add(const MapEntry('Stock', 'Aktie (Direktinvestment)'));
|
||||
if (categories.isEmpty || categories.contains('knockOutProduct')) {
|
||||
availableOptions.add(const MapEntry('KnockOut', 'Knock-Out Zertifikat'));
|
||||
}
|
||||
if (categories.contains('vanillaWarrant')) {
|
||||
availableOptions.add(const MapEntry('Option', 'Optionsschein'));
|
||||
}
|
||||
if (categories.contains('factorCertificate')) {
|
||||
availableOptions.add(const MapEntry('Factor', 'Faktor-Zertifikat'));
|
||||
}
|
||||
if (hasCfd) availableOptions.add(const MapEntry('CFD', 'CFD (Hebel-Derivat)'));
|
||||
}
|
||||
|
||||
final safeInstrumentValue = availableOptions.any((o) => o.key == selectedInstrumentType)
|
||||
? selectedInstrumentType
|
||||
: (availableOptions.isNotEmpty ? availableOptions.first.key : 'Stock');
|
||||
|
||||
return AlertDialog(
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: AppTheme.glassBorder)),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.flash_on, color: AppTheme.primaryEmerald),
|
||||
const SizedBox(width: 8),
|
||||
Text(isActive ? 'Aktiven Trade anpassen' : 'Trade-Vorschlag ausführen', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 580,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Trade-ID: ${trade.id} | Symbol/ISIN: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
|
||||
const SizedBox(height: 12),
|
||||
TradeExecutionAiPlanCard(trade: trade),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ihre Ausführungsdaten für das Depot:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
const SizedBox(height: 10),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: safeInstrumentValue,
|
||||
dropdownColor: AppTheme.cardSurface,
|
||||
decoration: const InputDecoration(labelText: 'Finanzinstrument Typ', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||
items: availableOptions.map((opt) {
|
||||
return DropdownMenuItem(value: opt.key, child: Text(opt.value, style: const TextStyle(color: Colors.white, fontSize: 13)));
|
||||
}).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) setModalState(() => selectedInstrumentType = val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (isKnockout) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: derivativeIsinController,
|
||||
decoration: const InputDecoration(labelText: 'Knock-Out / Derivat ISIN (z.B. DE000...)', hintText: 'ISIN des Hebels eingeben...', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: isFetchingDerivativePrice ? null : () => fetchDerivativePrice(setModalState, derivativeIsinController.text),
|
||||
icon: isFetchingDerivativePrice
|
||||
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
|
||||
: const Icon(Icons.bolt, size: 16),
|
||||
label: const Text('tr_GetPrice'),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentCyan, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: actualEntryController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Tatsächlicher Einstiegskurs (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: positionSizeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Investitionsvolumen (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: leverageController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Genutzter Hebel (x)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: quantityController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Stückzahl (Invest. / Einstieg)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: entryFeeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Einstiegsgebühr (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: exitFeeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Ausstiegsgebühr (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: slController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Stop-Loss (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: tpController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Take-Profit (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (trade.takeProfitTargets.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: trade.takeProfitTargets.asMap().entries.map((entry) {
|
||||
final idx = entry.key;
|
||||
final targetPrice = entry.value;
|
||||
return ActionChip(
|
||||
label: Text('TP${idx + 1}: €${targetPrice.toStringAsFixed(2)}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold)),
|
||||
onPressed: () {
|
||||
tpController.text = targetPrice.toStringAsFixed(2);
|
||||
},
|
||||
backgroundColor: Colors.white10,
|
||||
side: BorderSide(color: AppTheme.primaryEmerald.withValues(alpha: 0.4)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.amber.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.gavel_outlined, size: 14, color: Colors.amber.withValues(alpha: 0.85)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Rechtlicher Hinweis: Keine Anlageberatung. Sämtliche Angaben dienen ausschließlich Informationszwecken. Hebelprodukte bergen ein hohes Verlustrisiko bis hin zum Totalverlust.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 10, height: 1.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
|
||||
),
|
||||
if (!isActive && onReject != null)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
onReject(trade.id);
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
icon: Icon(Icons.cancel, color: AppTheme.accentRed, size: 16),
|
||||
label: Text('Trade Ablehnen', style: TextStyle(color: AppTheme.accentRed)),
|
||||
style: OutlinedButton.styleFrom(side: BorderSide(color: AppTheme.accentRed)),
|
||||
),
|
||||
if (!isActive && onReject != null) const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
final dto = buildDto();
|
||||
onAccept(dto);
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
icon: Icon(isActive ? Icons.save : Icons.check_circle, size: 16),
|
||||
label: Text(isActive ? 'Einstellungen Speichern' : 'Trade Annehmen & Ausführen'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isActive ? AppTheme.accentCyan : AppTheme.primaryEmerald,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,11 @@ class TradePerformanceBar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalOpenPnlAbs = activeTrades.fold<double>(0, (sum, t) => sum + t.calculatedPnlAbs);
|
||||
final totalOpenPnlAbs = activeTrades.fold<double>(0, (sum, t) => sum + t.pnlEur);
|
||||
final isPnlPos = totalOpenPnlAbs >= 0;
|
||||
final winRatePct = allTrades.isNotEmpty
|
||||
? (allTrades.where((t) => t.pnlAbsolute >= 0).length / allTrades.length * 100)
|
||||
final closedTrades = allTrades.where((t) => t.isClosed).toList();
|
||||
final winRatePct = closedTrades.isNotEmpty
|
||||
? (closedTrades.where((t) => t.pnlEur >= 0).length / closedTrades.length * 100)
|
||||
: 0.0;
|
||||
|
||||
return GlassContainer(
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
|
||||
/// Reusable bottom-sheet body showing the full score breakdown, earnings-lockout
|
||||
/// (and, where available, simulation-veto) status and AI reasoning for a single
|
||||
/// asset evaluation.
|
||||
///
|
||||
/// Used by both `TradesTab._showEvaluationRejectedSheet` (manual "Analyze now"
|
||||
/// result, backed by `AssetEvaluationResultModel`) and the admin
|
||||
/// evaluation-history detail sheet (backed by `EvaluationHistoryEntryModel`).
|
||||
/// The two source models diverge in exactly the fields this widget doesn't
|
||||
/// need (proposal payload vs. outcome/trigger enums), so what is shared here
|
||||
/// is the *display logic* via plain primitives, not the models themselves —
|
||||
/// that is what actually avoids a second, drifting copy of this sheet's UI.
|
||||
class EvaluationScoreBreakdownSheet extends StatelessWidget {
|
||||
final ScrollController scrollController;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData headerIcon;
|
||||
final Color headerColor;
|
||||
final double compositeScore;
|
||||
final double technicalScore;
|
||||
final double sentimentScore;
|
||||
final double fundamentalScore;
|
||||
|
||||
/// Simulation-matrix bonus points (0..~15). `null` omits the tile entirely —
|
||||
/// the manual-analysis result (`AssetEvaluationResultModel`) never carries this.
|
||||
final double? reliabilityBonus;
|
||||
|
||||
final bool passedEarningsLockout;
|
||||
final int? daysToNextEarnings;
|
||||
|
||||
/// Whether the ex-dividend gate passed (see `Engine.DividendGateDays`). Defaults to `true` so call sites
|
||||
/// that predate this gate (or a "no evaluation reached" early-return case) read as "not gated".
|
||||
final bool passedDividendGate;
|
||||
final int? daysToNextExDividend;
|
||||
|
||||
/// Human-readable label for why this ISIN was in FinlyticTechnicals' scan universe in the first place (e.g.
|
||||
/// "Nutzer-Favorit", "Sentiment-Spike"). `null` omits the row entirely - either the source model doesn't
|
||||
/// carry this (manual-analysis result), or the evaluation happened outside the scan universe.
|
||||
final String? universeSourceLabel;
|
||||
|
||||
/// When the ISIN entered that universe, shown alongside [universeSourceLabel]. Ignored if that is `null`.
|
||||
final DateTime? universeEnteredAtUtc;
|
||||
|
||||
/// `null` omits the row entirely (manual-analysis result doesn't carry this gate).
|
||||
final bool? passedSimulationVeto;
|
||||
|
||||
final String reasoningLabel;
|
||||
final String reasoningText;
|
||||
final List<String> identifiedRisks;
|
||||
|
||||
/// Optional extra content appended at the end (e.g. "→ Vorschlag XYZ entstand"
|
||||
/// linkage row shown by the admin history sheet when `proposalId` is set).
|
||||
final Widget? footer;
|
||||
|
||||
const EvaluationScoreBreakdownSheet({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.headerIcon,
|
||||
required this.headerColor,
|
||||
required this.compositeScore,
|
||||
required this.technicalScore,
|
||||
required this.sentimentScore,
|
||||
required this.fundamentalScore,
|
||||
this.reliabilityBonus,
|
||||
required this.passedEarningsLockout,
|
||||
this.daysToNextEarnings,
|
||||
this.passedDividendGate = true,
|
||||
this.daysToNextExDividend,
|
||||
this.universeSourceLabel,
|
||||
this.universeEnteredAtUtc,
|
||||
this.passedSimulationVeto,
|
||||
required this.reasoningLabel,
|
||||
required this.reasoningText,
|
||||
this.identifiedRisks = const [],
|
||||
this.footer,
|
||||
});
|
||||
|
||||
/// Shows this sheet inside the shared `DraggableScrollableSheet` chrome
|
||||
/// (rounded top corners, drag sizing) so both call sites get identical framing.
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required IconData headerIcon,
|
||||
required Color headerColor,
|
||||
required double compositeScore,
|
||||
required double technicalScore,
|
||||
required double sentimentScore,
|
||||
required double fundamentalScore,
|
||||
double? reliabilityBonus,
|
||||
required bool passedEarningsLockout,
|
||||
int? daysToNextEarnings,
|
||||
bool passedDividendGate = true,
|
||||
int? daysToNextExDividend,
|
||||
String? universeSourceLabel,
|
||||
DateTime? universeEnteredAtUtc,
|
||||
bool? passedSimulationVeto,
|
||||
required String reasoningLabel,
|
||||
required String reasoningText,
|
||||
List<String> identifiedRisks = const [],
|
||||
Widget? footer,
|
||||
}) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.35,
|
||||
maxChildSize: 0.9,
|
||||
expand: false,
|
||||
builder: (_, scrollController) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
child: EvaluationScoreBreakdownSheet(
|
||||
scrollController: scrollController,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
headerIcon: headerIcon,
|
||||
headerColor: headerColor,
|
||||
compositeScore: compositeScore,
|
||||
technicalScore: technicalScore,
|
||||
sentimentScore: sentimentScore,
|
||||
fundamentalScore: fundamentalScore,
|
||||
reliabilityBonus: reliabilityBonus,
|
||||
passedEarningsLockout: passedEarningsLockout,
|
||||
daysToNextEarnings: daysToNextEarnings,
|
||||
passedDividendGate: passedDividendGate,
|
||||
daysToNextExDividend: daysToNextExDividend,
|
||||
universeSourceLabel: universeSourceLabel,
|
||||
universeEnteredAtUtc: universeEnteredAtUtc,
|
||||
passedSimulationVeto: passedSimulationVeto,
|
||||
reasoningLabel: reasoningLabel,
|
||||
reasoningText: reasoningText,
|
||||
identifiedRisks: identifiedRisks,
|
||||
footer: footer,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(headerIcon, color: headerColor, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close, color: Colors.white54),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12, height: 1.4),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('SCORE-AUFSCHLÜSSELUNG', style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
_scoreTile('Composite', compositeScore),
|
||||
const SizedBox(width: 8),
|
||||
_scoreTile('Technisch', technicalScore),
|
||||
const SizedBox(width: 8),
|
||||
_scoreTile('Sentiment', sentimentScore),
|
||||
const SizedBox(width: 8),
|
||||
_scoreTile('Fundamental', fundamentalScore),
|
||||
],
|
||||
),
|
||||
if (reliabilityBonus != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(width: 110, child: _bonusTile('Simulation-Bonus', reliabilityBonus!)),
|
||||
),
|
||||
],
|
||||
if (universeSourceLabel != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.radar_outlined, size: 16, color: AppTheme.accentCyan),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
universeEnteredAtUtc != null
|
||||
? 'In der Dauerbeobachtung seit ${_formatRelativeAge(universeEnteredAtUtc!)} als "$universeSourceLabel".'
|
||||
: 'In der Dauerbeobachtung als "$universeSourceLabel".',
|
||||
style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, height: 1.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (daysToNextEarnings != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
passedEarningsLockout ? Icons.event_available_outlined : Icons.event_busy_outlined,
|
||||
size: 16,
|
||||
color: passedEarningsLockout ? AppTheme.textMuted : Colors.amber,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
passedEarningsLockout
|
||||
? 'Nächste Quartalszahlen in $daysToNextEarnings Tagen – keine Earnings-Sperre.'
|
||||
: 'Earnings-Sperre aktiv: Quartalszahlen in nur $daysToNextEarnings Tagen.',
|
||||
style: TextStyle(
|
||||
color: passedEarningsLockout ? AppTheme.textMuted : Colors.amber,
|
||||
fontSize: 12,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (daysToNextExDividend != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
passedDividendGate ? Icons.event_available_outlined : Icons.event_busy_outlined,
|
||||
size: 16,
|
||||
color: passedDividendGate ? AppTheme.textMuted : Colors.amber,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
passedDividendGate
|
||||
? 'Nächster Ex-Dividenden-Tag in $daysToNextExDividend Tag(en) – keine Dividend-Sperre.'
|
||||
: 'Dividend-Sperre aktiv: Ex-Dividenden-Tag in nur $daysToNextExDividend Tag(en).',
|
||||
style: TextStyle(
|
||||
color: passedDividendGate ? AppTheme.textMuted : Colors.amber,
|
||||
fontSize: 12,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (passedSimulationVeto != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
passedSimulationVeto! ? Icons.verified_outlined : Icons.gpp_bad_outlined,
|
||||
size: 16,
|
||||
color: passedSimulationVeto! ? AppTheme.textMuted : AppTheme.accentRed,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
passedSimulationVeto!
|
||||
? 'Backtest-Reliability-Matrix hat kein Veto ausgesprochen.'
|
||||
: 'Backtest-Reliability-Matrix hat diese Strategie/Asset-Kombination mit einem Veto belegt.',
|
||||
style: TextStyle(
|
||||
color: passedSimulationVeto! ? AppTheme.textMuted : AppTheme.accentRed,
|
||||
fontSize: 12,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.psychology, size: 16, color: Colors.purpleAccent),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
reasoningLabel,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
reasoningText.trim().isNotEmpty ? reasoningText : 'Keine weitere Begründung hinterlegt.',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4),
|
||||
),
|
||||
if (identifiedRisks.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Text('Identifizierte Risiken:', style: TextStyle(color: Colors.amber, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 6),
|
||||
...identifiedRisks.map((r) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.warning_amber, size: 12, color: Colors.amber),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: Text(r, style: TextStyle(color: AppTheme.textMuted, fontSize: 12, height: 1.3))),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
if (footer != null) ...[
|
||||
const SizedBox(height: 18),
|
||||
footer!,
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Coarse "vor X Minuten/Stunden/Tagen" age string, relative to now. Deliberately coarse (no seconds) since
|
||||
/// this is only ever used for a "how long has this been on the watchlist" hint, not a precise timestamp.
|
||||
static String _formatRelativeAge(DateTime utc) {
|
||||
final diff = DateTime.now().toUtc().difference(utc.toUtc());
|
||||
if (diff.inMinutes < 1) return 'wenigen Sekunden';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes} Minute(n)';
|
||||
if (diff.inHours < 24) return '${diff.inHours} Stunde(n)';
|
||||
return '${diff.inDays} Tag(en)';
|
||||
}
|
||||
|
||||
Widget _scoreTile(String label, double score) {
|
||||
final color = score >= 70 ? AppTheme.primaryEmerald : (score >= 40 ? Colors.amber : AppTheme.accentRed);
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(score.toStringAsFixed(0), style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 9), textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bonusTile(String label, double bonus) {
|
||||
final color = bonus > 0 ? AppTheme.primaryEmerald : AppTheme.textMuted;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text('+${bonus.toStringAsFixed(0)}', style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 9), textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import '../../core/network/api_client.dart';
|
||||
import '../../core/network/signalr_service.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/theme/theme_cubit.dart';
|
||||
import '../../features/admin/views/admin_evaluation_history_screen.dart';
|
||||
import '../../features/admin/views/admin_users_screen.dart';
|
||||
import '../../features/auth/models/user_model.dart';
|
||||
import '../../features/bot/views/bot_control_panel_screen.dart';
|
||||
import '../../features/calendar/views/corporate_calendar_screen.dart';
|
||||
import '../../features/dashboard/views/dashboard_screen.dart';
|
||||
import '../../features/discovery/cubit/discovery_cubit.dart';
|
||||
@@ -13,6 +15,7 @@ import '../../features/favorites/cubit/favorites_cubit.dart';
|
||||
|
||||
import '../../features/favorites/views/favorites_screen.dart';
|
||||
import '../../features/news/views/news_feed_screen.dart';
|
||||
import '../../features/simulation/views/backtest_visualizer_screen.dart';
|
||||
import '../../features/trades/views/trades_feed_screen.dart';
|
||||
import 'global_app_bar.dart';
|
||||
|
||||
@@ -56,7 +59,10 @@ class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
|
||||
'Favoriten',
|
||||
'Kalender',
|
||||
'Live Trades',
|
||||
if (widget.user.isAdmin) 'Bot Panel',
|
||||
if (widget.user.isAdmin) 'Backtest',
|
||||
if (widget.user.isAdmin) 'Admin Panel',
|
||||
if (widget.user.isAdmin) 'Evaluierungs-Historie',
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -71,7 +77,10 @@ class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
|
||||
FavoritesScreen(apiClient: widget.apiClient),
|
||||
CorporateCalendarScreen(apiClient: widget.apiClient),
|
||||
TradesFeedScreen(apiClient: widget.apiClient, signalRService: widget.signalRService),
|
||||
if (widget.user.isAdmin) BotControlPanelScreen(apiClient: widget.apiClient, signalRService: widget.signalRService),
|
||||
if (widget.user.isAdmin) BacktestVisualizerScreen(apiClient: widget.apiClient),
|
||||
if (widget.user.isAdmin) AdminUsersScreen(apiClient: widget.apiClient, signalRService: widget.signalRService),
|
||||
if (widget.user.isAdmin) AdminEvaluationHistoryScreen(apiClient: widget.apiClient),
|
||||
];
|
||||
|
||||
return BlocBuilder<ThemeCubit, ThemeState>(
|
||||
@@ -122,8 +131,14 @@ class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.star_rounded), label: 'Favoriten'),
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.calendar_month_rounded), label: 'Kalender'),
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.candlestick_chart_rounded), label: 'Trades'),
|
||||
if (widget.user.isAdmin)
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.smart_toy_outlined), label: 'Bot'),
|
||||
if (widget.user.isAdmin)
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.query_stats_rounded), label: 'Backtest'),
|
||||
if (widget.user.isAdmin)
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.admin_panel_settings_outlined), label: 'Admin'),
|
||||
if (widget.user.isAdmin)
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.history_rounded), label: 'Historie'),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -158,7 +173,10 @@ class _DesktopSidebar extends StatelessWidget {
|
||||
_navTile(activeTheme, 2, Icons.star_rounded, 'Favoriten'),
|
||||
_navTile(activeTheme, 3, Icons.calendar_month_rounded, 'Kalender'),
|
||||
_navTile(activeTheme, 4, Icons.candlestick_chart_rounded, 'Live Trades'),
|
||||
if (isAdmin) _navTile(activeTheme, 5, Icons.admin_panel_settings_outlined, 'Admin Panel'),
|
||||
if (isAdmin) _navTile(activeTheme, 5, Icons.smart_toy_outlined, 'Bot Panel'),
|
||||
if (isAdmin) _navTile(activeTheme, 6, Icons.query_stats_rounded, 'Backtest'),
|
||||
if (isAdmin) _navTile(activeTheme, 7, Icons.admin_panel_settings_outlined, 'Admin Panel'),
|
||||
if (isAdmin) _navTile(activeTheme, 8, Icons.history_rounded, 'Evaluierungs-Historie'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
+69
-62
@@ -21,10 +21,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bloc
|
||||
sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e"
|
||||
sha256: e03b235924e4f509c27b5d6b2f949200e0a91149a9818b4f65eeb56662b75413
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.4"
|
||||
version: "9.2.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -109,18 +109,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: ea2bad3c89a27635ce2d85cce4d6b199da49a5a48ec77b03e45b65a3b90922b0
|
||||
sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.10.0"
|
||||
version: "5.11.0"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: dd58dc3861eb36edb13b217efc006a1c21e5bbc341de8c229b85634fa5e362e4
|
||||
sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "2.2.1"
|
||||
equatable:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -145,6 +145,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
ffi_leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi_leak_tracker
|
||||
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -165,10 +173,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fl_chart
|
||||
sha256: "00b74ae680df6b1135bdbea00a7d1fc072a9180b7c3f3702e4b19a9943f5ed7d"
|
||||
sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.66.2"
|
||||
version: "1.2.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -178,10 +186,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_bloc
|
||||
sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a
|
||||
sha256: cf51747952201a455a1c840f8171d273be009b932c75093020f9af64f2123e38
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.6"
|
||||
version: "9.1.1"
|
||||
flutter_cache_manager:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -194,58 +202,58 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
version: "6.0.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
sha256: "15e8c8fe269fdf7d469b23008ab3df521c8b826ed345820532364c31bdebace6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
version: "11.0.0"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: ac6d76a752de0cd738334eb4b21743fc4943f449f5b6e308f18838b048c02ac0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.0"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
version: "3.0.2"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
version: "2.0.3"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
version: "2.1.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
version: "4.2.2"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -268,10 +276,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||
sha256: e3cb3ee6b47fd2472c23de6da5744796a4da195137759ddb3fbcc9467b7b3c7d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
version: "8.2.1"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -300,34 +308,34 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
||||
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.0"
|
||||
version: "0.20.3"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.0.3"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
js:
|
||||
version: "1.0.2"
|
||||
jni_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
name: jni_util
|
||||
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
version: "1.0.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -356,10 +364,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -412,10 +420,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.1"
|
||||
version: "9.5.0"
|
||||
octo_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -428,10 +436,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "3.0.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -627,10 +635,9 @@ packages:
|
||||
signalr_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: signalr_core
|
||||
sha256: "27c4ce798c8fedc2f7e3e4668c2b1dbcf6ee2a93f40ad24284b5f5bbed84529d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
path: "third_party/signalr_core"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.1.2"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
@@ -729,10 +736,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423"
|
||||
sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1+1"
|
||||
version: "3.4.1+2"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -841,10 +848,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
|
||||
sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
version: "1.2.3"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -857,10 +864,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3"
|
||||
sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.6"
|
||||
version: "1.3.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -873,10 +880,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
version: "15.3.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -897,10 +904,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
version: "6.4.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -9,15 +9,15 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_bloc: ^8.1.3
|
||||
flutter_bloc: ^9.1.1
|
||||
equatable: ^2.0.5
|
||||
dio: ^5.4.0
|
||||
signalr_core: ^1.1.2
|
||||
flutter_secure_storage: ^9.0.0
|
||||
flutter_secure_storage: ^11.0.0
|
||||
shared_preferences: ^2.2.2
|
||||
fl_chart: ^0.66.0
|
||||
google_fonts: ^6.1.0
|
||||
intl: ^0.19.0
|
||||
fl_chart: ^1.2.0
|
||||
google_fonts: ^8.2.1
|
||||
intl: ^0.20.3
|
||||
provider: ^6.1.1
|
||||
cupertino_icons: ^1.0.6
|
||||
url_launcher: ^6.3.2
|
||||
@@ -27,7 +27,18 @@ dependencies:
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# signalr_core 1.1.2 (pub.dev) selects its WebSocket transport via a `dart.library.html`
|
||||
# conditional import, which is false under the dart2wasm compile target (`flutter build
|
||||
# web --wasm`) — this makes every hub connection silently fall through to an
|
||||
# UnsupportedError stub instead of ever opening a socket. The package is unmaintained
|
||||
# (last published 2 years ago), so this vendors a copy with that one import guard swapped
|
||||
# to `dart.library.js_interop` (true under both dart2js and dart2wasm) — see
|
||||
# third_party/signalr_core/lib/src/transports/web_socket_transport.dart.
|
||||
dependency_overrides:
|
||||
signalr_core:
|
||||
path: third_party/signalr_core
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
## 1.0.0
|
||||
|
||||
- Initial version, created by Stagehand
|
||||
|
||||
## 1.0.1
|
||||
|
||||
- Formatting and other updates.
|
||||
|
||||
## 1.0.2
|
||||
|
||||
- Bug fix.
|
||||
|
||||
## 1.0.3
|
||||
|
||||
- Fixed web socket implementation. Other bugs.
|
||||
|
||||
## 1.0.4
|
||||
|
||||
- Fixed invoke method.
|
||||
|
||||
## 1.0.5
|
||||
|
||||
- Fixed typo in negotiate response - [f28f4bf](https://github.com/jamiewest/signalr_core/commit/f28f4bfa6f174dc24b2614ece057fcaaf3d121d1)
|
||||
|
||||
## 1.0.6
|
||||
|
||||
- Bug fixes - [359e1ae](https://github.com/dart-lang/http/commit/469ea67d412d4e90ade81bc672b601d4d663e685), [ab4a902](https://github.com/jamiewest/signalr_core/commit/ab4a9020c72c5ba1f9560940fade536516cb1292), [9cfeea4](https://github.com/jamiewest/signalr_core/commit/9cfeea4a86a78ce32f29a3147cf58532415ea814)
|
||||
|
||||
## 1.0.7
|
||||
|
||||
- Bug fixes - [51cba40](https://github.com/jamiewest/signalr_core/commit/51cba400e52640b7f2fdf0dd4154061d8117e778)
|
||||
|
||||
## 1.0.8
|
||||
|
||||
- Bug Fixes - [dc767b9](https://github.com/jamiewest/signalr_core/commit/dc767b943a5cf9dee6d464cae5c7e015c99deb50)
|
||||
|
||||
## 1.1.0
|
||||
|
||||
- Null safety contributed by Mol0ko [#44](https://github.com/jamiewest/signalr_core/commit/7e8eaaf7a7940c60385a076625b75f126a1292d7)
|
||||
|
||||
## 1.1.1
|
||||
|
||||
- Bug fixes contributed by janjoosse [#49](https://github.com/jamiewest/signalr_core/pull/49)
|
||||
|
||||
## 1.1.2
|
||||
|
||||
- Updated packages
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Jamie West
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
## Introduction
|
||||
|
||||
ASP.NET Core **SignalR** is an open-source library that simplifies adding real-time web functionality to apps. Real-time web functionality enables server-side code to push content to clients instantly. It's platform-independent, and can be used on both the command-line and the browser.
|
||||
|
||||
## Example
|
||||
```dart
|
||||
final connection = HubConnectionBuilder().withUrl('http://localhost:5000/chatHub',
|
||||
HttpConnectionOptions(
|
||||
logging: (level, message) => print(message),
|
||||
)).build();
|
||||
|
||||
await connection.start();
|
||||
|
||||
connection.on('ReceiveMessage', (message) {
|
||||
print(message.toString());
|
||||
});
|
||||
|
||||
await connection.invoke('SendMessage', args: ['Bob', 'Says hi!']);
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
include: package:lints/recommended.yaml
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:signalr_core/signalr_core.dart';
|
||||
|
||||
Future<void> main(List<String> arguments) async {
|
||||
final connection = HubConnectionBuilder()
|
||||
.withUrl(
|
||||
'http://localhost:5115/chatHub',
|
||||
HttpConnectionOptions(
|
||||
client: IOClient(
|
||||
HttpClient()..badCertificateCallback = (x, y, z) => true),
|
||||
logging: (level, message) => print(message),
|
||||
))
|
||||
.build();
|
||||
|
||||
await connection.start();
|
||||
|
||||
connection.on('ReceiveMessage', (message) {
|
||||
print(message.toString());
|
||||
});
|
||||
|
||||
await connection.invoke('SendMessage', args: ['Bob', 'Says hi!']);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/// An ASP.NET Core SignalR Dart Client
|
||||
///
|
||||
/// ASP.NET Core SignalR is an open-source library that simplifies adding real-time web functionality to apps.
|
||||
/// Real-time web functionality enables server-side code to push content to clients instantly.
|
||||
library signalr_core;
|
||||
|
||||
export 'src/connection.dart';
|
||||
export 'src/default_reconnect_policy.dart';
|
||||
export 'src/handshake_protocol.dart';
|
||||
export 'src/http_connection.dart';
|
||||
export 'src/http_connection_options.dart';
|
||||
export 'src/hub_connection.dart';
|
||||
export 'src/hub_connection_builder.dart';
|
||||
export 'src/hub_protocol.dart';
|
||||
export 'src/json_hub_protocol.dart';
|
||||
export 'src/logger.dart';
|
||||
export 'src/retry_policy.dart';
|
||||
export 'src/text_message_format.dart';
|
||||
export 'src/transport.dart';
|
||||
export 'src/utils.dart';
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:signalr_core/src/transport.dart' as transfer;
|
||||
import 'package:signalr_core/src/utils.dart';
|
||||
|
||||
abstract class Connection {
|
||||
Connection({
|
||||
this.features,
|
||||
this.connectionId,
|
||||
});
|
||||
|
||||
final dynamic features;
|
||||
|
||||
final String? connectionId;
|
||||
|
||||
String? baseUrl;
|
||||
|
||||
OnReceive? onreceive;
|
||||
|
||||
OnClose? onclose;
|
||||
|
||||
Future<void> start({
|
||||
transfer.TransferFormat? transferFormat = transfer.TransferFormat.binary,
|
||||
});
|
||||
|
||||
Future<void> send(dynamic data);
|
||||
|
||||
Future<void> stop({Exception? exception});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:signalr_core/src/retry_policy.dart';
|
||||
|
||||
const defaultRetryDelaysInMilliseconds = [0, 2000, 10000, 30000, null];
|
||||
|
||||
class DefaultReconnectPolicy implements RetryPolicy {
|
||||
DefaultReconnectPolicy({
|
||||
this.retryDelays = defaultRetryDelaysInMilliseconds,
|
||||
});
|
||||
|
||||
final List<int?> retryDelays;
|
||||
|
||||
@override
|
||||
int? nextRetryDelayInMilliseconds(RetryContext retryContext) {
|
||||
return retryDelays[retryContext.previousRetryCount!];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:signalr_core/src/text_message_format.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class HandshakeRequestMessage {
|
||||
HandshakeRequestMessage({
|
||||
this.protocol,
|
||||
this.version,
|
||||
});
|
||||
|
||||
final String? protocol;
|
||||
final int? version;
|
||||
}
|
||||
|
||||
class HandshakeResponseMessage {
|
||||
HandshakeResponseMessage({
|
||||
this.error,
|
||||
this.minorVersion,
|
||||
});
|
||||
|
||||
final String? error;
|
||||
final int? minorVersion;
|
||||
}
|
||||
|
||||
extension on HandshakeRequestMessage {
|
||||
Map<String, dynamic> toJson() => {
|
||||
'protocol': protocol,
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
|
||||
extension HandshakeResponseMessageExtensions on HandshakeResponseMessage {
|
||||
static HandshakeResponseMessage fromJson(Map<String, dynamic> json) {
|
||||
return HandshakeResponseMessage(
|
||||
error: json['error'] as String?,
|
||||
minorVersion: json['minorVersion'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HandshakeProtocol {
|
||||
String writeHandshakeRequest(HandshakeRequestMessage handshakeRequest) {
|
||||
return TextMessageFormat.write(json.encode(handshakeRequest.toJson()));
|
||||
}
|
||||
|
||||
Tuple2<dynamic, HandshakeResponseMessage> parseHandshakeResponse(
|
||||
dynamic data) {
|
||||
HandshakeResponseMessage _responseMessage;
|
||||
String _messageData;
|
||||
dynamic _remainingData;
|
||||
|
||||
if (data is Uint8List) {
|
||||
// Format is binary but still need to read JSON text from handshake response
|
||||
var separatorIndex = data.indexOf(TextMessageFormat.RecordSeparatorCode);
|
||||
if (separatorIndex == -1) {
|
||||
throw Exception('Message is incomplete.');
|
||||
}
|
||||
|
||||
// content before separator is handshake response
|
||||
// optional content after is additional messages
|
||||
final responseLength = separatorIndex + 1;
|
||||
_messageData = utf8.decode(data.sublist(0, responseLength));
|
||||
_remainingData = (data.length > responseLength)
|
||||
? data.sublist(responseLength, data.length)
|
||||
: null;
|
||||
} else {
|
||||
final textData = data as String;
|
||||
final separatorIndex =
|
||||
textData.indexOf(TextMessageFormat.recordSeparator);
|
||||
if (separatorIndex == -1) {
|
||||
throw Exception('Message is incomplete.');
|
||||
}
|
||||
|
||||
// content before separator is handshake response
|
||||
// optional content after is additional messages
|
||||
final responseLength = separatorIndex + 1;
|
||||
_messageData = textData.substring(0, responseLength);
|
||||
_remainingData = (textData.length > responseLength)
|
||||
? textData.substring(responseLength)
|
||||
: null;
|
||||
}
|
||||
|
||||
// At this point we should have just the single handshake message
|
||||
final messages = TextMessageFormat.parse(_messageData);
|
||||
final response = HandshakeResponseMessageExtensions.fromJson(
|
||||
json.decode(messages[0]) as Map<String, dynamic>);
|
||||
|
||||
// if (response.type) {
|
||||
// throw new Error("Expected a handshake response from the server.");
|
||||
// }
|
||||
|
||||
_responseMessage = response;
|
||||
|
||||
return Tuple2<dynamic, HandshakeResponseMessage>(
|
||||
_remainingData,
|
||||
_responseMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:signalr_core/src/connection.dart';
|
||||
import 'package:signalr_core/src/http_connection_options.dart';
|
||||
import 'package:signalr_core/src/logger.dart';
|
||||
import 'package:signalr_core/src/transport.dart';
|
||||
import 'package:signalr_core/src/transports/long_polling_transport.dart';
|
||||
import 'package:signalr_core/src/transports/server_sent_events_transport.dart';
|
||||
import 'package:signalr_core/src/transports/web_socket_transport.dart';
|
||||
import 'package:signalr_core/src/utils.dart';
|
||||
|
||||
enum ConnectionState {
|
||||
connecting,
|
||||
connected,
|
||||
disconnected,
|
||||
disconnecting,
|
||||
}
|
||||
|
||||
class NegotiateResponse {
|
||||
NegotiateResponse({
|
||||
this.connectionId,
|
||||
this.connectionToken,
|
||||
this.negotiateVersion,
|
||||
this.availableTransports,
|
||||
this.url,
|
||||
this.accessToken,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final String? connectionId;
|
||||
String? connectionToken;
|
||||
final int? negotiateVersion;
|
||||
final List<AvailableTransport>? availableTransports;
|
||||
final String? url;
|
||||
final String? accessToken;
|
||||
final String? error;
|
||||
}
|
||||
|
||||
extension NegotiateResponseExtensions on NegotiateResponse {
|
||||
static NegotiateResponse fromJson(Map<String, dynamic> json) {
|
||||
return NegotiateResponse(
|
||||
connectionId: json['connectionId'] as String?,
|
||||
connectionToken: json['connectionToken'] as String?,
|
||||
negotiateVersion: json['negotiateVersion'] as int?,
|
||||
availableTransports: AvailableTransportExtensions.listFromJson(
|
||||
json['availableTransports'] as List<dynamic>?,
|
||||
),
|
||||
url: json['url'] as String?,
|
||||
accessToken: json['accessToken'] as String?,
|
||||
error: json['error'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AvailableTransport {
|
||||
AvailableTransport({
|
||||
this.transport,
|
||||
this.transferFormats,
|
||||
});
|
||||
|
||||
final HttpTransportType? transport;
|
||||
final List<TransferFormat>? transferFormats;
|
||||
}
|
||||
|
||||
extension AvailableTransportExtensions on AvailableTransport {
|
||||
static AvailableTransport fromJson(Map<String, dynamic> json) {
|
||||
return AvailableTransport(
|
||||
transport:
|
||||
HttpTransportTypeExtensions.fromName(json['transport'] as String?),
|
||||
transferFormats: List<dynamic>.from(
|
||||
json['transferFormats'] as Iterable<dynamic>)
|
||||
.map((value) => TransferFormatExtensions.fromName(value as String))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
static List<AvailableTransport> listFromJson(List<dynamic>? json) {
|
||||
return json == null
|
||||
? <AvailableTransport>[]
|
||||
: json
|
||||
.map((value) => AvailableTransportExtensions.fromJson(
|
||||
value as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
const maxRedirects = 100;
|
||||
|
||||
class HttpConnection implements Connection {
|
||||
ConnectionState? _connectionState;
|
||||
late bool _connectionStarted;
|
||||
final http.BaseClient? _client;
|
||||
Logging? _logging;
|
||||
final HttpConnectionOptions _options;
|
||||
Transport? _transport;
|
||||
Future<void>? _startInternalFuture;
|
||||
Future<void>? _stopFuture;
|
||||
late Completer _stopCompleter;
|
||||
Exception? _stopException;
|
||||
AccessTokenFactory? _accessTokenFactory;
|
||||
TransportSendQueue? _sendQueue;
|
||||
|
||||
@override
|
||||
final dynamic features = {};
|
||||
@override
|
||||
String? baseUrl;
|
||||
@override
|
||||
String? connectionId;
|
||||
@override
|
||||
OnReceive? onreceive;
|
||||
@override
|
||||
OnClose? onclose;
|
||||
|
||||
final int negotiateVersion = 1;
|
||||
|
||||
HttpConnection({
|
||||
required String? url,
|
||||
required HttpConnectionOptions options,
|
||||
}) : baseUrl = url,
|
||||
_client = (options.client != null)
|
||||
? options.client
|
||||
: http.Client() as http.BaseClient,
|
||||
_options = options {
|
||||
_logging = (options.logging != null) ? options.logging : (l, m) => {};
|
||||
_connectionState = ConnectionState.disconnected;
|
||||
_connectionStarted = false;
|
||||
|
||||
onreceive = null;
|
||||
onclose = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> start({
|
||||
TransferFormat? transferFormat = TransferFormat.binary,
|
||||
}) async {
|
||||
_logging!(LogLevel.debug,
|
||||
'Starting connection with transfer format \'${transferFormat.toString()}\'.');
|
||||
|
||||
if (_connectionState != ConnectionState.disconnected) {
|
||||
return Future.error(
|
||||
Exception(
|
||||
'Cannot start an HttpConnection that is not in the \'Disconnected\' state.'),
|
||||
);
|
||||
}
|
||||
|
||||
_connectionState = ConnectionState.connecting;
|
||||
|
||||
_startInternalFuture = _startInternal(transferFormat: transferFormat);
|
||||
await _startInternalFuture;
|
||||
|
||||
if (_connectionState == ConnectionState.disconnecting) {
|
||||
// stop() was called and transitioned the client into the Disconnecting state.
|
||||
const message =
|
||||
'Failed to start the HttpConnection before stop() was called.';
|
||||
_logging!(LogLevel.error, message);
|
||||
|
||||
// We cannot await stopPromise inside startInternal since stopInternal awaits the startInternalPromise.
|
||||
await _stopFuture;
|
||||
|
||||
return Future.error(Exception(message));
|
||||
} else if (_connectionState as dynamic != ConnectionState.connected) {
|
||||
// stop() was called and transitioned the client into the Disconnecting state.
|
||||
const message =
|
||||
'HttpConnection.startInternal completed gracefully but didn\'t enter the connection into the connected state!';
|
||||
_logging!(LogLevel.error, message);
|
||||
return Future.error(Exception(message));
|
||||
}
|
||||
|
||||
_connectionStarted = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> send(dynamic data) {
|
||||
if (_connectionState != ConnectionState.connected) {
|
||||
return Future.error(Exception(
|
||||
'Cannot send data if the connection is not in the \'Connected\' State.'));
|
||||
}
|
||||
|
||||
_sendQueue ??= TransportSendQueue(transport: _transport);
|
||||
|
||||
// Transport will not be null if state is connected
|
||||
return _sendQueue!.send(data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop({Exception? exception}) async {
|
||||
if (_connectionState == ConnectionState.disconnected) {
|
||||
_logging!(LogLevel.debug,
|
||||
'Call to HttpConnection.stop(${exception.toString()}) ignored because the connection is already in the disconnected state.');
|
||||
return Future.value(null);
|
||||
}
|
||||
|
||||
if (_connectionState == ConnectionState.disconnecting) {
|
||||
_logging!(LogLevel.debug,
|
||||
'Call to HttpConnection.stop(${exception.toString()}) ignored because the connection is already in the disconnecting state.');
|
||||
return Future.value(null);
|
||||
}
|
||||
|
||||
_connectionState = ConnectionState.disconnecting;
|
||||
|
||||
_stopCompleter = Completer();
|
||||
|
||||
_stopFuture = _stopCompleter.future;
|
||||
|
||||
await _stopInternal(exception: exception);
|
||||
await _stopFuture;
|
||||
}
|
||||
|
||||
Future<void> _stopInternal({Exception? exception}) async {
|
||||
// Set exception as soon as possible otherwise there is a race between
|
||||
// the transport closing and providing an exception and the exception from a close message
|
||||
// We would prefer the close message exception.
|
||||
_stopException = exception;
|
||||
|
||||
try {
|
||||
await _startInternalFuture;
|
||||
} catch (e) {
|
||||
// This exception is returned to the user as a rejected Future from the start method.
|
||||
}
|
||||
|
||||
// if (_sendQueue != null) {
|
||||
// try {
|
||||
// await _sendQueue.stop();
|
||||
// } catch (e) {
|
||||
// _logging(LogLevel.error,
|
||||
// 'TransportSendQueue.stop() threw error \'${e.toString()}\'.');
|
||||
// }
|
||||
// _sendQueue = null;
|
||||
// }
|
||||
|
||||
// The transport's onclose will trigger stopConnection which will run our onclose event.
|
||||
// The transport should always be set if currently connected. If it wasn't set, it's likely because
|
||||
// stop was called during start() and start() failed.
|
||||
if (_transport != null) {
|
||||
try {
|
||||
await _transport!.stop();
|
||||
} catch (e) {
|
||||
_logging!(LogLevel.error,
|
||||
'HttpConnection.transport.stop() threw error \'${e.toString()}\'.');
|
||||
_stopConnection();
|
||||
}
|
||||
|
||||
_transport = null;
|
||||
} else {
|
||||
_logging!(LogLevel.debug,
|
||||
'HttpConnection.transport is undefined in HttpConnection.stop() because start() failed.');
|
||||
_stopConnection();
|
||||
}
|
||||
}
|
||||
|
||||
void _stopConnection({Exception? exception}) {
|
||||
_logging!(LogLevel.debug,
|
||||
'HttpConnection.stopConnection(${exception.toString()}) called while in state ${_connectionState.toString()}.');
|
||||
|
||||
_transport = null;
|
||||
|
||||
// If we have a stopError, it takes precedence over the error from the transport
|
||||
var _exception = (_stopException == null) ? exception : _stopException;
|
||||
_stopException = null;
|
||||
|
||||
if (_connectionState == ConnectionState.disconnected) {
|
||||
_logging!(LogLevel.debug,
|
||||
'Call to HttpConnection.stopConnection(${_exception.toString()}) was ignored because the connection is already in the disconnected state.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_connectionState == ConnectionState.connecting) {
|
||||
_logging!(LogLevel.warning,
|
||||
'Call to HttpConnection.stopConnection(${_exception.toString()}) was ignored because the connection is still in the connecting state.');
|
||||
throw Exception(
|
||||
'HttpConnection.stopConnection(${_exception.toString()}) was called while the connection is still in the connecting state.');
|
||||
}
|
||||
|
||||
if (_connectionState == ConnectionState.disconnecting) {
|
||||
// A call to stop() induced this call to stopConnection and needs to be completed.
|
||||
// Any stop() awaiters will be scheduled to continue after the onclose callback fires.
|
||||
_stopCompleter.complete();
|
||||
}
|
||||
|
||||
if (_exception != null) {
|
||||
_logging!(LogLevel.error,
|
||||
'Connection disconnected with error \'${_exception.toString()}\'.');
|
||||
} else {
|
||||
_logging!(LogLevel.information, 'Connection disconnected.');
|
||||
}
|
||||
|
||||
if (_sendQueue != null) {
|
||||
_sendQueue!.stop()!.catchError((e) => _logging!(LogLevel.error,
|
||||
'TransportSendQueue.stop() threw error \'${e.toString()}\'.'));
|
||||
_sendQueue = null;
|
||||
}
|
||||
|
||||
connectionId = null;
|
||||
_connectionState = ConnectionState.disconnected;
|
||||
|
||||
if (_connectionStarted) {
|
||||
_connectionStarted = false;
|
||||
try {
|
||||
if (onclose != null) {
|
||||
onclose!(_exception);
|
||||
}
|
||||
} catch (e) {
|
||||
_logging!(LogLevel.error,
|
||||
'HttpConnection.onclose(${_exception.toString()}) threw error \'${e.toString()}\'.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startInternal({required TransferFormat? transferFormat}) async {
|
||||
// Store the original base url and the access token factory since they may change
|
||||
// as part of negotiating
|
||||
var url = baseUrl;
|
||||
_accessTokenFactory = _options.accessTokenFactory;
|
||||
|
||||
try {
|
||||
if (_options.skipNegotiation) {
|
||||
if (_options.transport == HttpTransportType.webSockets) {
|
||||
// No need to add a connection ID in this case
|
||||
_transport = _constructTransport(HttpTransportType.webSockets);
|
||||
// We should just call connect directly in this case.
|
||||
// No fallback or negotiate in this case.
|
||||
await _startTransport(url: url, transferFormat: transferFormat);
|
||||
} else {
|
||||
throw Exception(
|
||||
'Negotiation can only be skipped when using the WebSocket transport directly.');
|
||||
}
|
||||
} else {
|
||||
NegotiateResponse negotiateResponse;
|
||||
var redirects = 0;
|
||||
|
||||
do {
|
||||
negotiateResponse = await _getNegotiationResponse(url!);
|
||||
// the user tries to stop the connection when it is being started
|
||||
if (_connectionState == ConnectionState.disconnecting ||
|
||||
_connectionState == ConnectionState.disconnected) {
|
||||
throw Exception('The connection was stopped during negotiation.');
|
||||
}
|
||||
|
||||
if (negotiateResponse.error != null) {
|
||||
throw Exception(negotiateResponse.error);
|
||||
}
|
||||
|
||||
// if ((negotiateResponse as dynamic).protocolVersion) {
|
||||
// throw Exception('Detected a connection attempt to an ASP.NET SignalR Server. This client only supports connecting to an ASP.NET Core SignalR Server. See https://aka.ms/signalr-core-differences for details.');
|
||||
// }
|
||||
|
||||
if (negotiateResponse.url != null) {
|
||||
url = negotiateResponse.url;
|
||||
}
|
||||
|
||||
if (negotiateResponse.accessToken != null) {
|
||||
// Replace the current access token factory with one that uses
|
||||
// the returned access token
|
||||
final accessToken = negotiateResponse.accessToken;
|
||||
_accessTokenFactory = () async => accessToken;
|
||||
}
|
||||
|
||||
redirects++;
|
||||
} while ((negotiateResponse.url != null) && redirects < maxRedirects);
|
||||
|
||||
if ((redirects == maxRedirects) && (negotiateResponse.url != null)) {
|
||||
throw Exception('Negotiate redirection limit exceeded.');
|
||||
}
|
||||
|
||||
await _createTransport(
|
||||
url, _options.transport, negotiateResponse, transferFormat);
|
||||
}
|
||||
|
||||
// TODO: Figure out how to check for dynamic properties.
|
||||
// if (_transport is LongPollingTransport) {
|
||||
// features.inherentKeepAlive = true;
|
||||
// }
|
||||
|
||||
if (_connectionState == ConnectionState.connecting) {
|
||||
// Ensure the connection transitions to the connected state prior to completing this.startInternalPromise.
|
||||
// start() will handle the case when stop was called and startInternal exits still in the disconnecting state.
|
||||
_logging!(LogLevel.debug, 'The HttpConnection connected successfully.');
|
||||
_connectionState = ConnectionState.connected;
|
||||
}
|
||||
|
||||
// stop() is waiting on us via this.startInternalPromise so keep this.transport around so it can clean up.
|
||||
// This is the only case startInternal can exit in neither the connected nor disconnected state because stopConnection()
|
||||
// will transition to the disconnected state. start() will wait for the transition using the stopPromise.
|
||||
} catch (e) {
|
||||
_logging!(
|
||||
LogLevel.error, 'Failed to start the connection: ' + e.toString());
|
||||
_connectionState = ConnectionState.disconnected;
|
||||
_transport = null;
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<NegotiateResponse> _getNegotiationResponse(String url) async {
|
||||
final headers = {};
|
||||
|
||||
if (_accessTokenFactory != null) {
|
||||
final token = await _accessTokenFactory!();
|
||||
if (token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.customHeaders != null) {
|
||||
headers.addAll(_options.customHeaders!);
|
||||
}
|
||||
|
||||
final negotiateUrl = _resolveNegotiateUrl(url);
|
||||
_logging!(LogLevel.debug, 'Sending negotiation request: $negotiateUrl.');
|
||||
|
||||
// TODO: Fix user agent header...
|
||||
//headers['X-SignalR-User-Agent'] = 'Microsoft SignalR/';
|
||||
headers['Content-Type'] = 'text/plain;charset=UTF-8';
|
||||
|
||||
try {
|
||||
final response = await _client!.post(Uri.parse(negotiateUrl),
|
||||
headers: Map<String, String>.from(headers));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return Future.error(Exception(
|
||||
'Unexpected status code returned from negotiate \'${response.statusCode}\''));
|
||||
}
|
||||
|
||||
final negotiateResponse = NegotiateResponseExtensions.fromJson(
|
||||
json.decode(response.body) as Map<String, dynamic>);
|
||||
|
||||
if ((negotiateResponse.negotiateVersion != null) &&
|
||||
negotiateResponse.negotiateVersion! < 1) {
|
||||
negotiateResponse.connectionToken = negotiateResponse.connectionId;
|
||||
}
|
||||
|
||||
if (negotiateResponse.negotiateVersion == null) {
|
||||
negotiateResponse.connectionToken = negotiateResponse.connectionId;
|
||||
}
|
||||
|
||||
return negotiateResponse;
|
||||
} catch (e) {
|
||||
_logging!(LogLevel.error,
|
||||
'Failed to complete negotiation with the server: ' + e.toString());
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
static String _resolveNegotiateUrl(String url) {
|
||||
final index = url.indexOf('?');
|
||||
var negotiateUrl = url.substring(0, index == -1 ? url.length : index);
|
||||
if (negotiateUrl[negotiateUrl.length - 1] != '/') {
|
||||
negotiateUrl += '/';
|
||||
}
|
||||
negotiateUrl += 'negotiate';
|
||||
negotiateUrl += index == -1 ? '' : url.substring(index);
|
||||
return negotiateUrl;
|
||||
}
|
||||
|
||||
Future<void> _startTransport({String? url, TransferFormat? transferFormat}) {
|
||||
if (_transport != null) {
|
||||
_transport!
|
||||
..onreceive = onreceive
|
||||
..onclose = (e) => _stopConnection(exception: e);
|
||||
return _transport!.connect(url, transferFormat);
|
||||
} else {
|
||||
return Future.value();
|
||||
}
|
||||
}
|
||||
|
||||
static String? _createConnectUrl(String? url, String? connectionToken) {
|
||||
if (connectionToken == null) {
|
||||
return url;
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(url!);
|
||||
if (uri == null) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return Uri(
|
||||
scheme: uri.scheme,
|
||||
host: uri.host,
|
||||
port: uri.port,
|
||||
path: uri.path,
|
||||
fragment: uri.fragment.isNotEmpty ? uri.fragment : null,
|
||||
queryParameters: <String, dynamic>{
|
||||
...uri.queryParameters,
|
||||
...{'id': connectionToken},
|
||||
},
|
||||
).toString();
|
||||
}
|
||||
|
||||
Future<void> _createTransport(
|
||||
String? url,
|
||||
dynamic requestedTransport,
|
||||
NegotiateResponse negotiateResponse,
|
||||
TransferFormat? requestedTransferFormat) async {
|
||||
var connectUrl = _createConnectUrl(url, negotiateResponse.connectionToken);
|
||||
if (requestedTransport is Transport) {
|
||||
_logging!(LogLevel.debug,
|
||||
'Connection was provided an instance of Transport, using that directly.');
|
||||
_transport = requestedTransport;
|
||||
await _startTransport(
|
||||
url: connectUrl, transferFormat: requestedTransferFormat);
|
||||
|
||||
connectionId = negotiateResponse.connectionId;
|
||||
return Future.value(null);
|
||||
}
|
||||
|
||||
final transportExceptions = [];
|
||||
final transports = negotiateResponse.availableTransports!;
|
||||
NegotiateResponse? negotiate = negotiateResponse;
|
||||
|
||||
for (var endpoint in transports) {
|
||||
_connectionState = ConnectionState.connecting;
|
||||
final transportOrError = _resolveTransportOrError(
|
||||
endpoint,
|
||||
requestedTransport as HttpTransportType?,
|
||||
requestedTransferFormat,
|
||||
);
|
||||
|
||||
if (transportOrError is Exception) {
|
||||
transportExceptions.add(transportOrError);
|
||||
} else {
|
||||
if (transportOrError is Transport) {
|
||||
_transport = transportOrError;
|
||||
if (negotiate == null) {
|
||||
try {
|
||||
negotiate = await _getNegotiationResponse(url!);
|
||||
} catch (ex) {
|
||||
return Future.error(ex);
|
||||
}
|
||||
connectUrl = _createConnectUrl(url, negotiate.connectionToken);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await _startTransport(
|
||||
url: connectUrl,
|
||||
transferFormat: requestedTransferFormat,
|
||||
);
|
||||
connectionId = negotiate!.connectionId;
|
||||
return Future.value(null);
|
||||
} catch (e) {
|
||||
_logging!(LogLevel.error,
|
||||
'Failed to start the transport \'${endpoint.transport}\': ${e.toString()}');
|
||||
negotiate = null;
|
||||
transportExceptions
|
||||
.add(Exception('${endpoint.transport} failed: ${e.toString()}'));
|
||||
|
||||
if (_connectionState != ConnectionState.connecting) {
|
||||
const message =
|
||||
'Failed to select transport before stop() was called.';
|
||||
_logging!(LogLevel.debug, message);
|
||||
return Future.error(Exception(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _resolveTransportOrError(
|
||||
AvailableTransport endpoint,
|
||||
HttpTransportType? requestedTransport,
|
||||
TransferFormat? requestedTransferFormat,
|
||||
) {
|
||||
final transport = endpoint.transport;
|
||||
if (transport == null) {
|
||||
_logging!(LogLevel.debug,
|
||||
'Skipping transport \'${endpoint.transport.toString()}\' because it is not supported by this client.');
|
||||
return Exception(
|
||||
'Skipping transport \'${endpoint.transport.toString()}\' because it is not supported by this client.');
|
||||
} else {
|
||||
if (_transportMatches(requestedTransport, transport)) {
|
||||
final transferFormats = endpoint.transferFormats!;
|
||||
if (transferFormats.contains(requestedTransferFormat)) {
|
||||
_logging!(LogLevel.debug,
|
||||
'Selecting transport \'${transport.toString()}\'.');
|
||||
try {
|
||||
return _constructTransport(transport);
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
} else {
|
||||
_logging!(LogLevel.debug,
|
||||
'Skipping transport \'${transport.toString()}\' because it does not support the requested transfer format \'${requestedTransferFormat.toString()}\'.');
|
||||
return Exception(
|
||||
'\'${transport.toString()}\' does not support ${requestedTransferFormat.toString()}');
|
||||
}
|
||||
} else {
|
||||
_logging!(LogLevel.debug,
|
||||
'Skipping transport \'${transport.toString()}\' because it was disabled by the client.');
|
||||
return Exception(
|
||||
'\'${transport.toString()}\' is disabled by the client.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _transportMatches(
|
||||
HttpTransportType? requestedTransport,
|
||||
HttpTransportType actualTransport,
|
||||
) {
|
||||
if (requestedTransport == null) {
|
||||
return true;
|
||||
} else {
|
||||
return requestedTransport.index == actualTransport.index;
|
||||
}
|
||||
}
|
||||
|
||||
Transport? _constructTransport(HttpTransportType transport) {
|
||||
switch (transport) {
|
||||
case HttpTransportType.none:
|
||||
break;
|
||||
case HttpTransportType.webSockets:
|
||||
return WebSocketTransport(
|
||||
accessTokenFactory: _accessTokenFactory,
|
||||
logging: _logging,
|
||||
logMessageContent: _options.logMessageContent,
|
||||
client: _client);
|
||||
case HttpTransportType.serverSentEvents:
|
||||
return ServerSentEventsTransport(
|
||||
accessTokenFactory: _accessTokenFactory,
|
||||
logMessageContent: _options.logMessageContent,
|
||||
logging: _logging,
|
||||
client: _client);
|
||||
case HttpTransportType.longPolling:
|
||||
return LongPollingTransport(
|
||||
accessTokenFactory: _accessTokenFactory,
|
||||
logMessageContent: _options.logMessageContent,
|
||||
log: _logging,
|
||||
client: _client);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class TransportSendQueue {
|
||||
final List<dynamic> _buffer = [];
|
||||
late Completer _sendBufferedData;
|
||||
bool _executing = true;
|
||||
Completer? _transportResult;
|
||||
Future<void>? _sendLoopPromise;
|
||||
|
||||
final Transport? transport;
|
||||
|
||||
TransportSendQueue({this.transport}) {
|
||||
_sendBufferedData = Completer();
|
||||
_transportResult = Completer();
|
||||
|
||||
_sendLoopPromise = sendLoop();
|
||||
}
|
||||
|
||||
Future<void> send(dynamic data) {
|
||||
_bufferData(data);
|
||||
_transportResult ??= Completer();
|
||||
return _transportResult!.future;
|
||||
}
|
||||
|
||||
Future<void>? stop() {
|
||||
_executing = false;
|
||||
_sendBufferedData.complete();
|
||||
return _sendLoopPromise;
|
||||
}
|
||||
|
||||
void _bufferData(dynamic data) {
|
||||
// TODO: I believe this is checking that the buffer contains already similar data, if not throw error.
|
||||
// fix this.
|
||||
if (_buffer.isNotEmpty) {
|
||||
//throw Exception('Expected data to be of type ${_buffer.toString()} but was of type ${data.toString()}');
|
||||
}
|
||||
|
||||
_buffer.add(data);
|
||||
|
||||
if (!_sendBufferedData.isCompleted) {
|
||||
_sendBufferedData.complete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendLoop() async {
|
||||
while (true) {
|
||||
await _sendBufferedData.future;
|
||||
|
||||
if (!_executing) {
|
||||
if (_transportResult != null) {
|
||||
_transportResult!.completeError(Exception('Connection stopped.'));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
_sendBufferedData = Completer();
|
||||
|
||||
final transportResult = _transportResult;
|
||||
_transportResult = null;
|
||||
|
||||
if (_buffer.isNotEmpty) {
|
||||
final data = (_buffer[0] is String)
|
||||
? _buffer.join('')
|
||||
: TransportSendQueue._concatBuffers(_buffer as List<ByteBuffer?>);
|
||||
|
||||
_buffer.clear();
|
||||
|
||||
try {
|
||||
await transport!.send(data);
|
||||
transportResult!.complete();
|
||||
} catch (error) {
|
||||
transportResult!.completeError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static ByteBuffer _concatBuffers(List<ByteBuffer?> byteBuffers) {
|
||||
final totalLength =
|
||||
byteBuffers.map((b) => b!.lengthInBytes).reduce((a, b) => a + b);
|
||||
final result = Uint8List(totalLength);
|
||||
|
||||
var offset = 0;
|
||||
for (final item in byteBuffers) {
|
||||
result.setAll(offset, item!.asUint8List());
|
||||
offset += item.lengthInBytes;
|
||||
}
|
||||
|
||||
return result.buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:http/http.dart';
|
||||
import 'package:signalr_core/src/transport.dart';
|
||||
import 'package:signalr_core/src/utils.dart';
|
||||
|
||||
/// Options provided to the 'withUrl' factory constructor on [HubConnection] to configure options for the HTTP-based transports.
|
||||
class HttpConnectionOptions {
|
||||
HttpConnectionOptions({
|
||||
this.client,
|
||||
this.transport,
|
||||
this.logging,
|
||||
this.accessTokenFactory,
|
||||
this.logMessageContent = false,
|
||||
this.skipNegotiation = false,
|
||||
this.withCredentials = true,
|
||||
this.customHeaders
|
||||
});
|
||||
|
||||
/// An [BaseClient] that will be used to make HTTP requests.
|
||||
final BaseClient? client;
|
||||
|
||||
/// An [HttpTransportType] or [Transport] value specifying the transport to use for the connection.
|
||||
final dynamic transport;
|
||||
|
||||
/// Configures the logger used for logging.
|
||||
///
|
||||
/// Provide an [Logger] instance, and log messages will be logged via that instance.
|
||||
final Logging? logging;
|
||||
|
||||
// custom headers sent with the negotiating HTTP request
|
||||
final Map<String, String>? customHeaders;
|
||||
|
||||
/// A function that provides an access token required for HTTP Bearer authentication.
|
||||
///
|
||||
/// A string containing the access token, or a Future that resolves to a string containing the access token.
|
||||
final AccessTokenFactory? accessTokenFactory;
|
||||
|
||||
/// A boolean indicating if message content should be logged.
|
||||
///
|
||||
/// Message content can contain sensitive user data, so this is disabled by default.
|
||||
final bool logMessageContent;
|
||||
|
||||
/// A boolean indicating if negotiation should be skipped.
|
||||
///
|
||||
/// Negotiation can only be skipped when the [transport] property is set to 'HttpTransportType.WebSockets'.
|
||||
final bool skipNegotiation;
|
||||
|
||||
/// This controls whether credentials such as cookies are sent in cross-site requests.
|
||||
///
|
||||
/// Cookies are used by many load-balancers for sticky sessions which is required when your app is deployed with multiple servers.
|
||||
final bool withCredentials;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
import 'package:signalr_core/signalr_core.dart';
|
||||
|
||||
/// A builder for configuring [HubConnection] instances.
|
||||
class HubConnectionBuilder {
|
||||
HubProtocol? _protocol;
|
||||
HttpConnectionOptions? _httpConnectionOptions;
|
||||
HttpTransportType? _httpTransportType;
|
||||
String? _url;
|
||||
RetryPolicy? reconnectPolicy;
|
||||
|
||||
/// Configures the [HubConnection] to use HTTP-based transports to connect to the specified URL.
|
||||
// ignore: avoid_returning_this
|
||||
HubConnectionBuilder withUrl(String url, [dynamic transportTypeOrOptions]) {
|
||||
_url = url;
|
||||
|
||||
if (transportTypeOrOptions != null) {
|
||||
if (transportTypeOrOptions is HttpConnectionOptions) {
|
||||
_httpConnectionOptions = transportTypeOrOptions;
|
||||
} else if (transportTypeOrOptions is HttpTransportType) {
|
||||
_httpTransportType = transportTypeOrOptions;
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Configures the [HubConnection] to use the specified Hub Protocol.
|
||||
// ignore: avoid_returning_this
|
||||
HubConnectionBuilder withHubProtocol(HubProtocol protocol) {
|
||||
_protocol = protocol;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Configures the [HubConnection] to automatically attempt to reconnect if the connection is lost.
|
||||
// ignore: avoid_returning_this
|
||||
HubConnectionBuilder withAutomaticReconnect(
|
||||
[dynamic retryDelaysOrReconnectPolicy]) {
|
||||
if (reconnectPolicy != null) {
|
||||
throw Exception('A reconnectPolicy has already been set.');
|
||||
}
|
||||
|
||||
if (retryDelaysOrReconnectPolicy == null) {
|
||||
reconnectPolicy = DefaultReconnectPolicy();
|
||||
} else if (retryDelaysOrReconnectPolicy is List) {
|
||||
reconnectPolicy = DefaultReconnectPolicy(
|
||||
retryDelays: retryDelaysOrReconnectPolicy as List<int>,
|
||||
);
|
||||
} else if (retryDelaysOrReconnectPolicy is RetryPolicy) {
|
||||
reconnectPolicy = retryDelaysOrReconnectPolicy;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Creates a [HubConnection] from the configuration options specified in this builder.
|
||||
HubConnection build() {
|
||||
// Now create the connection
|
||||
if (_url == null) {
|
||||
throw Exception(
|
||||
'The \'HubConnectionBuilder.withUrl\' method must be called before building the connection.');
|
||||
}
|
||||
|
||||
_httpConnectionOptions ??=
|
||||
HttpConnectionOptions(transport: _httpTransportType);
|
||||
|
||||
final connection =
|
||||
HttpConnection(url: _url, options: _httpConnectionOptions!);
|
||||
|
||||
return HubConnection(
|
||||
connection: connection,
|
||||
logging: (_httpConnectionOptions!.logging != null)
|
||||
? _httpConnectionOptions!.logging
|
||||
: (l, m) => {},
|
||||
protocol: (_protocol == null) ? JsonHubProtocol() : _protocol!,
|
||||
reconnectPolicy: reconnectPolicy,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:signalr_core/src/transport.dart';
|
||||
import 'package:signalr_core/src/utils.dart';
|
||||
|
||||
/// Defines the type of a Hub Message.
|
||||
enum MessageType {
|
||||
/// MessageType is not defined.
|
||||
undefined, // = 0,
|
||||
/// Indicates the message is an Invocation message and implements the [InvocationMessage] interface.
|
||||
invocation, // = 1,
|
||||
/// Indicates the message is a StreamItem message and implements the [StreamItemMessage] interface.
|
||||
streamItem, // = 2,
|
||||
/// Indicates the message is a Completion message and implements the [CompletionMessage] interface.
|
||||
completion, // = 3,
|
||||
/// Indicates the message is a Stream Invocation message and implements the [StreamInvocationMessage] interface.
|
||||
streamInvocation, // = 4,
|
||||
/// Indicates the message is a Cancel Invocation message and implements the [CancelInvocationMessage] interface.
|
||||
cancelInvocation, // = 5,
|
||||
/// Indicates the message is a Ping message and implements the [PingMessage] interface.
|
||||
ping, // = 6,
|
||||
/// Indicates the message is a Close message and implements the [CloseMessage] interface.
|
||||
close, // = 7,
|
||||
}
|
||||
|
||||
extension MessageTypeExtensions on MessageType? {
|
||||
int get value {
|
||||
switch (this) {
|
||||
case MessageType.undefined:
|
||||
return 0;
|
||||
case MessageType.invocation:
|
||||
return 1;
|
||||
case MessageType.streamItem:
|
||||
return 2;
|
||||
case MessageType.completion:
|
||||
return 3;
|
||||
case MessageType.streamInvocation:
|
||||
return 4;
|
||||
case MessageType.cancelInvocation:
|
||||
return 5;
|
||||
case MessageType.ping:
|
||||
return 6;
|
||||
case MessageType.close:
|
||||
return 7;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
String get name {
|
||||
switch (this) {
|
||||
case MessageType.undefined:
|
||||
return '0';
|
||||
case MessageType.invocation:
|
||||
return 'invocation';
|
||||
case MessageType.streamItem:
|
||||
return 'streamItem';
|
||||
case MessageType.completion:
|
||||
return 'completion';
|
||||
case MessageType.streamInvocation:
|
||||
return 'streamInvocation';
|
||||
case MessageType.cancelInvocation:
|
||||
return 'cancelInvocation';
|
||||
case MessageType.ping:
|
||||
return 'ping';
|
||||
case MessageType.close:
|
||||
return 'close';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines properties common to all Hub messages.
|
||||
abstract class HubMessage {
|
||||
const HubMessage({this.type});
|
||||
|
||||
/// A [MessageType] value indicating the type of this message.
|
||||
final MessageType? type;
|
||||
}
|
||||
|
||||
/// Defines properties common to all Hub messages relating to a specific invocation.
|
||||
abstract class HubInvocationMessage extends HubMessage {
|
||||
HubInvocationMessage({MessageType? type, this.headers, this.invocationId})
|
||||
: super(type: type);
|
||||
|
||||
/// A [MessageHeaders] dictionary containing headers attached to the message.
|
||||
final Map<String, String>? headers;
|
||||
|
||||
///The ID of the invocation relating to this message.
|
||||
///
|
||||
///This is expected to be present for StreamInvocationMessage and CompletionMessage. It may
|
||||
///be 'undefined' for an InvocationMessage if the sender does not expect a response.
|
||||
final String? invocationId;
|
||||
}
|
||||
|
||||
/// A hub message representing a non-streaming invocation.
|
||||
class InvocationMessage extends HubInvocationMessage {
|
||||
InvocationMessage(
|
||||
{this.target,
|
||||
this.arguments,
|
||||
this.streamIds,
|
||||
Map<String, String>? headers,
|
||||
String? invocationId})
|
||||
: super(
|
||||
type: MessageType.invocation,
|
||||
headers: headers,
|
||||
invocationId: invocationId);
|
||||
|
||||
/// The target method name.
|
||||
final String? target;
|
||||
|
||||
/// The target method arguments.
|
||||
final List<dynamic>? arguments;
|
||||
|
||||
/// The target method stream IDs.
|
||||
final List<String>? streamIds;
|
||||
}
|
||||
|
||||
/// A hub message representing a streaming invocation.
|
||||
class StreamInvocationMessage extends HubInvocationMessage {
|
||||
StreamInvocationMessage(
|
||||
{this.target,
|
||||
this.arguments,
|
||||
this.streamIds,
|
||||
Map<String, String>? headers,
|
||||
String? invocationId})
|
||||
: super(
|
||||
type: MessageType.streamInvocation,
|
||||
headers: headers,
|
||||
invocationId: invocationId);
|
||||
|
||||
/// The target method name.
|
||||
final String? target;
|
||||
|
||||
/// The target method arguments.
|
||||
final List<dynamic>? arguments;
|
||||
|
||||
/// The target method stream IDs.
|
||||
final List<String>? streamIds;
|
||||
}
|
||||
|
||||
/// A hub message representing a single item produced as part of a result stream.
|
||||
class StreamItemMessage extends HubInvocationMessage {
|
||||
StreamItemMessage(
|
||||
{this.item, Map<String, String>? headers, String? invocationId})
|
||||
: super(
|
||||
type: MessageType.streamItem,
|
||||
headers: headers,
|
||||
invocationId: invocationId);
|
||||
|
||||
/// The item produced by the server.
|
||||
final dynamic item;
|
||||
}
|
||||
|
||||
/// A hub message representing the result of an invocation.
|
||||
class CompletionMessage extends HubInvocationMessage with EquatableMixin {
|
||||
CompletionMessage(
|
||||
{this.error,
|
||||
this.result,
|
||||
Map<String, String>? headers,
|
||||
String? invocationId})
|
||||
: super(
|
||||
type: MessageType.completion,
|
||||
headers: headers,
|
||||
invocationId: invocationId);
|
||||
|
||||
/// The error produced by the invocation, if any.
|
||||
///
|
||||
/// Either CompletionMessage.error CompletionMessage.result must be defined, but not both.
|
||||
final String? error;
|
||||
|
||||
/// The result produced by the invocation, if any.
|
||||
///
|
||||
/// Either {@link @aspnet/signalr.CompletionMessage.error} or {@link @aspnet/signalr.CompletionMessage.result} must be defined, but not both.
|
||||
final dynamic result;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [error, result, headers, invocationId];
|
||||
}
|
||||
|
||||
/// A hub message indicating that the sender is still active.
|
||||
class PingMessage extends HubMessage with EquatableMixin {
|
||||
PingMessage() : super(type: MessageType.ping);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [type];
|
||||
}
|
||||
|
||||
/// A hub message indicating that the sender is closing the connection.
|
||||
///
|
||||
/// If {@link @aspnet/signalr.CloseMessage.error} is defined, the sender is closing the connection due to an error.
|
||||
///
|
||||
class CloseMessage extends HubMessage {
|
||||
CloseMessage({this.error, this.allowReconnect})
|
||||
: super(type: MessageType.close);
|
||||
|
||||
/// The error that triggered the close, if any.
|
||||
///
|
||||
/// If this property is undefined, the connection was closed normally and without error.
|
||||
final String? error;
|
||||
|
||||
/// If true, clients with automatic reconnects enabled should attempt to reconnect after receiving the CloseMessage.
|
||||
/// Otherwise, they should not.
|
||||
final bool? allowReconnect;
|
||||
}
|
||||
|
||||
/// A hub message sent to request that a streaming invocation be canceled.
|
||||
class CancelInvocationMessage extends HubInvocationMessage {
|
||||
CancelInvocationMessage({Map<String, String>? headers, String? invocationId})
|
||||
: super(
|
||||
type: MessageType.cancelInvocation,
|
||||
headers: headers,
|
||||
invocationId: invocationId);
|
||||
}
|
||||
|
||||
/// A protocol abstraction for communicating with SignalR Hubs.
|
||||
abstract class HubProtocol {
|
||||
HubProtocol({this.name, this.version, this.transferFormat});
|
||||
|
||||
/// The name of the protocol. This is used by SignalR to resolve the protocol between the client and server.
|
||||
final String? name;
|
||||
|
||||
/// The version of the protocol.
|
||||
final int? version;
|
||||
|
||||
/// The TransferFormat of the protocol. */
|
||||
final TransferFormat? transferFormat;
|
||||
|
||||
/// Creates an array of [HubMessage] objects from the specified serialized representation.
|
||||
///
|
||||
/// If transferFormat is 'Text', the `input` parameter must be a string, otherwise it must be an ArrayBuffer.
|
||||
///
|
||||
/// [input] A string (json), or Uint8List (binary) containing the serialized representation.
|
||||
/// [Logger] logger A logger that will be used to log messages that occur during parsing.
|
||||
|
||||
List<HubMessage?> parseMessages(Object input, Logging? logging);
|
||||
|
||||
/// Writes the specified HubMessage to a string or ArrayBuffer and returns it.
|
||||
///
|
||||
/// If transferFormat is 'Text', the result of this method will be a string, otherwise it will be an ArrayBuffer.
|
||||
///
|
||||
/// [message] The message to write.
|
||||
/// returns A string or ArrayBuffer containing the serialized representation of the message.
|
||||
|
||||
dynamic writeMessage(HubMessage message);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:signalr_core/src/hub_protocol.dart';
|
||||
import 'package:signalr_core/src/logger.dart';
|
||||
import 'package:signalr_core/src/text_message_format.dart';
|
||||
import 'package:signalr_core/src/transport.dart';
|
||||
import 'package:signalr_core/src/utils.dart';
|
||||
|
||||
const String jsonHubProtocolName = 'json';
|
||||
|
||||
/// Implements the JSON Hub Protocol.
|
||||
class JsonHubProtocol implements HubProtocol {
|
||||
@override
|
||||
String get name => jsonHubProtocolName;
|
||||
|
||||
@override
|
||||
int get version => 1;
|
||||
|
||||
@override
|
||||
TransferFormat get transferFormat => TransferFormat.text;
|
||||
|
||||
/// Creates an array of [HubMessage] objects from the specified serialized representation.
|
||||
@override
|
||||
List<HubMessage?> parseMessages(dynamic input, Logging? logging) {
|
||||
// Only JsonContent is allowed.
|
||||
if (!(input is String)) {
|
||||
throw Exception(
|
||||
'Invalid input for JSON hub protocol. Expected a string.');
|
||||
}
|
||||
|
||||
final jsonInput = input;
|
||||
final hubMessages = <HubMessage?>[];
|
||||
|
||||
// ignore: unnecessary_null_comparison
|
||||
if (input == null) {
|
||||
return hubMessages;
|
||||
}
|
||||
|
||||
// Parse the messages
|
||||
final messages = TextMessageFormat.parse(jsonInput);
|
||||
for (var message in messages) {
|
||||
final jsonData = json.decode(message);
|
||||
final messageType =
|
||||
_getMessageTypeFromJson(jsonData as Map<String, dynamic>);
|
||||
HubMessage? parsedMessage;
|
||||
|
||||
switch (messageType) {
|
||||
case MessageType.invocation:
|
||||
parsedMessage = InvocationMessageExtensions.fromJson(
|
||||
jsonData);
|
||||
_isInvocationMessage(parsedMessage as InvocationMessage);
|
||||
break;
|
||||
case MessageType.streamItem:
|
||||
parsedMessage = StreamItemMessageExtensions.fromJson(
|
||||
jsonData);
|
||||
_isStreamItemMessage(parsedMessage as StreamItemMessage);
|
||||
break;
|
||||
case MessageType.completion:
|
||||
parsedMessage = CompletionMessageExtensions.fromJson(
|
||||
jsonData);
|
||||
_isCompletionMessage(parsedMessage as CompletionMessage);
|
||||
break;
|
||||
case MessageType.ping:
|
||||
parsedMessage =
|
||||
PingMessageExtensions.fromJson(jsonData);
|
||||
// Single value, no need to validate
|
||||
break;
|
||||
case MessageType.close:
|
||||
parsedMessage =
|
||||
CloseMessageExtensions.fromJson(jsonData);
|
||||
// All optional values, no need to validate
|
||||
break;
|
||||
default:
|
||||
// Future protocol changes can add message types, old clients can ignore them
|
||||
logging!(
|
||||
LogLevel.information,
|
||||
'Unknown message type \'' +
|
||||
messageType.toString() +
|
||||
'\' ignored.');
|
||||
continue;
|
||||
}
|
||||
hubMessages.add(parsedMessage);
|
||||
}
|
||||
|
||||
return hubMessages;
|
||||
}
|
||||
|
||||
/// Writes the specified [HubMessage] to a string and returns it.
|
||||
@override
|
||||
String? writeMessage(HubMessage message) {
|
||||
switch (message.type) {
|
||||
case MessageType.undefined:
|
||||
break;
|
||||
case MessageType.invocation:
|
||||
return TextMessageFormat.write(
|
||||
json.encode((message as InvocationMessage).toJson()));
|
||||
case MessageType.streamItem:
|
||||
return TextMessageFormat.write(
|
||||
json.encode((message as StreamItemMessage).toJson()));
|
||||
case MessageType.completion:
|
||||
return TextMessageFormat.write(
|
||||
json.encode((message as CompletionMessage).toJson()));
|
||||
case MessageType.streamInvocation:
|
||||
return TextMessageFormat.write(
|
||||
json.encode((message as StreamInvocationMessage).toJson()));
|
||||
case MessageType.cancelInvocation:
|
||||
return TextMessageFormat.write(
|
||||
json.encode((message as CancelInvocationMessage).toJson()));
|
||||
case MessageType.ping:
|
||||
return TextMessageFormat.write(
|
||||
json.encode((message as PingMessage).toJson()));
|
||||
case MessageType.close:
|
||||
return TextMessageFormat.write(
|
||||
json.encode((message as CloseMessage).toJson()));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static MessageType _getMessageTypeFromJson(Map<String, dynamic> json) {
|
||||
switch (json['type'] as int?) {
|
||||
case 0:
|
||||
return MessageType.undefined;
|
||||
case 1:
|
||||
return MessageType.invocation;
|
||||
case 2:
|
||||
return MessageType.streamItem;
|
||||
case 3:
|
||||
return MessageType.completion;
|
||||
case 4:
|
||||
return MessageType.streamInvocation;
|
||||
case 5:
|
||||
return MessageType.cancelInvocation;
|
||||
case 6:
|
||||
return MessageType.ping;
|
||||
case 7:
|
||||
return MessageType.close;
|
||||
default:
|
||||
return MessageType.undefined;
|
||||
}
|
||||
}
|
||||
|
||||
void _isInvocationMessage(InvocationMessage message) {
|
||||
_assertNotEmptyString(
|
||||
message.target, 'Invalid payload for Invocation message.');
|
||||
|
||||
if (message.invocationId != null) {
|
||||
_assertNotEmptyString(
|
||||
message.target, 'Invalid payload for Invocation message.');
|
||||
}
|
||||
}
|
||||
|
||||
void _isStreamItemMessage(StreamItemMessage message) {
|
||||
_assertNotEmptyString(
|
||||
message.invocationId, 'Invalid payload for StreamItem message.');
|
||||
|
||||
if (message.item == null) {
|
||||
throw Exception('Invalid payload for StreamItem message.');
|
||||
}
|
||||
}
|
||||
|
||||
void _isCompletionMessage(CompletionMessage message) {
|
||||
if ((message.result == null) && (message.error != null)) {
|
||||
_assertNotEmptyString(
|
||||
message.error, 'Invalid payload for Completion message.');
|
||||
}
|
||||
|
||||
_assertNotEmptyString(
|
||||
message.invocationId, 'Invalid payload for Completion message.');
|
||||
}
|
||||
|
||||
void _assertNotEmptyString(dynamic value, String errorMessage) {
|
||||
if ((value is String == false) || (value as String).isEmpty) {
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension InvocationMessageExtensions on InvocationMessage {
|
||||
static InvocationMessage fromJson(Map<String, dynamic> json) {
|
||||
return InvocationMessage(
|
||||
target: json['target'] as String?,
|
||||
arguments: json['arguments'] as List?,
|
||||
headers: json['headers'] as Map<String, String>?,
|
||||
invocationId: json['invocationId'] as String?,
|
||||
streamIds: json['streamIds'] as List<String>?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
if (invocationId != null) 'invocationId': invocationId,
|
||||
'target': target,
|
||||
'arguments': arguments ?? [],
|
||||
if (streamIds != null) 'streamIds': streamIds
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension StreamInvocationMessageExtensions on StreamInvocationMessage {
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
'invocationId': invocationId,
|
||||
'target': target,
|
||||
'arguments': arguments,
|
||||
'streamIds': streamIds
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension StreamItemMessageExtensions on StreamItemMessage {
|
||||
static StreamItemMessage fromJson(Map<String, dynamic> json) {
|
||||
return StreamItemMessage(
|
||||
item: json['item'] as dynamic,
|
||||
headers: json['headers'] as Map<String, String>?,
|
||||
invocationId: json['invocationId'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
'item': item,
|
||||
'invocationId': invocationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension CancelInvocationMessageExtensions on CancelInvocationMessage {
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
'invocationId': invocationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension CompletionMessageExtensions on CompletionMessage {
|
||||
static CompletionMessage fromJson(Map<String, dynamic> json) {
|
||||
return CompletionMessage(
|
||||
result: json['result'],
|
||||
error: json['error'] as String?,
|
||||
headers: json['headers'] as Map<String, String>?,
|
||||
invocationId: json['invocationId'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
'invocationId': invocationId,
|
||||
'result': result,
|
||||
'error': error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension PingMessageExtensions on PingMessage {
|
||||
static PingMessage fromJson(Map<String, dynamic> json) {
|
||||
return PingMessage();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension CloseMessageExtensions on CloseMessage {
|
||||
static CloseMessage fromJson(Map<String, dynamic> json) {
|
||||
return CloseMessage(error: json['error'] as String?);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
'error': error,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// Log Levels are ordered in increasing severity. So `Debug` is more severe than `Trace`, etc.
|
||||
enum LogLevel {
|
||||
/// Log level for very low severity diagnostic messages.
|
||||
trace,
|
||||
|
||||
/// Log level for low severity diagnostic messages.
|
||||
debug,
|
||||
|
||||
/// Log level for informational diagnostic messages.
|
||||
information,
|
||||
|
||||
/// Log level for diagnostic messages that indicate a non-fatal problem.
|
||||
warning,
|
||||
|
||||
/// Log level for diagnostic messages that indicate a failure in the current operation.
|
||||
error,
|
||||
|
||||
/// Log level for diagnostic messages that indicate a failure that will terminate the entire application.
|
||||
critical,
|
||||
|
||||
/// The highest possible log level. Used when configuring logging to indicate that no log messages should be emitted.
|
||||
none,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// An abstraction that controls when the client attempts to reconnect and how many attempts to do so.
|
||||
abstract class RetryPolicy {
|
||||
/// Called after the transport loses the connection.
|
||||
int? nextRetryDelayInMilliseconds(RetryContext retryContext);
|
||||
}
|
||||
|
||||
class RetryContext {
|
||||
const RetryContext({
|
||||
this.previousRetryCount,
|
||||
this.elapsedMilliseconds,
|
||||
this.retryReason,
|
||||
});
|
||||
|
||||
/// The number of consecutive failed tries so far.
|
||||
final int? previousRetryCount;
|
||||
|
||||
/// The amount of time in milliseconds spent retrying so far.
|
||||
final int? elapsedMilliseconds;
|
||||
|
||||
/// The error that forced the upcoming retry.
|
||||
final Exception? retryReason;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user