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

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